productos, equivalencias, cross-sell y cantidades

This commit is contained in:
Lucas Tettamanti
2026-01-18 18:28:28 -03:00
parent 8cc4744c49
commit c7c56ddbfc
32 changed files with 4083 additions and 2073 deletions

View File

@@ -0,0 +1,858 @@
/**
* 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";
// ─────────────────────────────────────────────────────────────
// 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) {
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) {
const displayUnit = 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,
display_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 },
};
}
// 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;
}