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,39 @@
-- migrate:up
-- Nueva tabla para items de reglas de recomendación con qty/unit
create table if not exists reco_rule_items (
id bigserial primary key,
rule_id bigint not null references product_reco_rules(id) on delete cascade,
woo_product_id integer not null,
qty_per_person numeric(6,3), -- ej: 0.200 = 200g por persona
unit text, -- kg | g | unidad
reason text, -- razón opcional (ej: "base del asado")
display_order integer not null default 0,
unique (rule_id, woo_product_id)
);
create index if not exists reco_rule_items_rule_idx on reco_rule_items(rule_id);
-- Agregar tipo de regla y evento trigger a product_reco_rules
alter table product_reco_rules
add column if not exists rule_type text not null default 'crosssell',
add column if not exists trigger_event text;
-- Índice para búsqueda por tipo y evento
create index if not exists product_reco_rules_type_event_idx
on product_reco_rules(tenant_id, rule_type, trigger_event)
where active = true;
-- Migrar datos existentes: copiar recommended_product_ids a reco_rule_items
insert into reco_rule_items (rule_id, woo_product_id, display_order)
select r.id, unnest(r.recommended_product_ids), row_number() over (partition by r.id)
from product_reco_rules r
where array_length(r.recommended_product_ids, 1) > 0
on conflict (rule_id, woo_product_id) do nothing;
-- migrate:down
drop index if exists product_reco_rules_type_event_idx;
alter table product_reco_rules
drop column if exists rule_type,
drop column if exists trigger_event;
drop table if exists reco_rule_items;

View File

@@ -0,0 +1,24 @@
-- migrate:up
-- Nueva tabla para mapeos alias -> múltiples productos con score
create table if not exists alias_product_mappings (
tenant_id uuid not null references tenants(id) on delete cascade,
alias text not null,
woo_product_id integer not null,
score numeric(4,2) not null default 1.0,
created_at timestamptz not null default now(),
primary key (tenant_id, alias, woo_product_id)
);
create index if not exists alias_product_mappings_alias_idx
on alias_product_mappings(tenant_id, alias);
-- Migrar datos existentes: copiar woo_product_id de product_aliases
insert into alias_product_mappings (tenant_id, alias, woo_product_id, score)
select tenant_id, alias, woo_product_id, coalesce(boost, 1.0)
from product_aliases
where woo_product_id is not null
on conflict (tenant_id, alias, woo_product_id) do nothing;
-- migrate:down
drop table if exists alias_product_mappings;

View File

@@ -0,0 +1,12 @@
-- migrate:up
-- Agregar campo para distinguir adulto/niño
alter table reco_rule_items
add column if not exists audience_type text not null default 'adult';
-- Valores válidos: 'adult', 'child', 'all'
comment on column reco_rule_items.audience_type is 'Tipo de audiencia: adult, child, all';
-- migrate:down
alter table reco_rule_items
drop column if exists audience_type;

View File

@@ -0,0 +1,41 @@
-- migrate:up
-- Nueva tabla simplificada para cantidades por producto/evento/persona
create table if not exists product_qty_rules (
id bigserial primary key,
tenant_id uuid not null references tenants(id) on delete cascade,
woo_product_id integer not null,
event_type text not null, -- 'asado' | 'horno' | 'cumple' | 'almuerzo'
person_type text not null, -- 'adult' | 'child'
qty_per_person numeric(6,3), -- cantidad por persona
unit text not null default 'kg', -- 'kg' | 'g' | 'unidad'
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, woo_product_id, event_type, person_type)
);
create index if not exists product_qty_rules_tenant_idx on product_qty_rules(tenant_id);
create index if not exists product_qty_rules_product_idx on product_qty_rules(tenant_id, woo_product_id);
create index if not exists product_qty_rules_event_idx on product_qty_rules(tenant_id, event_type);
-- Migrar datos existentes de reco_rule_items (donde la regla es tipo qty_per_person)
insert into product_qty_rules (tenant_id, woo_product_id, event_type, person_type, qty_per_person, unit)
select
r.tenant_id,
i.woo_product_id,
coalesce(r.trigger_event, 'asado') as event_type,
coalesce(i.audience_type, 'adult') as person_type,
i.qty_per_person,
coalesce(i.unit, 'kg') as unit
from reco_rule_items i
inner join product_reco_rules r on r.id = i.rule_id
where r.rule_type = 'qty_per_person'
and i.qty_per_person is not null
on conflict (tenant_id, woo_product_id, event_type, person_type)
do update set
qty_per_person = excluded.qty_per_person,
unit = excluded.unit,
updated_at = now();
-- migrate:down
drop table if exists product_qty_rules;

View File

@@ -7,6 +7,7 @@ import "./components/users-crud.js";
import "./components/products-crud.js";
import "./components/aliases-crud.js";
import "./components/recommendations-crud.js";
import "./components/quantities-crud.js";
import { connectSSE } from "./lib/sse.js";
connectSSE();

View File

