Introduce un campo nuevo allowed_order_units por producto (array kg/unit) que separa "cómo se vende" de "cómo se puede pedir". Productos sell_unit=unit solo aceptan unit (regla dura aplicada por código y handler). Productos sell_unit=kg defaultean a ambos, editable desde la UI con checkboxes. Fixea bug crítico de wooSnapshot.upsertSnapshotItems que pisaba raw entero en cada sync, perdiendo overrides locales (_sell_unit_override, _categories_override y el nuevo _allowed_order_units). El UPDATE ahora mergea preservando los overrides. Las tools del agente (addToCart, setQuantity, selectCandidate) validan unit contra allowed_order_units y devuelven unit_not_allowed para que el LLM re-pregunte. workingMemory expone allowed_order_units y needs_unit_choice. buildLineItems agrega meta_data unit/sell_unit_product/unit_mode_mismatch y omite subtotal/total cuando hay mismatch para que Woo recalcule. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
370 lines
11 KiB
JavaScript
370 lines
11 KiB
JavaScript
/**
|
|
* 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 };
|
|
}
|