Files
botino/src/modules/3-turn-engine/orderModel.js
Lucas Tettamanti 423a99ab48 Productos: allowed_order_units + fix sync que pisaba overrides
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>
2026-05-18 00:40:12 -03:00

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 };
}