@@ -11,11 +11,14 @@ class AliasesCrud extends HTMLElement {
this.searchQuery = "";
this.editMode = null; // 'create' | 'edit' | null
// Productos mapeados con scores
this.productMappings = [];
this.shadowRoot.innerHTML = `
<style>
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:1fr 400px; gap:16px; height:100%; }
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
@@ -29,13 +32,14 @@ class AliasesCrud extends HTMLElement {
button.secondary:hover { background:#2d3e52; }
button.danger { background:#e74c3c; }
button.danger:hover { background:#c0392b; }
button.small { padding:4px 8px; font-size:11px; }
.list { flex:1; overflow-y:auto; }
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
.item:hover { border-color:#1f6feb; }
.item.active { border-color:#1f6feb; background:#111b2a; }
.item-alias { font-weight:600; color:#e7eef7; margin-bottom:4px; font-size:15px; }
.item-product { font-size:12px; color:#8aa0b5; }
.item-products { font-size:12px; color:#8aa0b5; }
.item-boost { color:#2ecc71; font-size:11px; }
.form { flex:1; overflow-y:auto; }
@@ -46,6 +50,35 @@ class AliasesCrud extends HTMLElement {
.actions { display:flex; gap:8px; margin-top:16px; }
.loading { text-align:center; padding:40px; color:#8aa0b5; }
/* Product mappings table */
.mappings-table { width:100%; border-collapse:collapse; margin-top:8px; }
.mappings-table th { text-align:left; font-size:11px; color:#8aa0b5; padding:8px 4px; border-bottom:1px solid #253245; }
.mappings-table td { padding:6px 4px; border-bottom:1px solid #1e2a3a; vertical-align:middle; }
.mappings-table input[type="number"] { width:70px; padding:6px 8px; font-size:12px; }
.mappings-table .product-name { font-size:13px; color:#e7eef7; }
.mappings-table .btn-remove { background:#e74c3c; padding:4px 8px; font-size:11px; }
.add-mapping-row { display:flex; gap:8px; margin-top:12px; align-items:flex-end; }
.add-mapping-row .field { margin-bottom:0; }
/* Product selector */
.product-selector { position:relative; }
.product-dropdown {
position:absolute; top:100%; left:0; right:0; z-index:100;
background:#0f1520; border:1px solid #253245; border-radius:8px;
max-height:200px; overflow-y:auto; display:none;
}
.product-dropdown.open { display:block; }
.product-option {
padding:8px 12px; cursor:pointer; font-size:13px; color:#e7eef7;
display:flex; justify-content:space-between; align-items:center;
}
.product-option:hover { background:#1a2535; }
.product-option .price { font-size:11px; color:#8aa0b5; }
.empty-hint { color:#8aa0b5; font-size:12px; font-style:italic; }
.badge { display:inline-block; padding:2px 6px; border-radius:999px; font-size:10px; background:#253245; color:#8aa0b5; margin-left:4px; }
</style>
<div class="container">
@@ -63,7 +96,7 @@ class AliasesCrud extends HTMLElement {
<div class="panel">
<div class="panel-title" id="formTitle">Detalle</div>
<div class="form" id="form">
<div class="form-empty">Seleccioná un alias o creá uno nuevo</div>
<div class="form-empty">Selecciona un alias o crea uno nuevo</div>
</div>
</div>
</div>
@@ -110,6 +143,11 @@ class AliasesCrud extends HTMLElement {
}
}
getProductName(id) {
const p = this.products.find(x => x.woo_product_id === id);
return p?.name || `Producto #${id}`;
}
renderList() {
const list = this.shadowRoot.getElementById("list");
@@ -128,18 +166,40 @@ class AliasesCrud extends HTMLElement {
const el = document.createElement("div");
el.className = "item" + (this.selected?.alias === item.alias ? " active" : "");
const product = this.products.find(p => p.woo_product_id === item.woo_product_id);
const productName = product?.name || `ID: ${item.woo_product_id || "—"}`;
const boost = item.boost ? `+${item.boost}` : "";
// Mostrar productos mapeados
const mappings = item.product_mappings || [];
let productsHtml = "";
if (mappings.length > 0) {
const productNames = mappings.slice(0, 3).map(m => {
const name = this.getProductName(m.woo_product_id);
return `${name} (${m.score})`;
}).join(", ");
const more = mappings.length > 3 ? ` +${mappings.length - 3} más` : "";
productsHtml = `<div class="item-products">→ ${productNames}${more}</div>`;
} else if (item.woo_product_id) {
// Fallback al producto único legacy
const productName = this.getProductName(item.woo_product_id);
const boost = item.boost ? ` <span class="item-boost">(boost: +${item.boost})</span>` : "";
productsHtml = `<div class="item-products">→ ${productName}${boost}</div>`;
} else {
productsHtml = `<div class="item-products">→ Sin productos</div>`;
}
el.innerHTML = `
<div class="item-alias">"${item.alias}"</div>
<div class="item-product">→ ${productName} ${boost ? `<span class="item-boost">(boost: ${boost})</span>` : ""}</div>
<div class="item-alias">"${item.alias}" <span class="badge">${mappings.length || (item.woo_product_id ? 1 : 0)} productos</span></div>
${productsHtml}
`;
el.onclick = () => {
this.selected = item;
this.editMode = "edit";
// Cargar mappings
this.productMappings = [...(item.product_mappings || [])];
// Si no hay mappings pero hay woo_product_id, crear uno por defecto
if (this.productMappings.length === 0 && item.woo_product_id) {
this.productMappings = [{ woo_product_id: item.woo_product_id, score: item.boost || 1.0 }];
}
this.renderList();
this.renderForm();
};
@@ -151,6 +211,7 @@ class AliasesCrud extends HTMLElement {
showCreateForm() {
this.selected = null;
this.editMode = "create";
this.productMappings = [];
this.renderList();
this.renderForm();
}
@@ -161,7 +222,7 @@ class AliasesCrud extends HTMLElement {
if (!this.editMode) {
title.textContent = "Detalle";
form.innerHTML = `<div class="form-empty">Seleccioná un alias o creá uno nuevo</div>`;
form.innerHTML = `<div class="form-empty">Selecciona un alias o crea uno nuevo</div>`;
return;
}
@@ -169,36 +230,49 @@ class AliasesCrud extends HTMLElement {
title.textContent = isCreate ? "Nuevo Alias" : "Editar Alias";
const alias = this.selected?.alias || "";
const wooProductId = this.selected?.woo_product_id || "";
const boost = this.selected?.boost || 0;
const categoryHint = this.selected?.category_hint || "";
const productOptions = this.products.map(p =>
`<option value="${p.woo_product_id}" ${p.woo_product_id === wooProductId ? "selected" : ""}>${p.name}</option>`
).join("");
form.innerHTML = `
<div class="field">
<label>Alias (lo que dice el usuario)</label>
<input type="text" id="aliasInput" value="${alias}" ${isCreate ? "" : "disabled"} placeholder="ej: chimi, vacio, bife" />
<div class="field-hint">Sin tildes, en minusculas. Ej: "chimi" mapea a "Chimichurri"</div>
</div>
<div class="field">
<label>Producto destino</label>
<select id="productInput">
<option value="">— Seleccionar producto —</option>
${productOptions}
</select>
</div>
<div class="field">
<label>Boost (puntuacion extra)</label>
<input type="number" id="boostInput" value="${boost}" min="0" max="10" step="0.1" />
<div class="field-hint">Valor entre 0 y 10. Mayor boost = mayor prioridad en resultados</div>
</div>
<div class="field">
<label>Categoria hint (opcional)</label>
<input type="text" id="categoryInput" value="${categoryHint}" placeholder="ej: carnes, bebidas" />
</div>
<div class="field">
<label>Productos que matchean</label>
<table class="mappings-table">
<thead>
<tr>
<th>Producto</th>
<th>Score (0-1)</th>
<th></th>
</tr>
</thead>
<tbody id="mappingsTableBody">
${this.renderMappingsRows()}
</tbody>
</table>
<div class="add-mapping-row">
<div class="field" style="flex:2">
<div class="product-selector" id="mappingSelector">
<input type="text" id="mappingSearch" placeholder="Buscar producto para agregar..." />
<div class="product-dropdown" id="mappingDropdown"></div>
</div>
</div>
<div class="field" style="flex:0">
<button id="addMappingBtn" class="secondary small">+ Agregar</button>
</div>
</div>
<div class="field-hint">Mayor score = mayor prioridad. Si hay ambiguedad, se pregunta al usuario.</div>
</div>
<div class="actions">
<button id="saveBtn">${isCreate ? "Crear" : "Guardar"}</button>
${!isCreate ? `<button id="deleteBtn" class="danger">Eliminar</button>` : ""}
@@ -211,28 +285,147 @@ class AliasesCrud extends HTMLElement {
if (!isCreate) {
this.shadowRoot.getElementById("deleteBtn").onclick = () => this.delete();
}
this.setupMappingsTable();
this.setupAddMappingSelector();
}
renderMappingsRows() {
if (!this.productMappings.length) {
return `<tr><td colspan="3" class="empty-hint">No hay productos mapeados</td></tr>`;
}
return this.productMappings.map((mapping, idx) => {
const name = this.getProductName(mapping.woo_product_id);
return `
<tr data-idx="${idx}">
<td class="product-name">${name}</td>
<td><input type="number" class="mapping-score" value="${mapping.score || 1}" step="0.1" min="0" max="1" /></td>
<td><button class="btn-remove small danger" data-idx="${idx}">×</button></td>
</tr>
`;
}).join("");
}
setupMappingsTable() {
const tbody = this.shadowRoot.getElementById("mappingsTableBody");
if (!tbody) return;
// Handle score changes
tbody.querySelectorAll(".mapping-score").forEach((input, idx) => {
input.onchange = () => {
this.productMappings[idx].score = parseFloat(input.value) || 1.0;
};
});
// Handle remove buttons
tbody.querySelectorAll(".btn-remove").forEach(btn => {
btn.onclick = () => {
const idx = parseInt(btn.dataset.idx, 10);
this.productMappings.splice(idx, 1);
this.renderForm();
};
});
}
setupAddMappingSelector() {
const searchInput = this.shadowRoot.getElementById("mappingSearch");
const dropdown = this.shadowRoot.getElementById("mappingDropdown");
const addBtn = this.shadowRoot.getElementById("addMappingBtn");
if (!searchInput || !dropdown) return;
let selectedProductId = null;
const renderDropdown = (query) => {
const q = (query || "").toLowerCase().trim();
const existingIds = new Set(this.productMappings.map(m => m.woo_product_id));
let filtered = this.products.filter(p => !existingIds.has(p.woo_product_id));
if (q) {
filtered = filtered.filter(p => p.name.toLowerCase().includes(q));
}
filtered = filtered.slice(0, 30);
if (!filtered.length) {
dropdown.classList.remove("open");
return;
}
dropdown.innerHTML = filtered.map(p => `
<div class="product-option" data-id="${p.woo_product_id}">
<span>${p.name}</span>
<span class="price">$${p.price || 0}</span>
</div>
`).join("");
dropdown.querySelectorAll(".product-option").forEach(opt => {
opt.onclick = () => {
selectedProductId = parseInt(opt.dataset.id, 10);
searchInput.value = this.getProductName(selectedProductId);
dropdown.classList.remove("open");
};
});
dropdown.classList.add("open");
};
searchInput.oninput = () => {
selectedProductId = null;
clearTimeout(this._mappingTimer);
this._mappingTimer = setTimeout(() => renderDropdown(searchInput.value), 150);
};
searchInput.onfocus = () => renderDropdown(searchInput.value);
addBtn.onclick = () => {
if (!selectedProductId) {
alert("Selecciona un producto primero");
return;
}
this.productMappings.push({
woo_product_id: selectedProductId,
score: 1.0,
});
searchInput.value = "";
selectedProductId = null;
this.renderForm();
};
// Close on outside click
document.addEventListener("click", (e) => {
if (!this.shadowRoot.getElementById("mappingSelector")?.contains(e.target)) {
dropdown.classList.remove("open");
}
});
}
async save() {
const aliasInput = this.shadowRoot.getElementById("aliasInput").value.trim().toLowerCase();
const productInput = this.shadowRoot.getElementById("productInput").value;
const boostInput = parseFloat(this.shadowRoot.getElementById("boostInput").value) || 0;
const categoryInput = this.shadowRoot.getElementById("categoryInput").value.trim();
if (!aliasInput) {
alert("El alias es requerido");
return;
}
if (!productInput) {
alert("Seleccioná un producto");
if (!this.productMappings.length) {
alert("Agrega al menos un producto");
return;
}
// Usar el primer producto con mayor score como woo_product_id principal (legacy)
const sortedMappings = [...this.productMappings].sort((a, b) => (b.score || 0) - (a.score || 0));
const primaryProduct = sortedMappings[0];
const data = {
alias: aliasInput,
woo_product_id: parseInt(productInput, 10),
boost: boostInput,
woo_product_id: primaryProduct.woo_product_id,
boost: primaryProduct.score || 1.0,
category_hint: categoryInput || null,
product_mappings: this.productMappings,
};
try {
@@ -243,6 +436,7 @@ class AliasesCrud extends HTMLElement {
}
this.editMode = null;
this.selected = null;
this.productMappings = [];
await this.load();
this.renderForm();
} catch (e) {
@@ -259,6 +453,7 @@ class AliasesCrud extends HTMLElement {
await api.deleteAlias(this.selected.alias);
this.editMode = null;
this.selected = null;
this.productMappings = [];
await this.load();
this.renderForm();
} catch (e) {
@@ -270,6 +465,7 @@ class AliasesCrud extends HTMLElement {
cancel() {
this.editMode = null;
this.selected = null;
this.productMappings = [];
this.renderList();
this.renderForm();
}

View File

@@ -170,14 +170,50 @@ class ConversationInspector extends HTMLElement {
if (!list.length) return "—";
return list
.map((it) => {
const label = it.label || it.name || `#${it.product_id}`;
const qty = it.quantity != null ? `${it.quantity}` : "?";
const label = it.label || it.name || `#${it.product_id || it.woo_id}`;
const qty = it.quantity ?? it.qty ?? "?";
const unit = it.unit || "";
return `${label} (${qty}${unit ? " " + unit : ""})`;
})
.join(" · ");
}
formatOrder(cart, pending, order) {
const parts = [];
// Cart items
const cartList = Array.isArray(cart) ? cart : [];
if (cartList.length > 0) {
const cartStr = cartList
.map((it) => {
const label = it.label || it.name || `#${it.product_id || it.woo_id}`;
const qty = it.quantity ?? it.qty ?? "?";
const unit = it.unit || "";
return `${label} (${qty}${unit ? " " + unit : ""})`;
})
.join(" · ");
parts.push(cartStr);
}
// Pending items
const pendingList = Array.isArray(pending) ? pending : [];
const activePending = pendingList.filter(p => p.status !== "READY");
if (activePending.length > 0) {
parts.push(`[${activePending.length} pendiente(s)]`);
}
// Checkout info
const checkoutInfo = [];
if (order?.is_delivery === true) checkoutInfo.push("🚚");
if (order?.is_delivery === false) checkoutInfo.push("🏪");
if (order?.payment_type === "cash") checkoutInfo.push("💵");
if (order?.payment_type === "link") checkoutInfo.push("💳");
if (order?.is_paid) checkoutInfo.push("✅");
if (checkoutInfo.length) parts.push(checkoutInfo.join(""));
return parts.length ? parts.join(" ") : "—";
}
toolSummary(tools = []) {
return tools.map((t) => ({
type: t.type || t.name || "tool",
@@ -218,7 +254,10 @@ class ConversationInspector extends HTMLElement {
const intent = run?.llm_output?.intent || "—";
const nextState = run?.llm_output?.next_state || "—";
const prevState = row.nextRun?.prev_state || "—";
const basket = run?.llm_output?.full_basket?.items || run?.llm_output?.basket_resolved?.items || [];
// Usar orden nueva si está disponible, sino fallback a formato viejo
const order = run?.order || {};
const basket = order.cart || run?.llm_output?.full_basket?.items || run?.llm_output?.basket_resolved?.items || [];
const pendingItems = order.pending || [];
const tools = this.toolSummary(run?.tools || []);
const llmMeta = run?.llm_output?._llm || null;
@@ -240,7 +279,7 @@ class ConversationInspector extends HTMLElement {
<div class="k">NLU</div>
<div class="v">${dir === "out" && llmMeta ? llmNote : "—"}</div>
</div>
<div class="cart"><strong>Carrito:</strong> ${dir === "out" ? this.formatCart(basket) : "—"}</div>
<div class="cart"><strong>Carrito:</strong> ${dir === "out" ? this.formatOrder(basket, pendingItems, order) : "—"}</div>
<div class="chips">
${tools
.map(

View File

@@ -16,7 +16,7 @@ class ConversationsCrud extends HTMLElement {
<style>
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:1fr 450px; gap:16px; height:100%; }
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }

View File

@@ -43,7 +43,8 @@ class OpsShell extends HTMLElement {
<button class="nav-btn" data-view="users">Usuarios</button>
<button class="nav-btn" data-view="products">Productos</button>
<button class="nav-btn" data-view="aliases">Equivalencias</button>
<button class="nav-btn" data-view="recommendations">Recomendaciones</button>
<button class="nav-btn" data-view="crosssell">Cross-sell</button>
<button class="nav-btn" data-view="quantities">Cantidades</button>
</nav>
<div class="spacer"></div>
<div class="status" id="sseStatus">SSE: connecting…</div>
@@ -81,9 +82,15 @@ class OpsShell extends HTMLElement {
</div>
</div>
<div id="viewRecommendations" class="view">
<div id="viewCrosssell" class="view">
<div class="layout-crud">
<recommendations-crud></recommendations-crud>
<recommendations-crud rule-type="crosssell"></recommendations-crud>
</div>
</div>
<div id="viewQuantities" class="view">
<div class="layout-crud">
<quantities-crud></quantities-crud>
</div>
</div>
</div>

View File

@@ -15,7 +15,7 @@ class ProductsCrud extends HTMLElement {
<style>
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:1fr 400px; gap:16px; height:100%; }
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
@@ -165,6 +165,34 @@ class ProductsCrud extends HTMLElement {
this.shadowRoot.getElementById("inStockCount").textContent = inStock;
}
/**
* Extrae todas las categorías únicas de todos los productos
*/
getAllCategories() {
const categoriesSet = new Set();
for (const p of this.items) {
try {
if (p.categories) {
const cats = typeof p.categories === 'string' ? JSON.parse(p.categories) : p.categories;
if (Array.isArray(cats)) {
cats.forEach(c => {
const name = c.name || c;
if (name) categoriesSet.add(name);
});
}
} else if (p.payload?.categories && Array.isArray(p.payload.categories)) {
p.payload.categories.forEach(c => {
const name = c.name || c;
if (name) categoriesSet.add(name);
});
}
} catch (e) { /* ignore parse errors */ }
}
return [...categoriesSet].sort((a, b) => a.localeCompare(b));
}
renderList() {
const list = this.shadowRoot.getElementById("list");
@@ -195,7 +223,8 @@ class ProductsCrud extends HTMLElement {
el.dataset.index = i;
const price = item.price != null ? `$${Number(item.price).toLocaleString()}` : "—";
const sku = item.sku || "—";
// SKU: puede venir de item.sku, item.payload.SKU (mayúsculas), o item.slug
const sku = item.sku || item.payload?.SKU || item.payload?.sku || item.slug || "—";
const stock = item.stock_status || item.payload?.stock_status || "unknown";
const stockBadge = stock === "instock"
? `<span class="badge stock">En stock</span>`
@@ -221,6 +250,8 @@ class ProductsCrud extends HTMLElement {
}
handleItemClick(e, item, index) {
console.log("[products-crud] handleItemClick", { shift: e.shiftKey, ctrl: e.ctrlKey, index, item: item?.name });
if (e.shiftKey && this.lastClickedIndex >= 0) {
// Shift+Click: seleccionar rango
const start = Math.min(this.lastClickedIndex, index);
@@ -245,18 +276,38 @@ class ProductsCrud extends HTMLElement {
this.selectedItems = [item];
}
console.log("[products-crud] after click, selectedItems:", this.selectedItems.length, this.selectedItems.map(s => s.name));
this.lastClickedIndex = index;
this.renderList();
try {
this.renderDetail();
} catch (err) {
console.error("[products-crud] Error in renderDetail:", err);
const detail = this.shadowRoot.getElementById("detail");
detail.innerHTML = `<div class="detail-empty" style="color:#e74c3c;">Error: ${err.message}</div>`;
}
// Scroll detail panel to top
const detail = this.shadowRoot.getElementById("detail");
if (detail) detail.scrollTop = 0;
}
escapeHtml(str) {
if (!str) return "";
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
renderDetail() {
const detail = this.shadowRoot.getElementById("detail");
console.log("[products-crud] renderDetail called, selectedItems:", this.selectedItems.length);
if (!this.selectedItems.length) {
detail.innerHTML = `<div class="detail-empty">Seleccioná un producto para ver detalles</div>`;
return;
@@ -264,33 +315,54 @@ class ProductsCrud extends HTMLElement {
// Si hay múltiples seleccionados, mostrar vista de edición masiva
if (this.selectedItems.length > 1) {
console.log("[products-crud] rendering multi detail");
this.renderMultiDetail();
return;
}
const p = this.selectedItems[0];
console.log("[products-crud] rendering single product:", p?.name, p?.woo_product_id);
if (!p) {
detail.innerHTML = `<div class="detail-empty">Error: producto no encontrado</div>`;
return;
}
// Categorías: pueden estar en p.categories (string JSON) o p.payload.categories (array)
let categoriesArray = [];
if (p.categories) {
try {
if (p.categories) {
const cats = typeof p.categories === 'string' ? JSON.parse(p.categories) : p.categories;
categoriesArray = Array.isArray(cats) ? cats.map(c => c.name || c) : [String(cats)];
} catch { categoriesArray = [String(p.categories)]; }
} else if (p.payload?.categories) {
} else if (p.payload?.categories && Array.isArray(p.payload.categories)) {
categoriesArray = p.payload.categories.map(c => c.name || c);
}
const categoriesText = categoriesArray.join(", ");
} catch (e) {
categoriesArray = p.categories ? [String(p.categories)] : [];
}
const categoriesText = this.escapeHtml(categoriesArray.join(", "));
const attributes = (p.attributes_normalized || p.payload?.attributes || []).map(a => `${a.name}: ${a.options?.join(", ")}`).join("; ") || "—";
const rawAttrs = p.attributes_normalized || p.payload?.attributes;
const attrsArray = Array.isArray(rawAttrs) ? rawAttrs : [];
const attributes = attrsArray.map(a => `${a.name}: ${a.options?.join(", ")}`).join("; ") || "—";
// Determinar unidad actual (de payload o inferida)
const currentUnit = p.sell_unit || p.payload?._sell_unit_override || this.inferUnit(p);
// Stock info
const stockStatus = p.stock_status || p.payload?.stock_status || "unknown";
const stockQty = p.stock_qty ?? p.stock_quantity ?? p.payload?.stock_quantity ?? null;
const stockBadgeClass = stockStatus === "instock" ? "stock" : "nostock";
const stockText = stockStatus === "instock" ? "En stock" : (stockStatus === "outofstock" ? "Sin stock" : stockStatus);
const stockQtyText = stockQty !== null ? ` (${stockQty} disponibles)` : "";
const productName = this.escapeHtml(p.name || "—");
const productSku = this.escapeHtml(p.sku || p.payload?.SKU || p.payload?.sku || p.slug || "—");
detail.innerHTML = `
<div class="field">
<label>Nombre</label>
<div class="field-value">${p.name || "—"}</div>
<div class="field-value">${productName}</div>
</div>
<div class="field">
<label>ID WooCommerce</label>
@@ -298,12 +370,18 @@ class ProductsCrud extends HTMLElement {
</div>
<div class="field">
<label>SKU</label>
<div class="field-value">${p.sku || "—"}</div>
<div class="field-value">${productSku}</div>
</div>
<div class="field">
<label>Precio</label>
<div class="field-value">${p.price != null ? `$${Number(p.price).toLocaleString()} ${p.currency || ""}` : "—"}</div>
</div>
<div class="field">
<label>Stock</label>
<div class="field-value">
<span class="badge ${stockBadgeClass}" style="margin-left:0;">${stockText}</span>${stockQtyText}
</div>
</div>
<div class="field">
<label>Unidad de venta</label>
<div style="display:flex;gap:8px;align-items:center;">
@@ -317,15 +395,32 @@ class ProductsCrud extends HTMLElement {
</div>
</div>
<div class="field">
<label>Categorías (separadas por coma)</label>
<input type="text" id="categoriesInput" value="${categoriesText}" placeholder="ej: Carnes, Vacuno, Premium" style="width:100%;" />
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
Categorías del producto, separadas por coma
<label>Categorías actuales</label>
<div id="currentCategories" style="display:flex;flex-wrap:wrap;gap:6px;min-height:30px;margin-bottom:8px;">
${categoriesArray.length > 0
? categoriesArray.map(cat => `
<span class="category-tag" data-category="${this.escapeHtml(cat)}"
style="display:inline-flex;align-items:center;gap:4px;background:#1a3a5c;color:#7eb8e7;padding:4px 8px;border-radius:6px;font-size:12px;">
${this.escapeHtml(cat)}
<span class="remove-cat" style="cursor:pointer;font-weight:bold;opacity:0.7;">&times;</span>
</span>
`).join("")
: '<span style="color:#8aa0b5;font-size:12px;">Sin categorías</span>'
}
</div>
<div style="display:flex;gap:8px;align-items:center;">
<select id="addCategorySelect" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
<option value="">-- Agregar categoría --</option>
${this.getAllCategories().filter(c => !categoriesArray.includes(c)).map(cat =>
`<option value="${this.escapeHtml(cat)}">${this.escapeHtml(cat)}</option>`
).join("")}
</select>
<button id="addCatBtn" class="secondary" style="padding:8px 12px;">+</button>
</div>
</div>
<div class="field">
<label>Atributos</label>
<div class="field-value">${attributes}</div>
<div class="field-value">${this.escapeHtml(attributes)}</div>
</div>
<div class="field">
<button id="saveProduct" style="width:100%;padding:10px;">Guardar cambios</button>
@@ -336,12 +431,75 @@ class ProductsCrud extends HTMLElement {
</div>
<div class="field">
<label>Payload completo</label>
<div class="field-value json">${JSON.stringify(p.payload || {}, null, 2)}</div>
<div class="field-value json">${this.escapeHtml(JSON.stringify(p.payload || {}, null, 2))}</div>
</div>
`;
// Bind save button
this.shadowRoot.getElementById("saveProduct").onclick = () => this.saveProduct();
// Bind add category button
this.shadowRoot.getElementById("addCatBtn").onclick = () => this.addCategoryToProduct();
// Bind remove category clicks
this.shadowRoot.querySelectorAll(".remove-cat").forEach(el => {
el.onclick = (e) => {
e.stopPropagation();
const tag = el.closest(".category-tag");
if (tag) tag.remove();
// Actualizar el select para mostrar la categoría removida
this.updateCategorySelect();
};
});
}
addCategoryToProduct() {
const select = this.shadowRoot.getElementById("addCategorySelect");
const container = this.shadowRoot.getElementById("currentCategories");
const categoryName = select.value;
if (!categoryName) return;
// Crear el tag
const tag = document.createElement("span");
tag.className = "category-tag";
tag.dataset.category = categoryName;
tag.style = "display:inline-flex;align-items:center;gap:4px;background:#1a3a5c;color:#7eb8e7;padding:4px 8px;border-radius:6px;font-size:12px;";
tag.innerHTML = `${this.escapeHtml(categoryName)}<span class="remove-cat" style="cursor:pointer;font-weight:bold;opacity:0.7;">&times;</span>`;
// Bind remove
tag.querySelector(".remove-cat").onclick = (e) => {
e.stopPropagation();
tag.remove();
this.updateCategorySelect();
};
// Remover el mensaje "Sin categorías" si existe
const emptyMsg = container.querySelector('span[style*="color:#8aa0b5"]');
if (emptyMsg) emptyMsg.remove();
container.appendChild(tag);
select.value = "";
this.updateCategorySelect();
}
updateCategorySelect() {
const select = this.shadowRoot.getElementById("addCategorySelect");
const currentTags = this.shadowRoot.querySelectorAll(".category-tag");
const currentCategories = [...currentTags].map(t => t.dataset.category);
const allCategories = this.getAllCategories();
// Rebuild options
select.innerHTML = `<option value="">-- Agregar categoría --</option>` +
allCategories
.filter(c => !currentCategories.includes(c))
.map(cat => `<option value="${this.escapeHtml(cat)}">${this.escapeHtml(cat)}</option>`)
.join("");
}
getCurrentProductCategories() {
const tags = this.shadowRoot.querySelectorAll(".category-tag");
return [...tags].map(t => t.dataset.category);
}
async saveProduct() {
@@ -350,10 +508,9 @@ class ProductsCrud extends HTMLElement {
const p = this.selectedItems[0];
const btn = this.shadowRoot.getElementById("saveProduct");
const sellUnitSelect = this.shadowRoot.getElementById("sellUnit");
const categoriesInput = this.shadowRoot.getElementById("categoriesInput");
const sell_unit = sellUnitSelect.value;
const categories = categoriesInput.value.split(",").map(s => s.trim()).filter(Boolean);
const categories = this.getCurrentProductCategories();
btn.disabled = true;
btn.textContent = "Guardando...";
@@ -387,11 +544,18 @@ class ProductsCrud extends HTMLElement {
const names = this.selectedItems.slice(0, 5).map(p => p.name).join(", ");
const moreText = count > 5 ? ` y ${count - 5} más...` : "";
// Contar stock
const inStockCount = this.selectedItems.filter(p => (p.stock_status || p.payload?.stock_status) === "instock").length;
detail.innerHTML = `
<div class="field">
<label>Productos seleccionados</label>
<div class="field-value" style="color:#2ecc71;font-weight:600;">${count} productos</div>
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">${names}${moreText}</div>
<div style="font-size:11px;margin-top:4px;">
<span class="badge stock" style="margin-left:0;">${inStockCount} en stock</span>
<span class="badge nostock">${count - inStockCount} sin stock</span>
</div>
</div>
<div class="field">
<label>Unidad de venta (para todos)</label>
@@ -400,18 +564,32 @@ class ProductsCrud extends HTMLElement {
<option value="kg">Por peso (kg)</option>
<option value="unit">Por unidad</option>
</select>
<button id="saveUnit" style="padding:8px 16px;">Guardar para ${count}</button>
<button id="saveUnit" style="padding:8px 16px;">Aplicar</button>
</div>
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
Se aplicará a todos los productos seleccionados
</div>
</div>
<div class="field">
<label>Agregar categoría (para todos)</label>
<div style="display:flex;gap:8px;align-items:center;">
<select id="addCategorySelect" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
<option value="">-- Seleccionar categoría --</option>
${this.getAllCategories().map(cat => `<option value="${this.escapeHtml(cat)}">${this.escapeHtml(cat)}</option>`).join("")}
</select>
<button id="addCategory" style="padding:8px 16px;">Agregar</button>
</div>
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
Se agregará esta categoría a todos los productos seleccionados
</div>
</div>
<div class="field">
<button id="clearSelection" class="secondary" style="width:100%;">Limpiar selección</button>
</div>
`;
this.shadowRoot.getElementById("saveUnit").onclick = () => this.saveProductUnit();
this.shadowRoot.getElementById("addCategory").onclick = () => this.addCategoryToSelected();
this.shadowRoot.getElementById("clearSelection").onclick = () => {
this.selectedItems = [];
this.lastClickedIndex = -1;
@@ -420,6 +598,63 @@ class ProductsCrud extends HTMLElement {
};
}
async addCategoryToSelected() {
if (!this.selectedItems.length) return;
const select = this.shadowRoot.getElementById("addCategorySelect");
const btn = this.shadowRoot.getElementById("addCategory");
const categoryName = select.value;
if (!categoryName) {
alert("Seleccioná una categoría");
return;
}
const count = this.selectedItems.length;
btn.disabled = true;
btn.textContent = "Guardando...";
try {
for (const p of this.selectedItems) {
// Obtener categorías actuales
let currentCats = [];
try {
if (p.categories) {
const cats = typeof p.categories === 'string' ? JSON.parse(p.categories) : p.categories;
currentCats = Array.isArray(cats) ? cats.map(c => c.name || c) : [String(cats)];
} else if (p.payload?.categories) {
currentCats = p.payload.categories.map(c => c.name || c);
}
} catch (e) { currentCats = []; }
// Agregar nueva categoría si no existe
if (!currentCats.includes(categoryName)) {
currentCats.push(categoryName);
}
// Guardar
await api.updateProduct(p.woo_product_id, {
sell_unit: p.sell_unit || 'kg',
categories: currentCats
});
// Actualizar localmente
p.categories = JSON.stringify(currentCats.map(name => ({ name })));
const idx = this.items.findIndex(i => i.woo_product_id === p.woo_product_id);
if (idx >= 0) this.items[idx].categories = p.categories;
}
select.value = "";
btn.textContent = `Agregado a ${count}!`;
setTimeout(() => { btn.textContent = "Agregar"; btn.disabled = false; }, 1500);
} catch (e) {
console.error("Error adding category:", e);
alert("Error agregando categoría: " + (e.message || e));
btn.textContent = "Agregar";
btn.disabled = false;
}
}
inferUnit(p) {
const name = String(p.name || "").toLowerCase();
const cats = (p.payload?.categories || []).map(c => String(c.name || c.slug || "").toLowerCase());

View File

@@ -0,0 +1,340 @@
import { api } from "../lib/api.js";
const FIXED_EVENTS = [
{ id: "asado", label: "Asado / Parrilla" },
{ id: "horno", label: "Horno" },
{ id: "sanguches", label: "Sanguches" },
];
const PERSON_TYPES = [
{ id: "adult", label: "Adulto" },
{ id: "child", label: "Niño" },
];
class QuantitiesCrud extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.products = [];
this.ruleCounts = new Map();
this.selectedProduct = null;
this.productRules = [];
this.loading = false;
this.searchQuery = "";
this.saving = false;
this.shadowRoot.innerHTML = `
<style>
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
input, select { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
input:focus, select:focus { outline:none; border-color:#1f6feb; }
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:#1a5fd0; }
button:disabled { opacity:.5; cursor:not-allowed; }
button.secondary { background:#253245; }
button.secondary:hover { background:#2d3e52; }
.list { flex:1; overflow-y:auto; }
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; display:flex; justify-content:space-between; align-items:center; }
.item:hover { border-color:#1f6feb; }
.item.active { border-color:#1f6feb; background:#111b2a; }
.item-name { font-weight:500; color:#e7eef7; }
.item-price { font-size:12px; color:#8aa0b5; }
.badge { display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 6px; border-radius:999px; font-size:11px; font-weight:600; background:#1f6feb; color:#fff; }
.badge.empty { background:#253245; color:#8aa0b5; }
.form { flex:1; overflow-y:auto; }
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
.product-header { margin-bottom:16px; }
.product-name { font-size:18px; font-weight:600; color:#e7eef7; margin-bottom:4px; }
.product-price { font-size:14px; color:#8aa0b5; }
.qty-grid { width:100%; border-collapse:collapse; }
.qty-grid th { text-align:left; font-size:12px; color:#8aa0b5; padding:10px 8px; border-bottom:1px solid #253245; }
.qty-grid td { padding:8px; border-bottom:1px solid #1e2a3a; }
.qty-grid .event-label { font-size:13px; color:#e7eef7; font-weight:500; }
.qty-grid input { width:70px; padding:6px 8px; font-size:12px; text-align:center; }
.qty-grid select { width:70px; padding:6px 4px; font-size:11px; }
.cell-group { display:flex; gap:4px; align-items:center; }
.actions { display:flex; gap:8px; margin-top:16px; }
.loading { text-align:center; padding:40px; color:#8aa0b5; }
.status { font-size:12px; color:#2ecc71; margin-left:auto; }
.status.error { color:#e74c3c; }
</style>
<div class="container">
<div class="panel">
<div class="panel-title">Productos</div>
<div class="toolbar">
<input type="text" id="search" placeholder="Buscar producto..." />
</div>
<div class="list" id="list">
<div class="loading">Cargando...</div>
</div>
</div>
<div class="panel">
<div class="panel-title">Cantidades por Persona</div>
<div class="form" id="form">
<div class="form-empty">Selecciona un producto para configurar cantidades</div>
</div>
</div>
</div>
`;
}
connectedCallback() {
this.shadowRoot.getElementById("search").oninput = (e) => {
this.searchQuery = e.target.value;
this.renderList();
};
this.load();
}
async load() {
this.loading = true;
this.renderList();
try {
// Cargar productos y cantidades en paralelo
const [productsData, quantitiesData] = await Promise.all([
api.products({ limit: 2000 }),
api.listQuantities(),
]);
this.products = (productsData.items || []).filter(p => p.stock_status === "instock");
// Crear mapa de conteo de reglas
this.ruleCounts = new Map();
for (const c of (quantitiesData.counts || [])) {
this.ruleCounts.set(c.woo_product_id, parseInt(c.rule_count, 10));
}
this.loading = false;
this.renderList();
} catch (e) {
console.error("Error loading:", e);
this.loading = false;
this.renderList();
}
}
renderList() {
const list = this.shadowRoot.getElementById("list");
if (this.loading) {
list.innerHTML = `<div class="loading">Cargando...</div>`;
return;
}
const q = this.searchQuery.toLowerCase().trim();
let filtered = this.products;
if (q) {
filtered = filtered.filter(p => p.name.toLowerCase().includes(q));
}
if (!filtered.length) {
list.innerHTML = `<div class="loading">No se encontraron productos</div>`;
return;
}
// Ordenar: primero los que tienen reglas, luego por nombre
filtered.sort((a, b) => {
const countA = this.ruleCounts.get(a.woo_product_id) || 0;
const countB = this.ruleCounts.get(b.woo_product_id) || 0;
if (countA !== countB) return countB - countA;
return a.name.localeCompare(b.name);
});
list.innerHTML = "";
for (const product of filtered.slice(0, 100)) {
const el = document.createElement("div");
el.className = "item" + (this.selectedProduct?.woo_product_id === product.woo_product_id ? " active" : "");
const ruleCount = this.ruleCounts.get(product.woo_product_id) || 0;
el.innerHTML = `
<div>
<div class="item-name">${product.name}</div>
<div class="item-price">$${product.price || 0} / ${product.sell_unit || 'kg'}</div>
</div>
<span class="badge ${ruleCount === 0 ? 'empty' : ''}">${ruleCount}</span>
`;
el.onclick = () => this.selectProduct(product);
list.appendChild(el);
}
}
async selectProduct(product) {
this.selectedProduct = product;
this.renderList();
// Cargar reglas del producto
try {
const data = await api.getProductQuantities(product.woo_product_id);
this.productRules = data.rules || [];
this.renderForm();
} catch (e) {
console.error("Error loading product rules:", e);
this.productRules = [];
this.renderForm();
}
}
renderForm() {
const form = this.shadowRoot.getElementById("form");
if (!this.selectedProduct) {
form.innerHTML = `<div class="form-empty">Selecciona un producto para configurar cantidades</div>`;
return;
}
const p = this.selectedProduct;
// Crear mapa de reglas existentes: "event_type:person_type" -> rule
const ruleMap = new Map();
for (const rule of this.productRules) {
const key = `${rule.event_type}:${rule.person_type}`;
ruleMap.set(key, rule);
}
// Generar filas de la grilla
const rows = FIXED_EVENTS.map(event => {
const cells = PERSON_TYPES.map(person => {
const key = `${event.id}:${person.id}`;
const rule = ruleMap.get(key);
const qty = rule?.qty_per_person ?? "";
const unit = rule?.unit || "kg";
return `
<td>
<div class="cell-group">
<input type="number"
data-event="${event.id}"
data-person="${person.id}"
class="qty-input"
value="${qty}"
step="0.01"
min="0"
placeholder="-" />
<select data-event="${event.id}" data-person="${person.id}" class="unit-select">
<option value="kg" ${unit === "kg" ? "selected" : ""}>kg</option>
<option value="g" ${unit === "g" ? "selected" : ""}>g</option>
<option value="unidad" ${unit === "unidad" ? "selected" : ""}>u</option>
</select>
</div>
</td>
`;
}).join("");
return `
<tr>
<td class="event-label">${event.label}</td>
${cells}
</tr>
`;
}).join("");
form.innerHTML = `
<div class="product-header">
<div class="product-name">${p.name}</div>
<div class="product-price">$${p.price || 0} / ${p.sell_unit || 'kg'}</div>
</div>
<table class="qty-grid">
<thead>
<tr>
<th>Evento</th>
${PERSON_TYPES.map(pt => `<th>${pt.label}</th>`).join("")}
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
<div class="actions">
<button id="saveBtn" ${this.saving ? "disabled" : ""}>Guardar</button>
<button id="clearBtn" class="secondary">Limpiar</button>
<span class="status" id="status"></span>
</div>
`;
this.shadowRoot.getElementById("saveBtn").onclick = () => this.save();
this.shadowRoot.getElementById("clearBtn").onclick = () => this.clear();
}
async save() {
if (!this.selectedProduct || this.saving) return;
// Recolectar valores de la grilla ANTES de renderizar
const rules = [];
const qtyInputs = this.shadowRoot.querySelectorAll(".qty-input");
for (const input of qtyInputs) {
const eventType = input.dataset.event;
const personType = input.dataset.person;
const qty = parseFloat(input.value);
if (!isNaN(qty) && qty > 0) {
const unitSelect = this.shadowRoot.querySelector(`.unit-select[data-event="${eventType}"][data-person="${personType}"]`);
const unit = unitSelect?.value || "kg";
rules.push({
event_type: eventType,
person_type: personType,
qty_per_person: qty,
unit,
});
}
}
this.saving = true;
this.renderForm();
try {
await api.saveProductQuantities(this.selectedProduct.woo_product_id, rules);
// Actualizar conteo local
this.ruleCounts.set(this.selectedProduct.woo_product_id, rules.length);
this.productRules = rules;
this.saving = false;
this.renderList();
this.renderForm();
const status = this.shadowRoot.getElementById("status");
status.textContent = "Guardado";
status.className = "status";
setTimeout(() => { status.textContent = ""; }, 2000);
} catch (e) {
console.error("Error saving:", e);
this.saving = false;
this.renderForm();
const status = this.shadowRoot.getElementById("status");
status.textContent = "Error al guardar";
status.className = "status error";
}
}
clear() {
const qtyInputs = this.shadowRoot.querySelectorAll(".qty-input");
for (const input of qtyInputs) {
input.value = "";
}
}
}
customElements.define("quantities-crud", QuantitiesCrud);

View File

@@ -1,6 +1,10 @@
import { api } from "../lib/api.js";
class RecommendationsCrud extends HTMLElement {
static get observedAttributes() {
return ["rule-type"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
@@ -18,11 +22,21 @@ class RecommendationsCrud extends HTMLElement {
this.selectedTriggerProducts = [];
this.selectedRecommendedProducts = [];
// Items con qty/unit para reglas qty_per_person
this.ruleItems = [];
// Tipo de regla filtrado por atributo (crosssell o qty_per_person)
this.filterRuleType = this.getAttribute("rule-type") || null;
// Tipo de regla actual en el formulario
this.currentRuleType = this.filterRuleType || "crosssell";
this.currentTriggerEvent = "";
this.shadowRoot.innerHTML = `
<style>
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:1fr 500px; gap:16px; height:100%; }
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
@@ -37,6 +51,7 @@ class RecommendationsCrud extends HTMLElement {
button.secondary:hover { background:#2d3e52; }
button.danger { background:#e74c3c; }
button.danger:hover { background:#c0392b; }
button.small { padding:4px 8px; font-size:11px; }
.list { flex:1; overflow-y:auto; }
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
@@ -49,6 +64,7 @@ class RecommendationsCrud extends HTMLElement {
.badge.active { background:#0f2a1a; color:#2ecc71; }
.badge.inactive { background:#241214; color:#e74c3c; }
.badge.priority { background:#253245; color:#8aa0b5; }
.badge.type { background:#1a2a4a; color:#5dade2; }
.form { flex:1; overflow-y:auto; }
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
@@ -95,11 +111,36 @@ class RecommendationsCrud extends HTMLElement {
.product-chip .remove:hover { background:#c0392b; }
.empty-hint { color:#8aa0b5; font-size:12px; font-style:italic; }
/* Items table styles */
.items-table { width:100%; border-collapse:collapse; margin-top:8px; }
.items-table th { text-align:left; font-size:11px; color:#8aa0b5; padding:8px 4px; border-bottom:1px solid #253245; }
.items-table td { padding:6px 4px; border-bottom:1px solid #1e2a3a; vertical-align:middle; }
.items-table input { padding:6px 8px; font-size:12px; }
.items-table input[type="number"] { width:70px; }
.items-table select { padding:6px 8px; font-size:12px; width:80px; }
.items-table .product-name { font-size:13px; color:#e7eef7; }
.items-table .btn-remove { background:#e74c3c; padding:4px 8px; font-size:11px; }
.add-item-row { display:flex; gap:8px; margin-top:12px; align-items:flex-end; }
.add-item-row .field { margin-bottom:0; }
/* Rule type selector */
.rule-type-selector { display:flex; gap:8px; margin-bottom:16px; }
.rule-type-btn {
flex:1; padding:12px; border:2px solid #253245; border-radius:8px;
background:#0f1520; color:#8aa0b5; cursor:pointer; text-align:center;
transition:all .15s;
}
.rule-type-btn:hover { border-color:#1f6feb; }
.rule-type-btn.active { border-color:#1f6feb; background:#111b2a; color:#e7eef7; }
.rule-type-btn .type-title { font-weight:600; margin-bottom:4px; }
.rule-type-btn .type-desc { font-size:11px; }
</style>
<div class="container">
<div class="panel">
<div class="panel-title">Reglas de Recomendacion</div>
<div class="panel-title" id="listTitle">Reglas</div>
<div class="toolbar">
<input type="text" id="search" placeholder="Buscar regla..." style="flex:1" />
<button id="newBtn">+ Nueva</button>
@@ -120,6 +161,20 @@ class RecommendationsCrud extends HTMLElement {
}
connectedCallback() {
// Leer atributo rule-type
this.filterRuleType = this.getAttribute("rule-type") || null;
this.currentRuleType = this.filterRuleType || "crosssell";
// Actualizar título según el tipo
const listTitle = this.shadowRoot.getElementById("listTitle");
if (this.filterRuleType === "crosssell") {
listTitle.textContent = "Reglas Cross-sell";
} else if (this.filterRuleType === "qty_per_person") {
listTitle.textContent = "Reglas de Cantidades";
} else {
listTitle.textContent = "Reglas de Recomendacion";
}
this.shadowRoot.getElementById("search").oninput = (e) => {
this.searchQuery = e.target.value;
clearTimeout(this._searchTimer);
@@ -132,6 +187,14 @@ class RecommendationsCrud extends HTMLElement {
this.loadProducts();
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "rule-type" && oldValue !== newValue) {
this.filterRuleType = newValue;
this.currentRuleType = newValue || "crosssell";
this.load();
}
}
async loadProducts() {
if (this.productsLoaded) return;
try {
@@ -150,7 +213,14 @@ class RecommendationsCrud extends HTMLElement {
try {
const data = await api.recommendations({ q: this.searchQuery, limit: 200 });
this.items = data.items || [];
let items = data.items || [];
// Filtrar por tipo si está especificado
if (this.filterRuleType) {
items = items.filter(item => (item.rule_type || "crosssell") === this.filterRuleType);
}
this.items = items;
this.loading = false;
this.renderList();
} catch (e) {
@@ -166,6 +236,10 @@ class RecommendationsCrud extends HTMLElement {
return p?.name || `Producto #${id}`;
}
getProduct(id) {
return this.allProducts.find(x => x.woo_product_id === id);
}
renderList() {
const list = this.shadowRoot.getElementById("list");
@@ -184,44 +258,79 @@ class RecommendationsCrud extends HTMLElement {
const el = document.createElement("div");
el.className = "item" + (this.selected?.id === item.id ? " active" : "");
// Mostrar productos trigger
const ruleType = item.rule_type || "crosssell";
const triggerEvent = item.trigger_event || "";
let contentHtml = "";
if (ruleType === "qty_per_person") {
contentHtml = `
<div class="item-trigger">Evento: ${triggerEvent || "General"}</div>
<div class="item-queries">Cantidades por persona configuradas</div>
`;
} else {
const triggerIds = item.trigger_product_ids || [];
const triggerNames = triggerIds.slice(0, 3).map(id => this.getProductName(id)).join(", ");
const triggerMore = triggerIds.length > 3 ? ` (+${triggerIds.length - 3})` : "";
// Mostrar productos recomendados
const recoIds = item.recommended_product_ids || [];
const recoNames = recoIds.slice(0, 3).map(id => this.getProductName(id)).join(", ");
const recoMore = recoIds.length > 3 ? ` (+${recoIds.length - 3})` : "";
el.innerHTML = `
<div class="item-key">
${item.rule_key}
<span class="badge ${item.active ? "active" : "inactive"}">${item.active ? "Activa" : "Inactiva"}</span>
<span class="badge priority">P: ${item.priority}</span>
</div>
contentHtml = `
<div class="item-trigger">Cuando piden: ${triggerNames || "—"}${triggerMore}</div>
<div class="item-queries">→ Recomendar: ${recoNames || "—"}${recoMore}</div>
`;
}
el.onclick = () => {
this.selected = item;
this.editMode = "edit";
this.selectedTriggerProducts = [...(item.trigger_product_ids || [])];
this.selectedRecommendedProducts = [...(item.recommended_product_ids || [])];
this.renderList();
this.renderForm();
};
// Solo mostrar badge de tipo si no está filtrado
const typeBadge = !this.filterRuleType
? `<span class="badge type">${ruleType === "qty_per_person" ? "Cantidades" : "Cross-sell"}</span>`
: "";
el.innerHTML = `
<div class="item-key">
${item.rule_key}
${typeBadge}
<span class="badge ${item.active ? "active" : "inactive"}">${item.active ? "Activa" : "Inactiva"}</span>
<span class="badge priority">P: ${item.priority}</span>
</div>
${contentHtml}
`;
el.onclick = () => this.selectItem(item);
list.appendChild(el);
}
}
async selectItem(item) {
// Cargar detalles incluyendo items
try {
const detail = await api.getRecommendation(item.id);
this.selected = detail || item;
} catch (e) {
this.selected = item;
}
this.editMode = "edit";
this.currentRuleType = this.selected.rule_type || "crosssell";
this.currentTriggerEvent = this.selected.trigger_event || "";
this.selectedTriggerProducts = [...(this.selected.trigger_product_ids || [])];
this.selectedRecommendedProducts = [...(this.selected.recommended_product_ids || [])];
this.ruleItems = [...(this.selected.items || [])];
this.renderList();
this.renderForm();
}
showCreateForm() {
this.selected = null;
this.editMode = "create";
// Usar el tipo filtrado si está definido
this.currentRuleType = this.filterRuleType || "crosssell";
this.currentTriggerEvent = "";
this.selectedTriggerProducts = [];
this.selectedRecommendedProducts = [];
this.ruleItems = [];
this.renderList();
this.renderForm();
}
@@ -243,10 +352,29 @@ class RecommendationsCrud extends HTMLElement {
const active = this.selected?.active !== false;
const priority = this.selected?.priority || 100;
// Solo mostrar selector de tipo si no está filtrado por atributo
const showTypeSelector = !this.filterRuleType;
form.innerHTML = `
${showTypeSelector ? `
<div class="field">
<label>Tipo de Regla</label>
<div class="rule-type-selector">
<div class="rule-type-btn ${this.currentRuleType === "crosssell" ? "active" : ""}" data-type="crosssell">
<div class="type-title">Cross-sell</div>
<div class="type-desc">Si pide A, ofrecer B, C, D</div>
</div>
<div class="rule-type-btn ${this.currentRuleType === "qty_per_person" ? "active" : ""}" data-type="qty_per_person">
<div class="type-title">Cantidades</div>
<div class="type-desc">Cantidad por persona por producto</div>
</div>
</div>
</div>
` : ""}
<div class="field">
<label>Nombre de la regla</label>
<input type="text" id="ruleKeyInput" value="${rule_key}" ${isCreate ? "" : "disabled"} placeholder="ej: asado_recos, vinos_con_carne" />
<input type="text" id="ruleKeyInput" value="${rule_key}" ${isCreate ? "" : "disabled"} placeholder="ej: asado_6_personas, vinos_con_carne" />
<div class="field-hint">Identificador unico, sin espacios</div>
</div>
@@ -264,6 +392,7 @@ class RecommendationsCrud extends HTMLElement {
</div>
</div>
<div id="crosssellFields" style="display:${this.currentRuleType === "crosssell" ? "block" : "none"}">
<div class="field">
<label>Cuando el cliente pide...</label>
<div class="product-selector" id="triggerSelector">
@@ -283,6 +412,52 @@ class RecommendationsCrud extends HTMLElement {
</div>
<div class="field-hint">Productos a sugerir al cliente</div>
</div>
</div>
<div id="qtyFields" style="display:${this.currentRuleType === "qty_per_person" ? "block" : "none"}">
<div class="field">
<label>Tipo de Evento</label>
<select id="triggerEventInput">
<option value="" ${!this.currentTriggerEvent ? "selected" : ""}>General (cualquier evento)</option>
<option value="asado" ${this.currentTriggerEvent === "asado" ? "selected" : ""}>Asado / Parrillada</option>
<option value="horno" ${this.currentTriggerEvent === "horno" ? "selected" : ""}>Horno</option>
<option value="cumple" ${this.currentTriggerEvent === "cumple" ? "selected" : ""}>Cumpleaños / Fiesta</option>
<option value="almuerzo" ${this.currentTriggerEvent === "almuerzo" ? "selected" : ""}>Almuerzo / Cena</option>
</select>
<div class="field-hint">Evento que activa esta regla de cantidades</div>
</div>
<div class="field">
<label>Productos y Cantidades por Persona</label>
<table class="items-table">
<thead>
<tr>
<th>Producto</th>
<th>Para</th>
<th>Cantidad</th>
<th>Unidad</th>
<th>Razon</th>
<th></th>
</tr>
</thead>
<tbody id="itemsTableBody">
${this.renderItemsRows()}
</tbody>
</table>
<div class="add-item-row">
<div class="field" style="flex:2">
<div class="product-selector" id="itemSelector">
<input type="text" class="product-search" id="itemSearch" placeholder="Buscar producto para agregar..." />
<div class="product-dropdown" id="itemDropdown"></div>
</div>
</div>
<div class="field" style="flex:0">
<button id="addItemBtn" class="secondary small">+ Agregar</button>
</div>
</div>
</div>
</div>
<div class="actions">
<button id="saveBtn">${isCreate ? "Crear" : "Guardar"}</button>
@@ -298,16 +473,186 @@ class RecommendationsCrud extends HTMLElement {
this.shadowRoot.getElementById("deleteBtn").onclick = () => this.delete();
}
// Setup product selectors
// Rule type selector
this.shadowRoot.querySelectorAll(".rule-type-btn").forEach(btn => {
btn.onclick = () => {
this.currentRuleType = btn.dataset.type;
this.renderForm();
};
});
// Setup product selectors for crosssell
if (this.currentRuleType === "crosssell") {
this.setupProductSelector("trigger", this.selectedTriggerProducts);
this.setupProductSelector("reco", this.selectedRecommendedProducts);
}
// Setup for qty_per_person
if (this.currentRuleType === "qty_per_person") {
this.setupItemsTable();
this.setupAddItemSelector();
}
}
renderItemsRows() {
if (!this.ruleItems.length) {
return `<tr><td colspan="6" class="empty-hint">No hay productos configurados</td></tr>`;
}
return this.ruleItems.map((item, idx) => {
const product = this.getProduct(item.woo_product_id);
const name = product?.name || `Producto #${item.woo_product_id}`;
const audience = item.audience_type || "adult";
return `
<tr data-idx="${idx}">
<td class="product-name">${name}</td>
<td>
<select class="item-audience">
<option value="adult" ${audience === "adult" ? "selected" : ""}>Adulto</option>
<option value="child" ${audience === "child" ? "selected" : ""}>Niño</option>
<option value="all" ${audience === "all" ? "selected" : ""}>Todos</option>
</select>
</td>
<td><input type="number" class="item-qty" value="${item.qty_per_person || ""}" step="0.01" min="0" placeholder="0.2" /></td>
<td>
<select class="item-unit">
<option value="kg" ${item.unit === "kg" ? "selected" : ""}>kg</option>
<option value="g" ${item.unit === "g" ? "selected" : ""}>g</option>
<option value="unidad" ${item.unit === "unidad" ? "selected" : ""}>unidad</option>
</select>
</td>
<td><input type="text" class="item-reason" value="${item.reason || ""}" placeholder="opcional" /></td>
<td><button class="btn-remove small danger" data-idx="${idx}">×</button></td>
</tr>
`;
}).join("");
}
setupItemsTable() {
const tbody = this.shadowRoot.getElementById("itemsTableBody");
if (!tbody) return;
// Handle audience changes
tbody.querySelectorAll(".item-audience").forEach((select, idx) => {
select.onchange = () => {
this.ruleItems[idx].audience_type = select.value;
};
});
// Handle qty/unit/reason changes
tbody.querySelectorAll(".item-qty").forEach((input, idx) => {
input.onchange = () => {
this.ruleItems[idx].qty_per_person = parseFloat(input.value) || null;
};
});
tbody.querySelectorAll(".item-unit").forEach((select, idx) => {
select.onchange = () => {
this.ruleItems[idx].unit = select.value;
};
});
tbody.querySelectorAll(".item-reason").forEach((input, idx) => {
input.onchange = () => {
this.ruleItems[idx].reason = input.value || null;
};
});
// Handle remove buttons
tbody.querySelectorAll(".btn-remove").forEach(btn => {
btn.onclick = () => {
const idx = parseInt(btn.dataset.idx, 10);
this.ruleItems.splice(idx, 1);
this.renderForm();
};
});
}
setupAddItemSelector() {
const searchInput = this.shadowRoot.getElementById("itemSearch");
const dropdown = this.shadowRoot.getElementById("itemDropdown");
const addBtn = this.shadowRoot.getElementById("addItemBtn");
if (!searchInput || !dropdown) return;
let selectedProductId = null;
const renderDropdown = (query) => {
const q = (query || "").toLowerCase().trim();
const existingIds = new Set(this.ruleItems.map(i => i.woo_product_id));
let filtered = this.allProducts.filter(p => !existingIds.has(p.woo_product_id));
if (q) {
filtered = filtered.filter(p => p.name.toLowerCase().includes(q));
}
filtered = filtered.slice(0, 30);
if (!filtered.length) {
dropdown.classList.remove("open");
return;
}
dropdown.innerHTML = filtered.map(p => `
<div class="product-option" data-id="${p.woo_product_id}">
<span>${p.name}</span>
<span class="price">$${p.price || 0}</span>
</div>
`).join("");
dropdown.querySelectorAll(".product-option").forEach(opt => {
opt.onclick = () => {
selectedProductId = parseInt(opt.dataset.id, 10);
searchInput.value = this.getProductName(selectedProductId);
dropdown.classList.remove("open");
};
});
dropdown.classList.add("open");
};
searchInput.oninput = () => {
selectedProductId = null;
clearTimeout(this._itemTimer);
this._itemTimer = setTimeout(() => renderDropdown(searchInput.value), 150);
};
searchInput.onfocus = () => renderDropdown(searchInput.value);
addBtn.onclick = () => {
if (!selectedProductId) {
alert("Selecciona un producto primero");
return;
}
this.ruleItems.push({
woo_product_id: selectedProductId,
audience_type: "adult",
qty_per_person: null,
unit: "kg",
reason: null,
display_order: this.ruleItems.length,
});
searchInput.value = "";
selectedProductId = null;
this.renderForm();
};
// Close on outside click
document.addEventListener("click", (e) => {
if (!this.shadowRoot.getElementById("itemSelector")?.contains(e.target)) {
dropdown.classList.remove("open");
}
});
}
setupProductSelector(type, selectedIds) {
const searchInput = this.shadowRoot.getElementById(`${type}Search`);
const dropdown = this.shadowRoot.getElementById(`${type}Dropdown`);
const selectedContainer = this.shadowRoot.getElementById(`${type}Selected`);
if (!searchInput || !dropdown || !selectedContainer) return;
const renderSelected = () => {
const ids = type === "trigger" ? this.selectedTriggerProducts : this.selectedRecommendedProducts;
if (!ids.length) {
@@ -342,7 +687,7 @@ class RecommendationsCrud extends HTMLElement {
if (q) {
filtered = filtered.filter(p => p.name.toLowerCase().includes(q));
}
filtered = filtered.slice(0, 50); // Limit for performance
filtered = filtered.slice(0, 50);
if (!q && !filtered.length) {
dropdown.classList.remove("open");
@@ -391,7 +736,6 @@ class RecommendationsCrud extends HTMLElement {
}
};
// Close dropdown on outside click
document.addEventListener("click", (e) => {
if (!this.shadowRoot.getElementById(`${type}Selector`)?.contains(e.target)) {
dropdown.classList.remove("open");
@@ -411,26 +755,50 @@ class RecommendationsCrud extends HTMLElement {
return;
}
const data = {
rule_key: ruleKey,
trigger: {},
queries: [],
ask_slots: [],
active,
priority,
rule_type: this.currentRuleType,
trigger_event: null,
trigger_product_ids: [],
recommended_product_ids: [],
items: [],
};
if (this.currentRuleType === "crosssell") {
if (!this.selectedTriggerProducts.length) {
alert("Selecciona al menos un producto trigger");
return;
}
if (!this.selectedRecommendedProducts.length) {
alert("Selecciona al menos un producto para recomendar");
return;
}
data.trigger_product_ids = this.selectedTriggerProducts;
data.recommended_product_ids = this.selectedRecommendedProducts;
} else {
// qty_per_person
const triggerEvent = this.shadowRoot.getElementById("triggerEventInput")?.value || null;
data.trigger_event = triggerEvent;
const data = {
rule_key: ruleKey,
trigger: {}, // Legacy field, keep empty
queries: [], // Legacy field, keep empty
ask_slots: [],
active,
priority,
trigger_product_ids: this.selectedTriggerProducts,
recommended_product_ids: this.selectedRecommendedProducts,
};
if (!this.ruleItems.length) {
alert("Agrega al menos un producto con cantidad");
return;
}
data.items = this.ruleItems.map((item, idx) => ({
woo_product_id: item.woo_product_id,
audience_type: item.audience_type || "adult",
qty_per_person: item.qty_per_person,
unit: item.unit || "kg",
reason: item.reason || null,
display_order: idx,
}));
}
try {
if (this.editMode === "create") {
@@ -469,6 +837,7 @@ class RecommendationsCrud extends HTMLElement {
this.selected = null;
this.selectedTriggerProducts = [];
this.selectedRecommendedProducts = [];
this.ruleItems = [];
this.renderList();
this.renderForm();
}

View File

@@ -15,7 +15,7 @@ class UsersCrud extends HTMLElement {
<style>
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:1fr 450px; gap:16px; height:100%; }
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }

View File

@@ -137,6 +137,11 @@ export const api = {
return fetch(u).then(r => r.json());
},
async getRecommendation(id) {
if (!id) return null;
return fetch(`/recommendations/${encodeURIComponent(id)}`).then(r => r.json());
},
async createRecommendation(data) {
return fetch("/recommendations", {
method: "POST",
@@ -156,4 +161,22 @@ export const api = {
async deleteRecommendation(id) {
return fetch(`/recommendations/${encodeURIComponent(id)}`, { method: "DELETE" }).then(r => r.json());
},
// Quantities (cantidades por producto/evento/persona)
async listQuantities() {
return fetch("/quantities").then(r => r.json());
},
async getProductQuantities(wooProductId) {
if (!wooProductId) return { rules: [] };
return fetch(`/quantities/${encodeURIComponent(wooProductId)}`).then(r => r.json());
},
async saveProductQuantities(wooProductId, rules) {
return fetch(`/quantities/${encodeURIComponent(wooProductId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rules }),
}).then(r => r.json());
},
};

View File

@@ -17,13 +17,13 @@ export const makeListAliases = (tenantIdOrFn) => async (req, res) => {
export const makeCreateAlias = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const { alias, woo_product_id, boost, category_hint, metadata } = req.body || {};
const { alias, woo_product_id, boost, category_hint, metadata, product_mappings } = req.body || {};
if (!alias || !woo_product_id) {
return res.status(400).json({ ok: false, error: "alias_and_woo_product_id_required" });
}
const result = await handleCreateAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
const result = await handleCreateAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata, product_mappings });
res.json({ ok: true, item: result });
} catch (err) {
console.error(err);
@@ -38,13 +38,13 @@ export const makeUpdateAlias = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const alias = req.params.alias;
const { woo_product_id, boost, category_hint, metadata } = req.body || {};
const { woo_product_id, boost, category_hint, metadata, product_mappings } = req.body || {};
if (!woo_product_id) {
return res.status(400).json({ ok: false, error: "woo_product_id_required" });
}
const result = await handleUpdateAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
const result = await handleUpdateAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata, product_mappings });
if (!result) {
return res.status(404).json({ ok: false, error: "alias_not_found" });
}

View File

@@ -0,0 +1,51 @@
import {
handleListProductQtyRules,
handleGetProductQtyRules,
handleSaveProductQtyRules
} from "../handlers/quantities.js";
export const makeListProductQtyRules = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const result = await handleListProductQtyRules({ tenantId });
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeGetProductQtyRules = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const wooProductId = parseInt(req.params.wooProductId, 10);
if (!wooProductId) {
return res.status(400).json({ ok: false, error: "woo_product_id_required" });
}
const result = await handleGetProductQtyRules({ tenantId, wooProductId });
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeSaveProductQtyRules = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const wooProductId = parseInt(req.params.wooProductId, 10);
const { rules } = req.body || {};
if (!wooProductId) {
return res.status(400).json({ ok: false, error: "woo_product_id_required" });
}
const result = await handleSaveProductQtyRules({ tenantId, wooProductId, rules: rules || [] });
res.json({ ok: true, ...result });
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};

View File

@@ -37,14 +37,18 @@ export const makeGetRecommendation = (tenantIdOrFn) => async (req, res) => {
export const makeCreateRecommendation = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const { rule_key, trigger, queries, boosts, ask_slots, active, priority } = req.body || {};
const {
rule_key, trigger, queries, boosts, ask_slots, active, priority,
trigger_product_ids, recommended_product_ids, rule_type, trigger_event, items
} = req.body || {};
if (!rule_key) {
return res.status(400).json({ ok: false, error: "rule_key_required" });
}
const result = await handleCreateRecommendation({
tenantId, rule_key, trigger, queries, boosts, ask_slots, active, priority
tenantId, rule_key, trigger, queries, boosts, ask_slots, active, priority,
trigger_product_ids, recommended_product_ids, rule_type, trigger_event, items
});
res.json({ ok: true, item: result });
} catch (err) {
@@ -60,10 +64,14 @@ export const makeUpdateRecommendation = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const id = req.params.id;
const { trigger, queries, boosts, ask_slots, active, priority } = req.body || {};
const {
trigger, queries, boosts, ask_slots, active, priority,
trigger_product_ids, recommended_product_ids, rule_type, trigger_event, items
} = req.body || {};
const result = await handleUpdateRecommendation({
tenantId, id, trigger, queries, boosts, ask_slots, active, priority
tenantId, id, trigger, queries, boosts, ask_slots, active, priority,
trigger_product_ids, recommended_product_ids, rule_type, trigger_event, items
});
if (!result) {
return res.status(404).json({ ok: false, error: "recommendation_not_found" });

View File

@@ -16,17 +16,19 @@ export async function listProducts({ tenantId, q = "", limit = 2000, offset = 0
select
woo_id as woo_product_id,
name,
slug as sku,
coalesce(raw->>'SKU', raw->>'sku', slug) as sku,
slug,
price_current as price,
stock_status,
stock_qty,
categories,
attributes_normalized,
updated_at as refreshed_at,
raw as payload,
raw->>'_sell_unit_override' as sell_unit
coalesce(raw->>'_sell_unit_override', 'kg') as sell_unit
from woo_products_snapshot
where tenant_id = $1
and (name ilike $2 or coalesce(slug,'') ilike $2)
and (name ilike $2 or coalesce(slug,'') ilike $2 or coalesce(raw->>'SKU', raw->>'sku', '') ilike $2)
order by name asc
limit $3 offset $4
`;
@@ -36,14 +38,16 @@ export async function listProducts({ tenantId, q = "", limit = 2000, offset = 0
select
woo_id as woo_product_id,
name,
slug as sku,
coalesce(raw->>'SKU', raw->>'sku', slug) as sku,
slug,
price_current as price,
stock_status,
stock_qty,
categories,
attributes_normalized,
updated_at as refreshed_at,
raw as payload,
raw->>'_sell_unit_override' as sell_unit
coalesce(raw->>'_sell_unit_override', 'kg') as sell_unit
from woo_products_snapshot
where tenant_id = $1
order by name asc
@@ -61,14 +65,16 @@ export async function getProductByWooId({ tenantId, wooProductId }) {
select
woo_id as woo_product_id,
name,
slug as sku,
coalesce(raw->>'sku', slug) as sku,
slug,
price_current as price,
stock_status,
stock_qty,
categories,
attributes_normalized,
updated_at as refreshed_at,
raw as payload,
raw->>'_sell_unit_override' as sell_unit
coalesce(raw->>'_sell_unit_override', 'kg') as sell_unit
from woo_products_snapshot
where tenant_id = $1 and woo_id = $2
limit 1
@@ -101,29 +107,37 @@ export async function bulkUpdateProductSellUnit({ tenantId, wooProductIds, sell_
}
export async function updateProduct({ tenantId, wooProductId, sell_unit, categories }) {
// Build the JSONB update dynamically
// Build the update - combine all raw updates into one
let updates = [];
let params = [tenantId, wooProductId];
let paramIdx = 3;
// Build the raw column update by chaining jsonb_set calls
let rawExpr = "coalesce(raw, '{}'::jsonb)";
if (sell_unit) {
updates.push(`raw = jsonb_set(coalesce(raw, '{}'::jsonb), '{_sell_unit_override}', $${paramIdx}::jsonb)`);
rawExpr = `jsonb_set(${rawExpr}, '{_sell_unit_override}', $${paramIdx}::jsonb)`;
params.push(JSON.stringify(sell_unit));
paramIdx++;
}
if (categories) {
// Also update the categories column if it exists
// Update categories column
updates.push(`categories = $${paramIdx}::jsonb`);
params.push(JSON.stringify(categories.map(name => ({ name }))));
paramIdx++;
// Also store in raw for persistence
updates.push(`raw = jsonb_set(coalesce(raw, '{}'::jsonb), '{_categories_override}', $${paramIdx}::jsonb)`);
// Chain the categories override into raw
rawExpr = `jsonb_set(${rawExpr}, '{_categories_override}', $${paramIdx}::jsonb)`;
params.push(JSON.stringify(categories));
paramIdx++;
}
// Only add raw update if we modified it
if (sell_unit || categories) {
updates.push(`raw = ${rawExpr}`);
}
if (!updates.length) return null;
const sql = `
@@ -185,6 +199,31 @@ export async function listAliases({ tenantId, q = "", woo_product_id = null, lim
}
const { rows } = await pool.query(sql, params);
// Cargar mappings para cada alias
if (rows.length > 0) {
const mappingsSql = `
select alias, woo_product_id, score
from alias_product_mappings
where tenant_id = $1 and alias = any($2::text[])
order by alias, score desc
`;
const aliases = rows.map(r => r.alias);
const { rows: mappings } = await pool.query(mappingsSql, [tenantId, aliases]);
// Agrupar mappings por alias
const mappingsByAlias = {};
for (const m of mappings) {
if (!mappingsByAlias[m.alias]) mappingsByAlias[m.alias] = [];
mappingsByAlias[m.alias].push({ woo_product_id: m.woo_product_id, score: m.score });
}
// Agregar mappings a cada alias
for (const row of rows) {
row.product_mappings = mappingsByAlias[row.alias] || [];
}
}
return rows;
}
@@ -236,9 +275,62 @@ export async function updateAlias({ tenantId, alias, woo_product_id, boost = 0,
export async function deleteAlias({ tenantId, alias }) {
const sql = `delete from product_aliases where tenant_id = $1 and alias = $2 returning alias`;
const { rows } = await pool.query(sql, [tenantId, alias.toLowerCase().trim()]);
// También eliminar mappings asociados
await pool.query(`delete from alias_product_mappings where tenant_id = $1 and alias = $2`, [tenantId, alias.toLowerCase().trim()]);
return rows.length > 0;
}
// ─────────────────────────────────────────────────────────────
// Alias Product Mappings (multi-producto)
// ─────────────────────────────────────────────────────────────
export async function listAliasMappings({ tenantId, alias }) {
const sql = `
select alias, woo_product_id, score, created_at
from alias_product_mappings
where tenant_id = $1 and alias = $2
order by score desc
`;
const { rows } = await pool.query(sql, [tenantId, alias.toLowerCase().trim()]);
return rows;
}
export async function upsertAliasMapping({ tenantId, alias, woo_product_id, score = 1.0 }) {
const sql = `
insert into alias_product_mappings (tenant_id, alias, woo_product_id, score)
values ($1, $2, $3, $4)
on conflict (tenant_id, alias, woo_product_id)
do update set score = $4
returning alias, woo_product_id, score, created_at
`;
const { rows } = await pool.query(sql, [tenantId, alias.toLowerCase().trim(), woo_product_id, score]);
return rows[0];
}
export async function deleteAliasMapping({ tenantId, alias, woo_product_id }) {
const sql = `delete from alias_product_mappings where tenant_id = $1 and alias = $2 and woo_product_id = $3 returning alias`;
const { rows } = await pool.query(sql, [tenantId, alias.toLowerCase().trim(), woo_product_id]);
return rows.length > 0;
}
export async function setAliasMappings({ tenantId, alias, mappings }) {
const normalizedAlias = alias.toLowerCase().trim();
// Eliminar mappings existentes
await pool.query(`delete from alias_product_mappings where tenant_id = $1 and alias = $2`, [tenantId, normalizedAlias]);
// Insertar nuevos mappings
if (mappings && mappings.length > 0) {
const insertSql = `
insert into alias_product_mappings (tenant_id, alias, woo_product_id, score)
values ($1, $2, $3, $4)
`;
for (const mapping of mappings) {
await pool.query(insertSql, [tenantId, normalizedAlias, mapping.woo_product_id, mapping.score ?? 1.0]);
}
}
}
// ─────────────────────────────────────────────────────────────
// Recommendations
// ─────────────────────────────────────────────────────────────
@@ -252,7 +344,7 @@ export async function listRecommendations({ tenantId, q = "", limit = 200 }) {
const like = `%${query}%`;
sql = `
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority,
trigger_product_ids, recommended_product_ids, created_at, updated_at
trigger_product_ids, recommended_product_ids, rule_type, trigger_event, created_at, updated_at
from product_reco_rules
where tenant_id = $1 and rule_key ilike $2
order by priority desc, rule_key asc
@@ -262,7 +354,7 @@ export async function listRecommendations({ tenantId, q = "", limit = 200 }) {
} else {
sql = `
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority,
trigger_product_ids, recommended_product_ids, created_at, updated_at
trigger_product_ids, recommended_product_ids, rule_type, trigger_event, created_at, updated_at
from product_reco_rules
where tenant_id = $1
order by priority desc, rule_key asc
@@ -278,13 +370,24 @@ export async function listRecommendations({ tenantId, q = "", limit = 200 }) {
export async function getRecommendationById({ tenantId, id }) {
const sql = `
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority,
trigger_product_ids, recommended_product_ids, created_at, updated_at
trigger_product_ids, recommended_product_ids, rule_type, trigger_event, created_at, updated_at
from product_reco_rules
where tenant_id = $1 and id = $2
limit 1
`;
const { rows } = await pool.query(sql, [tenantId, id]);
return rows[0] || null;
if (!rows[0]) return null;
// Cargar items asociados
const itemsSql = `
select id, woo_product_id, audience_type, qty_per_person, unit, reason, display_order
from reco_rule_items
where rule_id = $1
order by display_order asc
`;
const { rows: items } = await pool.query(itemsSql, [id]);
return { ...rows[0], items };
}
export async function insertRecommendation({
@@ -298,11 +401,14 @@ export async function insertRecommendation({
priority = 100,
trigger_product_ids = [],
recommended_product_ids = [],
rule_type = "crosssell",
trigger_event = null,
items = [],
}) {
const sql = `
insert into product_reco_rules (tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority, trigger_product_ids, recommended_product_ids)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
returning id, rule_key, trigger, queries, boosts, ask_slots, active, priority, trigger_product_ids, recommended_product_ids, created_at, updated_at
insert into product_reco_rules (tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority, trigger_product_ids, recommended_product_ids, rule_type, trigger_event)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
returning id, rule_key, trigger, queries, boosts, ask_slots, active, priority, trigger_product_ids, recommended_product_ids, rule_type, trigger_event, created_at, updated_at
`;
const { rows } = await pool.query(sql, [
@@ -316,9 +422,18 @@ export async function insertRecommendation({
priority || 100,
trigger_product_ids || [],
recommended_product_ids || [],
rule_type || "crosssell",
trigger_event || null,
]);
return rows[0];
const rule = rows[0];
// Insertar items si hay
if (items && items.length > 0) {
await upsertRecoRuleItems({ ruleId: rule.id, items });
}
return rule;
}
export async function updateRecommendation({
@@ -332,6 +447,9 @@ export async function updateRecommendation({
priority,
trigger_product_ids,
recommended_product_ids,
rule_type,
trigger_event,
items,
}) {
const sql = `
update product_reco_rules
@@ -344,9 +462,11 @@ export async function updateRecommendation({
priority = $8,
trigger_product_ids = $9,
recommended_product_ids = $10,
rule_type = $11,
trigger_event = $12,
updated_at = now()
where tenant_id = $1 and id = $2
returning id, rule_key, trigger, queries, boosts, ask_slots, active, priority, trigger_product_ids, recommended_product_ids, created_at, updated_at
returning id, rule_key, trigger, queries, boosts, ask_slots, active, priority, trigger_product_ids, recommended_product_ids, rule_type, trigger_event, created_at, updated_at
`;
const { rows } = await pool.query(sql, [
@@ -360,13 +480,147 @@ export async function updateRecommendation({
priority || 100,
trigger_product_ids || [],
recommended_product_ids || [],
rule_type || "crosssell",
trigger_event || null,
]);
// Actualizar items si se proporcionan
if (items !== undefined) {
await upsertRecoRuleItems({ ruleId: id, items: items || [] });
}
return rows[0] || null;
}
export async function upsertRecoRuleItems({ ruleId, items }) {
// Eliminar items existentes
await pool.query(`delete from reco_rule_items where rule_id = $1`, [ruleId]);
// Insertar nuevos items
if (items && items.length > 0) {
const insertSql = `
insert into reco_rule_items (rule_id, woo_product_id, audience_type, qty_per_person, unit, reason, display_order)
values ($1, $2, $3, $4, $5, $6, $7)
`;
for (let i = 0; i < items.length; i++) {
const item = items[i];
await pool.query(insertSql, [
ruleId,
item.woo_product_id,
item.audience_type ?? "adult",
item.qty_per_person ?? null,
item.unit ?? null,
item.reason ?? null,
item.display_order ?? i,
]);
}
}
}
export async function deleteRecommendation({ tenantId, id }) {
// Los items se eliminan en cascada
const sql = `delete from product_reco_rules where tenant_id = $1 and id = $2 returning id`;
const { rows } = await pool.query(sql, [tenantId, id]);
return rows.length > 0;
}
// ─────────────────────────────────────────────────────────────
// Product Quantity Rules (cantidades por producto/evento/persona)
// ─────────────────────────────────────────────────────────────
/**
* Obtener todas las reglas de cantidad agrupadas por producto
*/
export async function listProductQtyRules({ tenantId }) {
const sql = `
select id, woo_product_id, event_type, person_type, qty_per_person, unit, updated_at
from product_qty_rules
where tenant_id = $1
order by woo_product_id, event_type, person_type
`;
const { rows } = await pool.query(sql, [tenantId]);
return rows;
}
/**
* Obtener reglas de un producto específico
*/
export async function getProductQtyRules({ tenantId, wooProductId }) {
const sql = `
select id, woo_product_id, event_type, person_type, qty_per_person, unit, updated_at
from product_qty_rules
where tenant_id = $1 and woo_product_id = $2
order by event_type, person_type
`;
const { rows } = await pool.query(sql, [tenantId, wooProductId]);
return rows;
}
/**
* Upsert una regla de cantidad (crear o actualizar)
*/
export async function upsertProductQtyRule({ tenantId, wooProductId, eventType, personType, qtyPerPerson, unit }) {
const sql = `
insert into product_qty_rules (tenant_id, woo_product_id, event_type, person_type, qty_per_person, unit)
values ($1, $2, $3, $4, $5, $6)
on conflict (tenant_id, woo_product_id, event_type, person_type)
do update set
qty_per_person = $5,
unit = $6,
updated_at = now()
returning id, woo_product_id, event_type, person_type, qty_per_person, unit, updated_at
`;
const { rows } = await pool.query(sql, [tenantId, wooProductId, eventType, personType, qtyPerPerson, unit || 'kg']);
return rows[0];
}
/**
* Eliminar una regla de cantidad específica
*/
export async function deleteProductQtyRule({ tenantId, id }) {
const sql = `delete from product_qty_rules where tenant_id = $1 and id = $2 returning id`;
const { rows } = await pool.query(sql, [tenantId, id]);
return rows.length > 0;
}
/**
* Guardar todas las reglas de un producto (reemplaza las existentes)
*/
export async function saveProductQtyRules({ tenantId, wooProductId, rules }) {
// Eliminar reglas existentes del producto
await pool.query(`delete from product_qty_rules where tenant_id = $1 and woo_product_id = $2`, [tenantId, wooProductId]);
// Insertar nuevas reglas
if (rules && rules.length > 0) {
const insertSql = `
insert into product_qty_rules (tenant_id, woo_product_id, event_type, person_type, qty_per_person, unit)
values ($1, $2, $3, $4, $5, $6)
`;
for (const rule of rules) {
if (rule.qty_per_person != null && rule.qty_per_person > 0) {
await pool.query(insertSql, [
tenantId,
wooProductId,
rule.event_type,
rule.person_type,
rule.qty_per_person,
rule.unit || 'kg'
]);
}
}
}
}
/**
* Contar reglas por producto (para mostrar badges)
*/
export async function countQtyRulesByProduct({ tenantId }) {
const sql = `
select woo_product_id, count(*) as rule_count
from product_qty_rules
where tenant_id = $1
group by woo_product_id
`;
const { rows } = await pool.query(sql, [tenantId]);
return rows;
}

View File

@@ -1,19 +1,64 @@
import { listAliases, insertAlias, updateAlias, deleteAlias } from "../db/repo.js";
import {
listAliases,
insertAlias,
updateAlias,
deleteAlias,
listAliasMappings,
setAliasMappings,
} from "../db/repo.js";
export async function handleListAliases({ tenantId, q = "", woo_product_id = null, limit = 200 }) {
const items = await listAliases({ tenantId, q, woo_product_id, limit });
return { items };
}
export async function handleCreateAlias({ tenantId, alias, woo_product_id, boost = 0, category_hint = null, metadata = {} }) {
return insertAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
export async function handleCreateAlias({
tenantId,
alias,
woo_product_id,
boost = 0,
category_hint = null,
metadata = {},
product_mappings = [],
}) {
const result = await insertAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
// Si hay mappings, guardarlos
if (product_mappings && product_mappings.length > 0) {
await setAliasMappings({ tenantId, alias, mappings: product_mappings });
} else if (woo_product_id) {
// Si solo hay un producto, crear mapping por defecto
await setAliasMappings({ tenantId, alias, mappings: [{ woo_product_id, score: boost || 1.0 }] });
}
export async function handleUpdateAlias({ tenantId, alias, woo_product_id, boost = 0, category_hint = null, metadata = {} }) {
return updateAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
return result;
}
export async function handleUpdateAlias({
tenantId,
alias,
woo_product_id,
boost = 0,
category_hint = null,
metadata = {},
product_mappings,
}) {
const result = await updateAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
// Si hay mappings, actualizarlos
if (product_mappings !== undefined) {
await setAliasMappings({ tenantId, alias, mappings: product_mappings || [] });
}
return result;
}
export async function handleDeleteAlias({ tenantId, alias }) {
const deleted = await deleteAlias({ tenantId, alias });
return { deleted };
}
export async function handleGetAliasMappings({ tenantId, alias }) {
const mappings = await listAliasMappings({ tenantId, alias });
return { mappings };
}

View File

@@ -0,0 +1,23 @@
import {
listProductQtyRules,
getProductQtyRules,
saveProductQtyRules,
countQtyRulesByProduct,
} from "../db/repo.js";
export async function handleListProductQtyRules({ tenantId }) {
const rules = await listProductQtyRules({ tenantId });
const counts = await countQtyRulesByProduct({ tenantId });
return { rules, counts };
}
export async function handleGetProductQtyRules({ tenantId, wooProductId }) {
const rules = await getProductQtyRules({ tenantId, wooProductId });
return { rules };
}
export async function handleSaveProductQtyRules({ tenantId, wooProductId, rules }) {
await saveProductQtyRules({ tenantId, wooProductId, rules });
const updated = await getProductQtyRules({ tenantId, wooProductId });
return { rules: updated };
}

View File

@@ -26,8 +26,14 @@ export async function handleCreateRecommendation({
priority = 100,
trigger_product_ids = [],
recommended_product_ids = [],
rule_type = "crosssell",
trigger_event = null,
items = [],
}) {
return insertRecommendation({ tenantId, rule_key, trigger, queries, boosts, ask_slots, active, priority, trigger_product_ids, recommended_product_ids });
return insertRecommendation({
tenantId, rule_key, trigger, queries, boosts, ask_slots, active, priority,
trigger_product_ids, recommended_product_ids, rule_type, trigger_event, items
});
}
export async function handleUpdateRecommendation({
@@ -41,8 +47,14 @@ export async function handleUpdateRecommendation({
priority,
trigger_product_ids,
recommended_product_ids,
rule_type,
trigger_event,
items,
}) {
return updateRecommendation({ tenantId, id, trigger, queries, boosts, ask_slots, active, priority, trigger_product_ids, recommended_product_ids });
return updateRecommendation({
tenantId, id, trigger, queries, boosts, ask_slots, active, priority,
trigger_product_ids, recommended_product_ids, rule_type, trigger_event, items
});
}
export async function handleDeleteRecommendation({ tenantId, id }) {

View File

@@ -9,6 +9,7 @@ import { makeListMessages } from "../../0-ui/controllers/messages.js";
import { makeSearchProducts, makeListProducts, makeGetProduct, makeSyncProducts, makeUpdateProductUnit, makeBulkUpdateProductUnit, makeUpdateProduct } from "../../0-ui/controllers/products.js";
import { makeListAliases, makeCreateAlias, makeUpdateAlias, makeDeleteAlias } from "../../0-ui/controllers/aliases.js";
import { makeListRecommendations, makeGetRecommendation, makeCreateRecommendation, makeUpdateRecommendation, makeDeleteRecommendation } from "../../0-ui/controllers/recommendations.js";
import { makeListProductQtyRules, makeGetProductQtyRules, makeSaveProductQtyRules } from "../../0-ui/controllers/quantities.js";
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js";
function nowIso() {
@@ -70,6 +71,10 @@ export function createSimulatorRouter({ tenantId }) {
router.put("/recommendations/:id", makeUpdateRecommendation(getTenantId));
router.delete("/recommendations/:id", makeDeleteRecommendation(getTenantId));
router.get("/quantities", makeListProductQtyRules(getTenantId));
router.get("/quantities/:wooProductId", makeGetProductQtyRules(getTenantId));
router.put("/quantities/:wooProductId", makeSaveProductQtyRules(getTenantId));
router.get("/users", makeListUsers(getTenantId));
router.delete("/users/:chat_id", makeDeleteUser(getTenantId));

View File

@@ -594,7 +594,7 @@ export async function getRecoRulesByProductIds({ tenant_id, product_ids = [] })
export async function getRecoRuleByKey({ tenant_id, rule_key }) {
const sql = `
select id, tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority,
trigger_product_ids, recommended_product_ids, created_at, updated_at
trigger_product_ids, recommended_product_ids, rule_type, trigger_event, created_at, updated_at
from product_reco_rules
where tenant_id=$1 and rule_key=$2
limit 1
@@ -603,6 +603,95 @@ export async function getRecoRuleByKey({ tenant_id, rule_key }) {
return rows[0] || null;
}
/**
* Obtener reglas de qty_per_person por tipo de evento (asado, horno, etc.)
* DEPRECATED: Usar getProductQtyRulesByEvent en su lugar
*/
export async function getQtyPerPersonRules({ tenant_id, event_type }) {
const sql = `
select r.id, r.rule_key, r.rule_type, r.trigger_event, r.priority,
json_agg(json_build_object(
'woo_product_id', i.woo_product_id,
'audience_type', i.audience_type,
'qty_per_person', i.qty_per_person,
'unit', i.unit,
'reason', i.reason,
'display_order', i.display_order
) order by i.display_order) as items
from product_reco_rules r
inner join reco_rule_items i on i.rule_id = r.id
where r.tenant_id = $1
and r.active = true
and r.rule_type = 'qty_per_person'
and (r.trigger_event = $2 or r.trigger_event is null)
group by r.id, r.rule_key, r.rule_type, r.trigger_event, r.priority
order by
case when r.trigger_event = $2 then 0 else 1 end,
r.priority asc
`;
const { rows } = await pool.query(sql, [tenant_id, event_type]);
return rows;
}
/**
* Obtener reglas de cantidad por evento desde la nueva tabla product_qty_rules
*/
export async function getProductQtyRulesByEvent({ tenant_id, event_type }) {
const sql = `
select woo_product_id, event_type, person_type, qty_per_person, unit
from product_qty_rules
where tenant_id = $1 and event_type = $2
order by woo_product_id, person_type
`;
const { rows } = await pool.query(sql, [tenant_id, event_type]);
return rows;
}
/**
* Obtener items de una regla específica con detalles
*/
export async function getRecoRuleItems({ rule_id }) {
const sql = `
select id, rule_id, woo_product_id, audience_type, qty_per_person, unit, reason, display_order
from reco_rule_items
where rule_id = $1
order by display_order asc
`;
const { rows } = await pool.query(sql, [rule_id]);
return rows;
}
/**
* Obtener productos mapeados a un alias con scores
*/
export async function getAliasProductMappings({ tenant_id, alias }) {
const normalizedAlias = String(alias || "").toLowerCase().trim();
if (!normalizedAlias) return [];
const sql = `
select woo_product_id, score
from alias_product_mappings
where tenant_id = $1 and alias = $2
order by score desc
`;
const { rows } = await pool.query(sql, [tenant_id, normalizedAlias]);
return rows;
}
/**
* Obtener todos los mappings de alias para un tenant (para búsqueda)
*/
export async function getAllAliasProductMappings({ tenant_id }) {
const sql = `
select alias, woo_product_id, score
from alias_product_mappings
where tenant_id = $1
order by alias, score desc
`;
const { rows } = await pool.query(sql, [tenant_id]);
return rows;
}
export async function getProductEmbedding({ tenant_id, content_hash }) {
const sql = `
select tenant_id, content_hash, content_text, embedding, model, updated_at

View File

@@ -338,7 +338,13 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
railguard: { simulated: isSimulated, source: meta?.source || null },
woo_customer_error: wooCustomerError,
};
const nextState = safeNextState(prev_state, context, { requested_checkout: plan.intent === "checkout" }).next_state;
// El nuevo FSM usa context.order, extraerlo para safeNextState
const orderForFsm = context?.order || context?.order_basket || {};
const signals = {
confirm_order: plan.intent === "confirm_order",
payment_selected: plan.intent === "select_payment",
};
const nextState = safeNextState(prev_state, orderForFsm, signals).next_state;
plan.next_state = nextState;
const stateRow = await upsertConversationState({
@@ -366,8 +372,17 @@ const nextState = safeNextState(prev_state, context, { requested_checkout: plan.
await updateRunLatency({ tenant_id: tenantId, run_id, latency_ms: end_to_end_ms });
}
// Incluir carrito completo para la UI
const fullBasket = context?.order_basket?.items || [];
// Incluir carrito completo para la UI (nuevo formato order.cart o legacy order_basket)
const orderData = context?.order || {};
const fullBasket = (orderData.cart || []).map(c => ({
product_id: c.woo_id,
woo_product_id: c.woo_id,
quantity: c.qty,
unit: c.unit,
label: c.name,
name: c.name,
price: c.price,
}));
sseSend("run.created", {
run_id,
@@ -377,6 +392,8 @@ const nextState = safeNextState(prev_state, context, { requested_checkout: plan.
status: runStatus,
prev_state,
input: { text },
// Incluir order completo para la UI
order: orderData,
llm_output: { ...plan, _llm: llmMeta, full_basket: { items: fullBasket } },
tools,
invariants,

View File

@@ -6,6 +6,7 @@ import {
searchProductAliases,
getProductEmbedding,
upsertProductEmbedding,
getAllAliasProductMappings,
} from "../2-identity/db/repo.js";
function getOpenAiKey() {
@@ -141,6 +142,37 @@ export async function retrieveCandidates({
const aliases = await searchProductAliases({ tenant_id: tenantId, q, limit: 20 });
const aliasBoostByProduct = new Map();
const aliasProductIds = new Set();
// También buscar en alias_product_mappings (multi-producto)
const allMappings = await getAllAliasProductMappings({ tenant_id: tenantId });
const normalizedQuery = normalizeText(q);
const queryWords = new Set(normalizedQuery.split(" ").filter(Boolean));
// Buscar mappings cuyos aliases matcheen la query
for (const mapping of allMappings) {
const aliasNorm = normalizeText(mapping.alias);
// Match exacto o parcial del alias
if (aliasNorm === normalizedQuery || normalizedQuery.includes(aliasNorm) || aliasNorm.includes(normalizedQuery)) {
const id = Number(mapping.woo_product_id);
const score = Number(mapping.score || 1);
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, score));
aliasProductIds.add(id);
} else {
// Check word overlap
const aliasWords = new Set(aliasNorm.split(" ").filter(Boolean));
for (const word of queryWords) {
if (aliasWords.has(word)) {
const id = Number(mapping.woo_product_id);
const score = Number(mapping.score || 1) * 0.7; // Partial match gets lower score
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, score));
aliasProductIds.add(id);
break;
}
}
}
}
// También incluir aliases legacy (product_aliases.woo_product_id)
for (const a of aliases) {
if (a?.woo_product_id) {
const id = Number(a.woo_product_id);
@@ -150,6 +182,7 @@ export async function retrieveCandidates({
}
}
audit.sources.aliases = aliases.length;
audit.sources.alias_mappings = aliasProductIds.size;
// 2) Buscar productos por nombre/slug (búsqueda literal)
const { items: wooItems, source: wooSource } = await searchSnapshotItems({

View File

@@ -1,219 +1,193 @@
/**
* FSM autoritativa (server-side) para el flujo conversacional.
* FSM simplificada para el flujo conversacional.
*
* Principios:
* - El LLM NO decide estados. Solo NLU.
* - El backend deriva el estado objetivo a partir del contexto + acciones.
* - Validamos transiciones y, si algo queda inconsistente, caemos a ERROR_RECOVERY.
* Estados lineales: IDLE → CART → SHIPPING → PAYMENT → WAITING_WEBHOOKS
* Regla universal: add_to_cart SIEMPRE vuelve a CART desde cualquier estado.
*/
export const ConversationState = Object.freeze({
IDLE: "IDLE",
BROWSING: "BROWSING",
CLARIFYING_ITEMS: "CLARIFYING_ITEMS", // Clarificando items pendientes uno por uno
AWAITING_QUANTITY: "AWAITING_QUANTITY",
CART_ACTIVE: "CART_ACTIVE",
CLARIFYING_PAYMENT: "CLARIFYING_PAYMENT", // Preguntando método de pago (efectivo/link)
CLARIFYING_SHIPPING: "CLARIFYING_SHIPPING", // Preguntando delivery o retiro
AWAITING_ADDRESS: "AWAITING_ADDRESS",
AWAITING_PAYMENT: "AWAITING_PAYMENT",
COMPLETED: "COMPLETED",
ERROR_RECOVERY: "ERROR_RECOVERY",
CART: "CART",
SHIPPING: "SHIPPING",
PAYMENT: "PAYMENT",
WAITING_WEBHOOKS: "WAITING_WEBHOOKS",
});
export const ALL_STATES = Object.freeze(Object.values(ConversationState));
function hasBasketItems(ctx) {
const items = ctx?.basket?.items || ctx?.order_basket?.items;
return Array.isArray(items) && items.length > 0;
}
// Intents válidos por estado
export const INTENTS_BY_STATE = Object.freeze({
[ConversationState.IDLE]: [
"greeting", "add_to_cart", "browse", "price_query", "recommend", "other"
],
[ConversationState.CART]: [
"add_to_cart", "remove_from_cart", "browse", "price_query",
"recommend", "view_cart", "confirm_order", "other"
],
[ConversationState.SHIPPING]: [
"provide_address", "select_shipping", "add_to_cart", "view_cart", "other"
],
[ConversationState.PAYMENT]: [
"select_payment", "add_to_cart", "view_cart", "other"
],
[ConversationState.WAITING_WEBHOOKS]: [
"add_to_cart", "view_cart", "other"
],
});
function hasPendingClarification(ctx) {
const pc = ctx?.pending_clarification;
return Boolean(pc?.candidates?.length) || Boolean(pc?.options?.length);
/**
* Verifica si el usuario quiere agregar productos (debe volver a CART).
*/
export function shouldReturnToCart(state, nlu) {
if (state === ConversationState.CART || state === ConversationState.IDLE) {
return false; // Ya está en CART o IDLE (IDLE irá a CART naturalmente)
}
function hasPendingItem(ctx) {
return Boolean(ctx?.pending_item?.product_id || ctx?.pending_item?.sku);
const intent = nlu?.intent;
// Si el intent es add_to_cart, browse, price_query, o recommend → volver a CART
if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) {
return true;
}
// Si hay menciones de producto en entities
if (nlu?.entities?.product_query) return true;
if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.length > 0) return true;
return false;
}
/**
* Verifica si hay items pendientes de clarificar (nuevo modelo acumulativo).
* Un item pendiente tiene status "needs_type" o "needs_quantity".
* Helpers para verificar estado de la orden
*/
function hasPendingItems(ctx) {
const items = ctx?.pending_items;
if (!Array.isArray(items) || items.length === 0) return false;
return items.some(i => i.status === "needs_type" || i.status === "needs_quantity");
export function hasCartItems(order) {
return Array.isArray(order?.cart) && order.cart.length > 0;
}
function hasAddress(ctx) {
return Boolean(ctx?.delivery_address?.text || ctx?.address?.text || ctx?.address_text);
export function hasPendingItems(order) {
if (!Array.isArray(order?.pending) || order.pending.length === 0) return false;
return order.pending.some(i => i.status === "NEEDS_TYPE" || i.status === "NEEDS_QUANTITY");
}
function hasWooOrder(ctx) {
return Boolean(ctx?.woo_order_id || ctx?.last_order_id);
export function hasReadyPendingItems(order) {
if (!Array.isArray(order?.pending)) return false;
return order.pending.some(i => i.status === "READY");
}
function hasPaymentLink(ctx) {
return Boolean(ctx?.mp?.init_point || ctx?.payment?.init_point || ctx?.payment_link);
export function hasShippingInfo(order) {
if (order?.is_delivery === false) return true; // Pickup no necesita dirección
if (order?.is_delivery === true && order?.shipping_address) return true;
return false;
}
function isPaid(ctx) {
const st =
ctx?.mp?.payment_status ||
ctx?.payment?.status ||
ctx?.payment_status ||
null;
return st === "approved" || st === "paid";
export function hasPaymentInfo(order) {
return order?.payment_type === "cash" || order?.payment_type === "link";
}
export function isPaid(order) {
return order?.is_paid === true;
}
/**
* Verifica si estamos clarificando método de pago.
* Deriva el siguiente estado basado en el contexto y signals.
*
* signals: {
* confirm_order: boolean, // Usuario quiere cerrar pedido
* shipping_selected: boolean, // Usuario seleccionó delivery/pickup
* payment_selected: boolean, // Usuario seleccionó método de pago
* return_to_cart: boolean, // Forzar volver a CART
* }
*/
function isClarifyingPayment(ctx) {
return ctx?.checkout_step === "payment_method";
}
/**
* Verifica si estamos clarificando shipping (delivery/retiro).
*/
function isClarifyingShipping(ctx) {
return ctx?.checkout_step === "shipping_method";
}
/**
* Verifica si ya se eligió método de pago.
*/
function hasPaymentMethod(ctx) {
return Boolean(ctx?.payment_method); // "cash" | "link"
}
/**
* Verifica si ya se eligió método de envío.
*/
function hasShippingMethod(ctx) {
return Boolean(ctx?.shipping_method); // "delivery" | "pickup"
}
/**
* Deriva el estado objetivo según el contexto actual y señales del turno.
* `signals` es información determinística del motor del turno (no del LLM),
* por ejemplo: { requested_checkout: true }.
*/
export function deriveNextState(prevState, ctx = {}, signals = {}) {
// Regla 1: pago confirmado gana siempre
if (isPaid(ctx)) return ConversationState.COMPLETED;
// Regla 2: si ya existe orden + link de pago, estamos esperando pago
if (hasWooOrder(ctx) && hasPaymentLink(ctx)) return ConversationState.AWAITING_PAYMENT;
// Regla 3: si estamos clarificando método de pago
if (isClarifyingPayment(ctx)) {
return ConversationState.CLARIFYING_PAYMENT;
}
// Regla 4: si estamos clarificando shipping
if (isClarifyingShipping(ctx)) {
return ConversationState.CLARIFYING_SHIPPING;
}
// Regla 5: si intentó checkout, tiene shipping=delivery, pero falta dirección
if (signals.requested_address || (hasShippingMethod(ctx) && ctx.shipping_method === "delivery" && !hasAddress(ctx))) {
return ConversationState.AWAITING_ADDRESS;
}
// Regla 6: si hay items pendientes de clarificar (nuevo modelo acumulativo)
if (hasPendingItems(ctx)) {
return ConversationState.CLARIFYING_ITEMS;
}
// Regla 7: si hay item pendiente sin completar cantidad (modelo legacy)
if (hasPendingItem(ctx) && !signals.pending_item_completed) {
return ConversationState.AWAITING_QUANTITY;
}
// Regla 8: si hay carrito activo
if (hasBasketItems(ctx)) return ConversationState.CART_ACTIVE;
// Regla 9: si estamos mostrando opciones / esperando selección (modelo legacy)
if (hasPendingClarification(ctx) || signals.did_show_options || signals.is_browsing) {
return ConversationState.BROWSING;
export function deriveNextState(prevState, order = {}, signals = {}) {
// Regla 0: Si se fuerza volver a CART
if (signals.return_to_cart) {
return ConversationState.CART;
}
// Regla 1: Si está pagado, completado (volver a IDLE para nueva conversación)
if (isPaid(order)) {
return ConversationState.IDLE;
}
// Regla 2: Si tiene woo_order_id y espera pago
if (order?.woo_order_id && !isPaid(order)) {
return ConversationState.WAITING_WEBHOOKS;
}
// Desde IDLE
if (prevState === ConversationState.IDLE) {
// Si hay cart o pending items, ir a CART
if (hasCartItems(order) || hasPendingItems(order)) {
return ConversationState.CART;
}
return ConversationState.IDLE;
}
// Desde CART
if (prevState === ConversationState.CART) {
// Si hay pending items sin resolver, quedarse en CART
if (hasPendingItems(order)) {
return ConversationState.CART;
}
// Si usuario confirma orden y hay items en cart, ir a SHIPPING
if (signals.confirm_order && hasCartItems(order)) {
return ConversationState.SHIPPING;
}
return ConversationState.CART;
}
// Desde SHIPPING
if (prevState === ConversationState.SHIPPING) {
// Si ya tiene shipping info completa, ir a PAYMENT
if (hasShippingInfo(order)) {
return ConversationState.PAYMENT;
}
return ConversationState.SHIPPING;
}
// Desde PAYMENT
if (prevState === ConversationState.PAYMENT) {
// Si ya tiene payment info, ir a WAITING_WEBHOOKS
if (signals.payment_selected || hasPaymentInfo(order)) {
return ConversationState.WAITING_WEBHOOKS;
}
return ConversationState.PAYMENT;
}
// Desde WAITING_WEBHOOKS
if (prevState === ConversationState.WAITING_WEBHOOKS) {
if (isPaid(order)) {
return ConversationState.IDLE;
}
return ConversationState.WAITING_WEBHOOKS;
}
// Default
return prevState || ConversationState.IDLE;
}
// Transiciones permitidas (para validación)
const ALLOWED = Object.freeze({
[ConversationState.IDLE]: [
ConversationState.IDLE,
ConversationState.BROWSING,
ConversationState.CLARIFYING_ITEMS,
ConversationState.AWAITING_QUANTITY,
ConversationState.CART_ACTIVE,
ConversationState.ERROR_RECOVERY,
ConversationState.CART,
],
[ConversationState.BROWSING]: [
ConversationState.BROWSING,
ConversationState.CLARIFYING_ITEMS,
ConversationState.AWAITING_QUANTITY,
ConversationState.CART_ACTIVE,
ConversationState.IDLE,
ConversationState.ERROR_RECOVERY,
[ConversationState.CART]: [
ConversationState.CART,
ConversationState.SHIPPING,
ConversationState.IDLE, // Si vacía el carrito
],
[ConversationState.CLARIFYING_ITEMS]: [
ConversationState.CLARIFYING_ITEMS,
ConversationState.CART_ACTIVE,
ConversationState.BROWSING,
ConversationState.IDLE,
ConversationState.ERROR_RECOVERY,
[ConversationState.SHIPPING]: [
ConversationState.SHIPPING,
ConversationState.PAYMENT,
ConversationState.CART, // Volver a agregar productos
],
[ConversationState.AWAITING_QUANTITY]: [
ConversationState.AWAITING_QUANTITY,
ConversationState.CLARIFYING_ITEMS,
ConversationState.CART_ACTIVE,
ConversationState.BROWSING,
ConversationState.ERROR_RECOVERY,
[ConversationState.PAYMENT]: [
ConversationState.PAYMENT,
ConversationState.WAITING_WEBHOOKS,
ConversationState.CART, // Volver a agregar productos
],
[ConversationState.CART_ACTIVE]: [
ConversationState.CART_ACTIVE,
ConversationState.CLARIFYING_ITEMS,
ConversationState.CLARIFYING_PAYMENT,
ConversationState.AWAITING_ADDRESS,
ConversationState.AWAITING_PAYMENT,
ConversationState.ERROR_RECOVERY,
ConversationState.BROWSING,
[ConversationState.WAITING_WEBHOOKS]: [
ConversationState.WAITING_WEBHOOKS,
ConversationState.IDLE, // Pago completado
ConversationState.CART, // Agregar más productos
],
[ConversationState.CLARIFYING_PAYMENT]: [
ConversationState.CLARIFYING_PAYMENT,
ConversationState.CLARIFYING_SHIPPING,
ConversationState.CART_ACTIVE, // Volver si cancela
ConversationState.ERROR_RECOVERY,
],
[ConversationState.CLARIFYING_SHIPPING]: [
ConversationState.CLARIFYING_SHIPPING,
ConversationState.AWAITING_ADDRESS, // Si elige delivery
ConversationState.AWAITING_PAYMENT, // Si elige retiro (directo a crear orden)
ConversationState.CLARIFYING_PAYMENT, // Volver a cambiar pago
ConversationState.ERROR_RECOVERY,
],
[ConversationState.AWAITING_ADDRESS]: [
ConversationState.AWAITING_ADDRESS,
ConversationState.AWAITING_PAYMENT,
ConversationState.CART_ACTIVE,
ConversationState.ERROR_RECOVERY,
],
[ConversationState.AWAITING_PAYMENT]: [
ConversationState.AWAITING_PAYMENT,
ConversationState.COMPLETED,
ConversationState.ERROR_RECOVERY,
],
[ConversationState.COMPLETED]: [
ConversationState.COMPLETED,
ConversationState.IDLE, // nueva conversación / reinicio natural
ConversationState.ERROR_RECOVERY,
],
[ConversationState.ERROR_RECOVERY]: ALL_STATES,
});
export function validateTransition(prevState, nextState) {
@@ -225,10 +199,11 @@ export function validateTransition(prevState, nextState) {
return ok ? { ok: true } : { ok: false, reason: "invalid_transition", prev: p, next: n };
}
export function safeNextState(prevState, ctx, signals) {
const desired = deriveNextState(prevState, ctx, signals);
export function safeNextState(prevState, order, signals) {
const desired = deriveNextState(prevState, order, signals);
const v = validateTransition(prevState, desired);
if (v.ok) return { next_state: desired, validation: v };
return { next_state: ConversationState.ERROR_RECOVERY, validation: v };
// Si la transición no es válida, forzar a un estado seguro
// En el nuevo modelo, siempre podemos ir a CART
return { next_state: ConversationState.CART, validation: { ...v, forced_to_cart: true } };
}

View File

@@ -312,10 +312,42 @@ export async function llmNluV3({ input, model } = {}) {
"- IMPORTANTE: Si NO hay opciones mostradas (last_shown_options vacío) y el usuario dice un número + producto (ej: '2 provoletas'), eso es quantity+product_query, NO selection.\n" +
"- selection SOLO aplica cuando hay opciones visibles para seleccionar (last_shown_options tiene elementos).\n" +
"- Si el usuario responde 'mostrame más', poné intent='browse' y entities.selection=null (la paginación la maneja el servidor).\n" +
"- needs.catalog_lookup debe ser true para intents price_query|browse|add_to_cart si NO es una pura selección sobre opciones ya mostradas.\n" +
"- PREGUNTAS SOBRE DISPONIBILIDAD: Si el usuario pregunta si hay/venden/tienen un producto (ej: 'vendés vino?', 'tenés chimichurri?', 'hay provoleta?'), usá intent='browse' con product_query=ese producto. needs.catalog_lookup=true.\n" +
"- RECOMENDACIONES: SOLO usá intent='recommend' si el usuario pide sugerencias SIN mencionar ningún producto (ej: 'qué me recomendás?', 'qué me sugerís?'). Si menciona CUALQUIER producto, usá intent='add_to_cart' con product_query=ese producto. Ejemplos que son add_to_cart: 'me recomendás un vino?', 'recomendame un vino', 'qué vino me recomendás?', 'tenés algún vino bueno?' → TODOS son add_to_cart con product_query='vino'.\n" +
"- COMPRAR/PEDIR PRODUCTOS: Si el usuario quiere comprar/pedir/llevar productos (ej: 'quiero comprar X', 'quiero X', 'dame X', 'necesito X', 'anotame X'), usá intent='add_to_cart'. needs.catalog_lookup=true. Aunque incluya un saludo o pida recomendación, si menciona productos específicos es add_to_cart.\n" +
"- needs.catalog_lookup debe ser true para intents price_query|browse|add_to_cart|recommend.\n" +
"\n" +
"JERARQUÍA DE DECISIÓN (en orden de prioridad):\n" +
"1. PREGUNTAS DE PLANIFICACIÓN/CONSEJO → recommend\n" +
" Si el usuario PREGUNTA qué comprar/llevar/necesitar para un evento o situación.\n" +
" Señales: 'qué me recomendás', 'qué llevo', 'qué necesito', 'para X personas', 'para un asado/cumple/evento'.\n" +
" El producto mencionado es CONTEXTO, no algo para agregar directamente.\n" +
" Ejemplos → recommend:\n" +
" - 'quiero hacer un asado para 6, qué me recomendás?' (planificación)\n" +
" - 'para una parrillada de 10 personas qué llevo?' (planificación)\n" +
" - 'qué cortes van bien para 6?' (consejo)\n" +
" - 'qué necesito para un asado?' (planificación)\n" +
" - 'qué vino va bien con carne?' (maridaje/consejo)\n" +
"\n" +
"2. PREGUNTAS SOBRE DISPONIBILIDAD → browse\n" +
" Si el usuario pregunta si hay/venden/tienen un producto.\n" +
" Ejemplos → browse: 'vendés vino?', 'tenés chimichurri?', 'hay provoleta?'\n" +
"\n" +
"3. PEDIDOS DIRECTOS → add_to_cart\n" +
" Si el usuario AFIRMA que quiere/pide/necesita un producto específico con intención de comprarlo.\n" +
" Señales: 'quiero X', 'dame X', 'anotame X', 'poneme X', cantidad + producto.\n" +
" Ejemplos → add_to_cart:\n" +
" - 'quiero 2kg de asado' (pedido directo con cantidad)\n" +
" - 'dame un vino' (pedido directo)\n" +
" - 'anotame 3 provoletas' (pedido directo)\n" +
" - 'necesito chimichurri' (pedido directo)\n" +
"\n" +
"EJEMPLOS CONTRASTIVOS (importante distinguir):\n" +
"- 'quiero asado' → add_to_cart (afirmación directa de compra)\n" +
"- 'quiero hacer un asado, qué llevo?' → recommend (planificación, pregunta)\n" +
"- 'dame vino' → add_to_cart (pedido directo)\n" +
"- 'qué vino me recomendás?' → recommend (pide consejo)\n" +
"- 'tenés vino?' → browse (pregunta disponibilidad)\n" +
"- '2kg de vacío' → add_to_cart (pedido con cantidad)\n" +
"- 'para 6 personas cuánto vacío necesito?' → recommend (pregunta de planificación)\n" +
"\n" +
"- SALUDOS: Si el usuario SOLO saluda sin mencionar productos (hola, buen día, buenas tardes, buenas noches, qué tal, hey, hi), usá intent='greeting'. needs.catalog_lookup=false.\n" +
"- VER CARRITO: Si el usuario pregunta qué tiene anotado/pedido/en el carrito (ej: 'qué tengo?', 'qué llevó?', 'qué anoté?', 'mostrame mi pedido'), usá intent='view_cart'. needs.catalog_lookup=false.\n" +
"- CONFIRMAR ORDEN: Si el usuario quiere cerrar/confirmar el pedido (ej: 'listo', 'eso es todo', 'cerrar pedido', 'ya está', 'nada más'), usá intent='confirm_order'. needs.catalog_lookup=false.\n" +
@@ -438,3 +470,140 @@ export async function llmRecommendWriter({
validation: { ok: false, errors: validateRecommendWriter.errors || [] },
};
}
// --- Planning Recommendation LLM ---
const PlanningRecommendSchema = {
$id: "PlanningRecommend",
type: "object",
additionalProperties: false,
required: ["reply", "suggested_items"],
properties: {
reply: { type: "string", minLength: 1 },
suggested_items: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["product_query", "suggested_qty", "unit", "reason"],
properties: {
product_query: { type: "string", minLength: 1 },
suggested_qty: { anyOf: [{ type: "number" }, { type: "null" }] },
unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] },
reason: { type: "string" },
},
},
},
},
};
const validatePlanningRecommend = ajv.compile(PlanningRecommendSchema);
/**
* LLM para recomendaciones de planificación (eventos, asados, etc.)
* Genera sugerencias de productos y cantidades basadas en el contexto.
*/
export async function llmPlanningRecommend({
user_message,
event_type = null,
people_count = null,
cooking_method = null,
mentioned_products = [],
available_categories = [],
locale = "es-AR",
model,
} = {}) {
const system =
"Sos un experto en carnicería y asados argentinos (es-AR). Tu rol es recomendar productos y cantidades.\n\n" +
"CONTEXTO:\n" +
"- Trabajás en una carnicería online.\n" +
"- El cliente te pide ayuda para planificar una comida/evento.\n" +
"- Debés sugerir productos disponibles y cantidades razonables.\n\n" +
"REGLAS DE CANTIDADES (por persona, aproximado):\n" +
"- Asado/Parrilla: 400-500g de carne total por persona\n" +
"- Horno: 300-400g de carne por persona\n" +
"- Mezcla sugerida para asado:\n" +
" * 200g de asado de tira o costilla\n" +
" * 150g de vacío o entraña\n" +
" * 50-100g de chorizo/morcilla (1 unidad c/u cada 2-3 personas)\n" +
" * 1 provoleta cada 3-4 personas\n" +
" * Chimichurri: 1 frasco cada 6-8 personas\n" +
"- Vino: 1 botella cada 2-3 personas\n\n" +
"REGLAS DE RESPUESTA:\n" +
"- Usá product_query con términos genéricos que el catálogo pueda buscar (ej: 'asado', 'vacío', 'chorizo').\n" +
"- NO inventes productos específicos, usá nombres genéricos.\n" +
"- Incluí un 'reason' breve para cada sugerencia.\n" +
"- IMPORTANTE: En 'reply' escribí un mensaje COMPLETO que incluya la lista de productos con cantidades.\n" +
" El reply debe ser autosuficiente, con formato:\n" +
" 'Para [X] personas te recomiendo:\\n- 2kg de asado de tira\\n- 1kg de vacío\\n- 2 chorizos\\n...'\n" +
"- Si el cliente pregunta por PRECIOS, respondé que vas a buscar los productos para mostrarle los precios.\n" +
"- Si pregunta por método de cocción (horno vs parrilla), explicá brevemente y sugerí cortes apropiados.\n\n" +
"FORMATO JSON ESTRICTO:\n" +
"{\n" +
" \"reply\": \"Para 6 personas te recomiendo:\\n- 2kg de asado de tira\\n- 1.5kg de vacío\\n- 3 chorizos\\n- 3 morcillas\\n- 2 provoletas\\n\\n¿Querés que te arme el pedido con esto?\",\n" +
" \"suggested_items\": [\n" +
" {\"product_query\": \"asado\", \"suggested_qty\": 2, \"unit\": \"kg\", \"reason\": \"base del asado\"},\n" +
" {\"product_query\": \"vacío\", \"suggested_qty\": 1.5, \"unit\": \"kg\", \"reason\": \"corte tierno\"}\n" +
" ]\n" +
"}\n" +
"suggested_items se usa para buscar en el catálogo. Si no hay items, usá suggested_items: [].\n";
const userPayload = {
locale,
user_message,
context: {
event_type,
people_count,
cooking_method,
mentioned_products,
},
available_categories: available_categories.slice(0, 30),
};
const first = await jsonCompletion({ system, user: JSON.stringify(userPayload), model });
if (validatePlanningRecommend(first.parsed)) {
return {
reply: first.parsed.reply,
suggested_items: first.parsed.suggested_items || [],
raw_text: first.raw_text,
model: first.model,
usage: first.usage,
validation: { ok: true },
};
}
// Retry con errores
const errors = validatePlanningRecommend.errors || [];
const systemRetry =
system +
"\nTu respuesta anterior no validó. Corregí el JSON.\n" +
`Errores: ${JSON.stringify(errors).slice(0, 1000)}\n`;
try {
const second = await jsonCompletion({ system: systemRetry, user: JSON.stringify(userPayload), model });
if (validatePlanningRecommend(second.parsed)) {
return {
reply: second.parsed.reply,
suggested_items: second.parsed.suggested_items || [],
raw_text: second.raw_text,
model: second.model,
usage: second.usage,
validation: { ok: true, retried: true },
};
}
} catch (e) {
// Fallback
}
// Fallback: usar el reply si existe
const fallbackReply = first.parsed?.reply || "Dejame buscar algunas opciones para vos.";
return {
reply: fallbackReply,
suggested_items: [],
raw_text: first.raw_text,
model: first.model,
usage: first.usage,
validation: { ok: false, errors },
};
}

View File

@@ -0,0 +1,251 @@
/**
* Modelo unificado de orden para el contexto de conversación.
*
* Reemplaza: order_basket, pending_items, pending_clarification, pending_item,
* checkout_step, payment_method, shipping_method, delivery_address
*/
// 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
payment_type: null, // "link" | "cash" | null
is_delivery: null, // true | false | null
shipping_address: null,
woo_order_id: null,
is_paid: false,
};
}
/**
* Crea un item de carrito confirmado
*/
export function createCartItem({ woo_id, qty, unit, name = null, price = null }) {
return {
woo_id,
qty: Number(qty) || 1,
unit: unit || "unit", // "kg" | "g" | "unit"
name,
price,
};
}
/**
* 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,
qty = null,
unit = null,
status = PendingStatus.NEEDS_TYPE
}) {
return {
id: id || `pending_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
query,
candidates, // [{ woo_id, name, price, display_unit }]
selected_woo_id, // Producto elegido (null si NEEDS_TYPE)
selected_name,
selected_price,
selected_unit, // Unidad del producto seleccionado
qty, // Cantidad (null si NEEDS_QUANTITY)
unit, // Unidad elegida por usuario
status,
};
}
/**
* 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,
});
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],
};
}
/**
* 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.payment_type = ctx.payment_method || null;
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;
order.is_paid = ctx.mp?.payment_status === "approved" ||
ctx.payment?.status === "paid" ||
ctx.payment_status === "approved" || false;
return order;
}
/**
* Formatea el carrito para mostrar al usuario
*/
export function formatCartForDisplay(order, locale = "es-AR") {
if (!order?.cart?.length) {
return "Tu carrito está vacío.";
}
const lines = order.cart.map(item => {
const qtyStr = item.unit === "kg" ? `${item.qty}kg` :
item.unit === "g" ? `${item.qty}g` :
`${item.qty}`;
return `- ${qtyStr} de ${item.name || `Producto #${item.woo_id}`}`;
});
return "Tenés anotado:\n" + lines.join("\n");
}
/**
* Formatea opciones de un pending item para mostrar al usuario
*/
export function formatOptionsForDisplay(pendingItem, pageSize = 9) {
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 };
}

View File

@@ -1,6 +1,8 @@
import { getRecoRules, getRecoRulesByProductIds } from "../2-identity/db/repo.js";
import { getSnapshotItemsByIds } from "../shared/wooSnapshot.js";
import { getRecoRules, getRecoRulesByProductIds, getProductQtyRulesByEvent } from "../2-identity/db/repo.js";
import { getSnapshotItemsByIds, searchSnapshotItems } from "../shared/wooSnapshot.js";
import { buildPagedOptions } from "./turnEngineV3.pendingSelection.js";
import { llmPlanningRecommend } from "./openai.js";
import { retrieveCandidates } from "./catalogRetrieval.js";
/**
* Extrae los IDs de productos del carrito.
@@ -8,7 +10,7 @@ import { buildPagedOptions } from "./turnEngineV3.pendingSelection.js";
function getBasketProductIds(basket_items) {
const items = Array.isArray(basket_items) ? basket_items : [];
return items
.map(item => item.product_id || item.woo_product_id)
.map(item => item.product_id || item.woo_product_id || item.woo_id)
.filter(id => id != null)
.map(Number);
}
@@ -30,29 +32,356 @@ function collectRecommendedIds(rules, excludeIds = []) {
return [...ids];
}
export async function handleRecommend({
/**
* Detecta si el mensaje es una solicitud de planificación/consejo.
*/
function detectPlanningRequest(text, nlu) {
const t = String(text || "").toLowerCase();
// Patrones de planificación
const planningPatterns = [
/\bpara\s+(\d+)\s*(personas?|comensales?|invitados?)\b/i,
/\bqu[eé]\s+(me\s+)?recomend[aá]s?\b/i,
/\bqu[eé]\s+(necesito|llevo|compro)\b/i,
/\bcu[aá]nto\s+(necesito|llevo|compro)\b/i,
/\bpara\s+(un|una|el|la)\s+(asado|parrilla|parrillada|horno|sangu[ií]?che?s?|reuni[oó]n|evento|juntada|fiesta)\b/i,
/\bqu[eé]\s+cortes?\b/i,
/\bqu[eé]\s+vino?\s+(va|combina|queda)\b/i,
/\bmaridaje\b/i,
/\bqu[eé]\s+(llevo|necesito|compro)\s+para\b/i,
/\bcomo\s+para\s+\d+/i,
];
for (const pattern of planningPatterns) {
if (pattern.test(t)) return true;
}
// Si el NLU es recommend y no hay productos específicos en el carrito, es planificación
if (nlu?.intent === "recommend") {
return true;
}
return false;
}
/**
* Extrae información de planificación del texto.
*/
function extractPlanningInfo(text) {
const t = String(text || "").toLowerCase();
const info = {
people_count: null,
adults_count: null,
children_count: null,
event_type: null,
cooking_method: null,
mentioned_products: [],
};
// Cantidad de adultos
const adultsMatch = t.match(/\b(\d+)\s*(adultos?|grandes?|mayores?)\b/i);
if (adultsMatch) {
info.adults_count = parseInt(adultsMatch[1], 10);
}
// Cantidad de niños
const childrenMatch = t.match(/\b(\d+)\s*(ni[nñ]os?|chicos?|menores?|peques?|hijos?)\b/i);
if (childrenMatch) {
info.children_count = parseInt(childrenMatch[1], 10);
}
// Cantidad total de personas (si no especificó adultos/niños)
const peopleMatch = t.match(/\b(\d+)\s*(personas?|comensales?|invitados?)\b/i) ||
t.match(/\bpara\s+(\d+)\b/) ||
t.match(/\bcomo\s+para\s+(\d+)\b/i);
if (peopleMatch) {
info.people_count = parseInt(peopleMatch[1], 10);
}
// Si especificó adultos y niños pero no total, calcularlo
if (info.adults_count !== null || info.children_count !== null) {
info.people_count = (info.adults_count || 0) + (info.children_count || 0);
}
// Si solo especificó total, asumir todos adultos
if (info.people_count && info.adults_count === null && info.children_count === null) {
info.adults_count = info.people_count;
info.children_count = 0;
}
// Tipo de evento (asado, horno, sanguches)
if (/\basado\b|\bparrilla(da)?\b/i.test(t)) info.event_type = "asado";
else if (/\bhorno\b/i.test(t)) info.event_type = "horno";
else if (/\bsangu[ií]?che?s?\b|\bsandwich(es)?\b/i.test(t)) info.event_type = "sanguches";
// Método de cocción
if (/\bparrilla\b|\bbrasa\b|\bcarbón\b/i.test(t)) info.cooking_method = "parrilla";
else if (/\bhorno\b/i.test(t)) info.cooking_method = "horno";
else if (/\bplancha\b/i.test(t)) info.cooking_method = "plancha";
// Productos mencionados (keywords comunes)
const productKeywords = ["asado", "vacío", "vacio", "entraña", "entrania", "chorizo", "morcilla",
"provoleta", "chimichurri", "vino", "tira", "costilla", "bife", "lomo", "matambre",
"pollo", "cerdo", "bondiola", "carne"];
for (const kw of productKeywords) {
if (t.includes(kw)) {
info.mentioned_products.push(kw);
}
}
return info;
}
/**
* Maneja recomendaciones de planificación usando reglas de BD o LLM como fallback.
*/
async function handlePlanningRecommend({ tenantId, text, nlu, order, audit }) {
const planningInfo = extractPlanningInfo(text);
audit.planning_info = planningInfo;
const adultsCount = planningInfo.adults_count || planningInfo.people_count || 1;
const childrenCount = planningInfo.children_count || 0;
const totalPeople = adultsCount + childrenCount;
const eventType = planningInfo.event_type || "asado"; // Default asado
// 1) Buscar reglas de cantidad desde la nueva tabla product_qty_rules
const qtyRules = await getProductQtyRulesByEvent({ tenant_id: tenantId, event_type: eventType });
audit.qty_rules_found = qtyRules.length;
// Si hay reglas configuradas, usarlas en lugar del LLM
if (qtyRules.length > 0) {
audit.using_rules = { event: eventType, count: qtyRules.length };
// Agrupar por producto y calcular cantidades según tipo de persona
const productQtyMap = new Map(); // woo_product_id -> { qty, unit, product }
for (const rule of qtyRules) {
const qtyPerPerson = Number(rule.qty_per_person) || 0;
const personType = rule.person_type || "adult";
// Calcular cantidad según tipo de persona
let calculatedQty = 0;
if (personType === "adult") {
calculatedQty = qtyPerPerson * adultsCount;
} else if (personType === "child") {
calculatedQty = qtyPerPerson * childrenCount;
}
if (calculatedQty <= 0) continue;
const key = rule.woo_product_id;
if (productQtyMap.has(key)) {
// Sumar cantidad al existente
const existing = productQtyMap.get(key);
existing.qty += calculatedQty;
} else {
productQtyMap.set(key, {
woo_product_id: rule.woo_product_id,
qty: calculatedQty,
unit: rule.unit || "kg",
});
}
}
// Obtener info de productos del catálogo
const productIds = [...productQtyMap.keys()];
const { items: products } = await getSnapshotItemsByIds({
tenantId,
text,
prev_context = {},
basket_items = [],
limit = 9,
} = {}) {
wooProductIds: productIds,
});
const productMap = new Map();
for (const p of products) {
productMap.set(p.woo_product_id, p);
}
// Convertir a pendingItems
const pendingItems = [];
for (const [wooId, data] of productQtyMap) {
const product = productMap.get(wooId);
if (!product) continue;
const roundedQty = Math.round(data.qty * 100) / 100; // Redondear a 2 decimales
pendingItems.push({
query: product.name,
suggested_qty: roundedQty,
suggested_unit: data.unit,
reason: "",
candidates: [{
woo_id: product.woo_product_id,
name: product.name,
price: product.price,
}],
});
}
audit.pending_items_created = pendingItems.length;
// Construir respuesta con detalle de adultos/niños si aplica
let headerLine = "";
if (childrenCount > 0) {
headerLine = `Para ${adultsCount} adulto${adultsCount > 1 ? "s" : ""} y ${childrenCount} niño${childrenCount > 1 ? "s" : ""}, te recomiendo:`;
} else {
headerLine = `Para ${totalPeople} persona${totalPeople > 1 ? "s" : ""}, te recomiendo:`;
}
let reply = headerLine + "\n\n";
const lines = pendingItems.map(item => {
const qtyStr = item.suggested_unit === "unidad"
? `${item.suggested_qty} unidad${item.suggested_qty > 1 ? "es" : ""}`
: `${item.suggested_qty}${item.suggested_unit}`;
return `${item.candidates[0]?.name}: ${qtyStr}`;
});
reply += lines.join("\n");
// Agregar precios si están disponibles
const itemsWithPrices = pendingItems.filter(item => item.candidates[0]?.price != null);
if (itemsWithPrices.length > 0) {
reply += "\n\n¿Querés que te arme el pedido?";
const priceLines = itemsWithPrices.map(item => {
const unitLabel = item.suggested_unit === "unidad" ? "/u" : `/${item.suggested_unit}`;
return `${item.candidates[0].name}: $${item.candidates[0].price}${unitLabel}`;
}).join("\n");
reply += "\n\n📋 *Precios actuales:*\n" + priceLines;
}
return {
plan: {
reply,
next_state: null,
intent: "recommend",
missing_fields: [],
order_action: "none",
},
decision: {
actions: [],
order,
audit,
context_patch: {
planning_suggestions: pendingItems,
},
},
};
}
// 2) Fallback: usar LLM si no hay reglas configuradas
audit.fallback_to_llm = true;
// Obtener categorías disponibles para contexto
const categoryResult = await searchSnapshotItems({ tenantId, q: "", limit: 50 });
const categories = new Set();
for (const item of (categoryResult?.items || [])) {
for (const cat of (item.categories || [])) {
if (cat?.name) categories.add(cat.name);
}
}
// Llamar al LLM de planificación
const llmResult = await llmPlanningRecommend({
user_message: text,
event_type: planningInfo.event_type,
people_count: planningInfo.people_count,
cooking_method: planningInfo.cooking_method,
mentioned_products: planningInfo.mentioned_products,
available_categories: [...categories],
});
audit.planning_llm = {
model: llmResult.model,
usage: llmResult.usage,
suggested_count: llmResult.suggested_items?.length || 0,
validation: llmResult.validation,
};
// Si hay items sugeridos, buscar en el catálogo y crear pending items
const suggestedItems = llmResult.suggested_items || [];
const pendingItems = [];
for (const suggestion of suggestedItems.slice(0, 8)) {
const searchResult = await retrieveCandidates({
tenantId,
query: suggestion.product_query,
limit: 5
});
const candidates = searchResult?.candidates || [];
if (candidates.length > 0) {
pendingItems.push({
query: suggestion.product_query,
suggested_qty: suggestion.suggested_qty,
suggested_unit: suggestion.unit,
reason: suggestion.reason,
candidates: candidates.slice(0, 5).map(c => ({
woo_id: c.woo_product_id,
name: c.name,
price: c.price,
})),
});
}
}
audit.pending_items_created = pendingItems.length;
// Usar el reply del LLM directamente (ya incluye la lista de productos)
let reply = llmResult.reply || "Te ayudo con eso.";
// Si encontramos items en el catálogo, agregar precios reales
if (pendingItems.length > 0) {
// Solo agregar info de precios si el catálogo tiene datos
const itemsWithPrices = pendingItems.filter(item => item.candidates[0]?.price != null);
if (itemsWithPrices.length > 0) {
// Si el reply NO termina con pregunta de si quiere agregar, añadirla
if (!/\?[\s]*$/.test(reply)) {
reply += "\n\n¿Querés que te arme el pedido?";
}
// Agregar precios reales del catálogo
const priceLines = itemsWithPrices.map(item => {
const unitLabel = item.suggested_unit === "unidad" ? "/u" : `/${item.suggested_unit || "kg"}`;
return `${item.candidates[0].name}: $${item.candidates[0].price}${unitLabel}`;
}).join("\n");
reply += "\n\n📋 *Precios actuales:*\n" + priceLines;
}
}
return {
plan: {
reply,
next_state: null, // Se determinará por el caller
intent: "recommend",
missing_fields: [],
order_action: "none",
},
decision: {
actions: [],
order,
audit,
context_patch: {
planning_suggestions: pendingItems,
},
},
};
}
/**
* Maneja recomendaciones de cross-sell basadas en el carrito.
*/
async function handleCrossSellRecommend({ tenantId, text, order, basket_items, limit, audit }) {
const context_patch = {};
const audit = { basket_product_ids: [], rules_used: [], recommended_ids: [] };
// 1. Obtener IDs de productos en el carrito
const basketProductIds = getBasketProductIds(basket_items);
audit.basket_product_ids = basketProductIds;
if (!basketProductIds.length) {
return {
reply: "Primero agregá algo al carrito y después te sugiero complementos perfectos.",
actions: [],
context_patch,
audit,
asked_slot: null,
candidates: [],
};
// No hay items, delegar a planificación
return null;
}
// 2. Buscar reglas que matcheen con los productos del carrito
@@ -63,12 +392,14 @@ export async function handleRecommend({
// Fallback: no hay reglas configuradas para estos productos
const basketNames = basket_items.map(i => i.label || i.name).filter(Boolean).slice(0, 3).join(", ");
return {
plan: {
reply: `Por ahora no tengo recomendaciones especiales para ${basketNames}. ¿Te interesa algo más?`,
actions: [],
context_patch,
audit,
asked_slot: null,
candidates: [],
next_state: null,
intent: "recommend",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order, audit, context_patch },
};
}
@@ -78,26 +409,31 @@ export async function handleRecommend({
if (!recommendedIds.length) {
return {
plan: {
reply: "No encontré complementos adicionales para tu pedido. ¿Necesitás algo más?",
actions: [],
context_patch,
audit,
asked_slot: null,
candidates: [],
next_state: null,
intent: "recommend",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order, audit, context_patch },
};
}
// 4. Obtener detalles de los productos recomendados
const recommendedProducts = await getSnapshotItemsByIds({ tenantId, wooProductIds: recommendedIds.slice(0, limit) });
const recommendedResult = await getSnapshotItemsByIds({ tenantId, wooProductIds: recommendedIds.slice(0, limit) });
const recommendedProducts = recommendedResult?.items || [];
if (!recommendedProducts.length) {
return {
plan: {
reply: "No encontré los productos recomendados disponibles. ¿Querés ver algo más?",
actions: [],
context_patch,
audit,
asked_slot: null,
candidates: [],
next_state: null,
intent: "recommend",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order, audit, context_patch },
};
}
@@ -116,11 +452,60 @@ export async function handleRecommend({
context_patch.pending_item = null;
return {
plan: {
reply,
next_state: null,
intent: "recommend",
missing_fields: [],
order_action: "none",
},
decision: {
actions: [{ type: "show_options", payload: { count: pending.options?.length || 0 } }],
context_patch,
order,
audit,
asked_slot: null,
candidates: recommendedProducts.slice(0, limit),
context_patch,
},
};
}
/**
* Handler principal de recomendaciones.
* Detecta si es planificación o cross-sell y delega al handler apropiado.
*/
export async function handleRecommend({
tenantId,
text,
nlu,
order,
prevContext = {},
basket_items = [],
limit = 9,
audit = {},
} = {}) {
audit.recommendation_type = null;
// Extraer basket_items del order si no se pasan explícitamente
const cartItems = basket_items.length > 0
? basket_items
: (order?.cart || []).map(item => ({
woo_product_id: item.woo_id,
name: item.name,
label: item.name,
}));
// Detectar si es planificación
const isPlanningRequest = detectPlanningRequest(text, nlu);
// Si hay items en el carrito y no es claramente planificación, intentar cross-sell primero
if (cartItems.length > 0 && !isPlanningRequest) {
audit.recommendation_type = "cross_sell";
const crossSellResult = await handleCrossSellRecommend({
tenantId, text, order, basket_items: cartItems, limit, audit
});
if (crossSellResult) return crossSellResult;
}
// Planificación (carrito vacío o solicitud explícita de consejo)
audit.recommendation_type = "planning";
return handlePlanningRecommend({ tenantId, text, nlu, order, audit });
}

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

File diff suppressed because it is too large Load Diff

View File

@@ -111,13 +111,17 @@ function normalizeWooProduct(p) {
function snapshotRowToItem(row) {
const categories = Array.isArray(row?.categories) ? row.categories : [];
const attributes = row?.attributes_normalized && typeof row.attributes_normalized === "object" ? row.attributes_normalized : {};
const raw = row?.raw || {};
return {
woo_product_id: row?.woo_id,
name: row?.name || "",
sku: row?.slug || null,
sku: raw?.SKU || raw?.sku || row?.slug || null,
slug: row?.slug || null,
price: row?.price_current != null ? Number(row.price_current) : null,
currency: null,
type: row?.type || null,
stock_status: row?.stock_status || null,
stock_qty: row?.stock_qty ?? null,
categories: categories.map((c) => ({ id: null, name: String(c), slug: String(c) })),
attributes: Object.entries(attributes).map(([name, options]) => ({
name,