/** * Modelo unificado de orden para el contexto de conversación. * * Reemplaza: order_basket, pending_items, pending_clarification, pending_item, * 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 export const PendingStatus = Object.freeze({ NEEDS_TYPE: "NEEDS_TYPE", // Necesita seleccionar producto de opciones NEEDS_QUANTITY: "NEEDS_QUANTITY", // Necesita especificar cantidad READY: "READY", // Listo para mover a cart }); /** * Crea una orden vacía */ export function createEmptyOrder() { return { cart: [], // Items confirmados: [{ woo_id, qty, unit, name, price }] pending: [], // Items por clarificar is_delivery: null, // true | false | null shipping_address: null, woo_order_id: null, pending_location: null, // { lat, lng, label?, received_at } — última ubicación compartida por WhatsApp matched_zone: null, // resumen de zona matched (id, name, cost, days, hours) delivery_window: null, // { day: "lun"|..., time: "HH:MM" } seleccionado por el cliente }; } /** * Crea un item de carrito confirmado */ export function createCartItem({ woo_id, qty, unit, name = null, price = null, allowed_order_units = null }) { return { woo_id, qty: Number(qty) || 1, unit: unit || "unit", // "kg" | "g" | "unit" name, price, allowed_order_units, }; } /** * Crea un item pendiente de clarificación */ export function createPendingItem({ id, query, candidates = [], selected_woo_id = null, selected_name = null, selected_price = null, selected_unit = null, selected_allowed_order_units = null, needs_unit_choice = false, qty = null, unit = null, status = PendingStatus.NEEDS_TYPE, requested_qty = null, requested_unit = null, }) { return { id: id || `pending_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, query, candidates, // [{ woo_id, name, price, display_unit, allowed_order_units }] selected_woo_id, // Producto elegido (null si NEEDS_TYPE) selected_name, selected_price, selected_unit, // Unidad del producto seleccionado selected_allowed_order_units, // Unidades válidas para este producto (cache) needs_unit_choice, // Si true, el LLM tiene que preguntar la unidad antes de cerrar qty, // Cantidad (null si NEEDS_QUANTITY) unit, // Unidad elegida por usuario status, requested_qty, // Cantidad pedida originalmente por el usuario (para usar después de selección) requested_unit, // Unidad pedida originalmente por el usuario }; } /** * Mueve items READY de pending a cart */ export function moveReadyToCart(order) { if (!order) return createEmptyOrder(); const newCart = [...(order.cart || [])]; const newPending = []; for (const item of (order.pending || [])) { if (item.status === PendingStatus.READY && item.selected_woo_id && item.qty) { // Buscar si ya existe en cart const existingIdx = newCart.findIndex(c => c.woo_id === item.selected_woo_id); const cartItem = createCartItem({ woo_id: item.selected_woo_id, qty: item.qty, unit: item.unit || item.selected_unit || "unit", name: item.selected_name, price: item.selected_price, allowed_order_units: item.selected_allowed_order_units || null, }); if (existingIdx >= 0) { // Actualizar cantidad existente newCart[existingIdx] = { ...newCart[existingIdx], qty: cartItem.qty, unit: cartItem.unit, }; } else { newCart.push(cartItem); } } else { newPending.push(item); } } return { ...order, cart: newCart, pending: newPending, }; } /** * Obtiene el primer item pendiente que necesita clarificación */ export function getNextPendingItem(order) { if (!Array.isArray(order?.pending)) return null; return order.pending.find(i => i.status === PendingStatus.NEEDS_TYPE || i.status === PendingStatus.NEEDS_QUANTITY ) || null; } /** * Actualiza un item pendiente por ID */ export function updatePendingItem(order, itemId, updates) { if (!order?.pending) return order; const newPending = order.pending.map(item => { if (item.id === itemId) { return { ...item, ...updates }; } return item; }); return { ...order, pending: newPending }; } /** * Agrega un nuevo item pendiente */ export function addPendingItem(order, pendingItem) { const current = order || createEmptyOrder(); return { ...current, pending: [...(current.pending || []), pendingItem], }; } /** * Remueve items del carrito que coincidan con el query * Usa fuzzy matching para encontrar el producto */ export function removeCartItem(order, productQuery) { if (!order?.cart?.length || !productQuery) return { order, removed: null }; const query = String(productQuery).toLowerCase().trim(); const words = query.split(/\s+/).filter(w => w.length > 2); // Buscar el item que mejor matchee let bestIdx = -1; let bestScore = 0; for (let i = 0; i < order.cart.length; i++) { const item = order.cart[i]; const name = String(item.name || "").toLowerCase(); // Score basado en coincidencia de palabras let score = 0; for (const word of words) { if (name.includes(word)) score += 1; } // Bonus si el query entero está en el nombre if (name.includes(query)) score += 2; if (score > bestScore) { bestScore = score; bestIdx = i; } } if (bestIdx >= 0 && bestScore > 0) { const removed = order.cart[bestIdx]; const newCart = [...order.cart]; newCart.splice(bestIdx, 1); return { order: { ...order, cart: newCart }, removed, }; } return { order, removed: null }; } /** * Actualiza la cantidad de un item en el carrito */ export function updateCartItemQuantity(order, productQuery, newQty, newUnit = null) { if (!order?.cart?.length || !productQuery) return { order, updated: null }; const query = String(productQuery).toLowerCase().trim(); const words = query.split(/\s+/).filter(w => w.length > 2); // Buscar el item que mejor matchee let bestIdx = -1; let bestScore = 0; for (let i = 0; i < order.cart.length; i++) { const item = order.cart[i]; const name = String(item.name || "").toLowerCase(); let score = 0; for (const word of words) { if (name.includes(word)) score += 1; } if (name.includes(query)) score += 2; if (score > bestScore) { bestScore = score; bestIdx = i; } } if (bestIdx >= 0 && bestScore > 0) { const updated = { ...order.cart[bestIdx] }; const newCart = [...order.cart]; newCart[bestIdx] = { ...updated, qty: newQty, unit: newUnit || updated.unit, }; return { order: { ...order, cart: newCart }, updated: newCart[bestIdx], }; } return { order, updated: null }; } /** * Convierte orden vieja (order_basket, pending_items, etc.) a nuevo formato */ export function migrateOldContext(ctx) { if (!ctx) return createEmptyOrder(); // Si ya tiene el nuevo formato if (ctx.order && (Array.isArray(ctx.order.cart) || Array.isArray(ctx.order.pending))) { return ctx.order; } const order = createEmptyOrder(); // Migrar order_basket if (ctx.order_basket?.items) { order.cart = ctx.order_basket.items.map(item => createCartItem({ woo_id: item.product_id || item.woo_id || item.woo_product_id, qty: item.quantity || item.qty || 1, unit: item.unit || "unit", name: item.label || item.name, price: item.price, })); } // Migrar pending_items if (Array.isArray(ctx.pending_items)) { order.pending = ctx.pending_items.map(item => createPendingItem({ id: item.id, query: item.query, candidates: (item.candidates || []).map(c => ({ woo_id: c.woo_product_id || c.woo_id, name: c.name, price: c.price, display_unit: c.display_unit, })), selected_woo_id: item.resolved_product?.woo_product_id || item.resolved_product?.woo_id, selected_name: item.resolved_product?.name, selected_price: item.resolved_product?.price, selected_unit: item.resolved_product?.display_unit, qty: item.quantity, unit: item.unit, status: item.status === "needs_type" ? PendingStatus.NEEDS_TYPE : item.status === "needs_quantity" ? PendingStatus.NEEDS_QUANTITY : item.status === "ready" ? PendingStatus.READY : item.status?.toUpperCase() || PendingStatus.NEEDS_TYPE, })); } // Migrar checkout info order.is_delivery = ctx.shipping_method === "delivery" ? true : ctx.shipping_method === "pickup" ? false : null; order.shipping_address = ctx.delivery_address?.text || ctx.address?.text || ctx.address_text || null; order.woo_order_id = ctx.woo_order_id || ctx.last_order_id || null; return order; } /** * Formatea el carrito para mostrar al usuario * Incluye items del carrito + items pendientes (incompletos) */ export function formatCartForDisplay(order, locale = "es-AR") { const lines = []; // Items confirmados en el carrito for (const item of (order?.cart || [])) { if (item.qty != null) { const qtyStr = item.unit === "kg" ? `${item.qty}kg` : item.unit === "g" ? `${item.qty}g` : item.unit === "unit" ? `${item.qty}` : `${item.qty}`; lines.push(`- ${qtyStr} de ${item.name || `Producto #${item.woo_id}`}`); } else { lines.push(`- ${item.name || `Producto #${item.woo_id}`} (falta cantidad)`); } } // Items pendientes (mostrar como provisionales) for (const p of (order?.pending || [])) { if (p.status === PendingStatus.NEEDS_TYPE) { lines.push(`- "${p.query}" (pendiente de elegir)`); } else if (p.status === PendingStatus.NEEDS_QUANTITY && p.selected_name) { lines.push(`- ${p.selected_name} (falta cantidad)`); } } if (lines.length === 0) { return "Tu carrito está vacío."; } return "Tenés anotado:\n" + lines.join("\n"); } /** * Formatea opciones de un pending item para mostrar al usuario */ export function formatOptionsForDisplay(pendingItem, pageSize = 12) { if (!pendingItem?.candidates?.length) { return { question: `No encontré "${pendingItem?.query}". ¿Podrías ser más específico?`, options: [] }; } const options = pendingItem.candidates.slice(0, pageSize); const hasMore = pendingItem.candidates.length > pageSize; const lines = options.map((c, i) => `${i + 1}) ${c.name}`); if (hasMore) { lines.push(`${pageSize + 1}) Mostrame más...`); } const question = `Para "${pendingItem.query}", ¿cuál de estos querés?\n` + lines.join("\n") + "\n\nRespondé con el número."; return { question, options }; }