productos, equivalencias, cross-sell y cantidades
This commit is contained in:
39
db/migrations/20260119100000_reco_rule_items.sql
Normal file
39
db/migrations/20260119100000_reco_rule_items.sql
Normal 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;
|
||||||
24
db/migrations/20260119100100_alias_product_mappings.sql
Normal file
24
db/migrations/20260119100100_alias_product_mappings.sql
Normal 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;
|
||||||
12
db/migrations/20260119110000_reco_rule_items_audience.sql
Normal file
12
db/migrations/20260119110000_reco_rule_items_audience.sql
Normal 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;
|
||||||
41
db/migrations/20260119120000_product_qty_rules.sql
Normal file
41
db/migrations/20260119120000_product_qty_rules.sql
Normal 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;
|
||||||
@@ -7,6 +7,7 @@ import "./components/users-crud.js";
|
|||||||
import "./components/products-crud.js";
|
import "./components/products-crud.js";
|
||||||
import "./components/aliases-crud.js";
|
import "./components/aliases-crud.js";
|
||||||
import "./components/recommendations-crud.js";
|
import "./components/recommendations-crud.js";
|
||||||
|
import "./components/quantities-crud.js";
|
||||||
import { connectSSE } from "./lib/sse.js";
|
import { connectSSE } from "./lib/sse.js";
|
||||||
|
|
||||||
connectSSE();
|
connectSSE();
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ class AliasesCrud extends HTMLElement {
|
|||||||
this.searchQuery = "";
|
this.searchQuery = "";
|
||||||
this.editMode = null; // 'create' | 'edit' | null
|
this.editMode = null; // 'create' | 'edit' | null
|
||||||
|
|
||||||
|
// Productos mapeados con scores
|
||||||
|
this.productMappings = [];
|
||||||
|
|
||||||
this.shadowRoot.innerHTML = `
|
this.shadowRoot.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
* { 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 { 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; }
|
.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.secondary:hover { background:#2d3e52; }
|
||||||
button.danger { background:#e74c3c; }
|
button.danger { background:#e74c3c; }
|
||||||
button.danger:hover { background:#c0392b; }
|
button.danger:hover { background:#c0392b; }
|
||||||
|
button.small { padding:4px 8px; font-size:11px; }
|
||||||
|
|
||||||
.list { flex:1; overflow-y:auto; }
|
.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 { 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:hover { border-color:#1f6feb; }
|
||||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
.item.active { border-color:#1f6feb; background:#111b2a; }
|
||||||
.item-alias { font-weight:600; color:#e7eef7; margin-bottom:4px; font-size:15px; }
|
.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; }
|
.item-boost { color:#2ecc71; font-size:11px; }
|
||||||
|
|
||||||
.form { flex:1; overflow-y:auto; }
|
.form { flex:1; overflow-y:auto; }
|
||||||
@@ -46,6 +50,35 @@ class AliasesCrud extends HTMLElement {
|
|||||||
|
|
||||||
.actions { display:flex; gap:8px; margin-top:16px; }
|
.actions { display:flex; gap:8px; margin-top:16px; }
|
||||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
.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>
|
</style>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -63,7 +96,7 @@ class AliasesCrud extends HTMLElement {
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-title" id="formTitle">Detalle</div>
|
<div class="panel-title" id="formTitle">Detalle</div>
|
||||||
<div class="form" id="form">
|
<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>
|
</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() {
|
renderList() {
|
||||||
const list = this.shadowRoot.getElementById("list");
|
const list = this.shadowRoot.getElementById("list");
|
||||||
|
|
||||||
@@ -128,18 +166,40 @@ class AliasesCrud extends HTMLElement {
|
|||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = "item" + (this.selected?.alias === item.alias ? " active" : "");
|
el.className = "item" + (this.selected?.alias === item.alias ? " active" : "");
|
||||||
|
|
||||||
const product = this.products.find(p => p.woo_product_id === item.woo_product_id);
|
// Mostrar productos mapeados
|
||||||
const productName = product?.name || `ID: ${item.woo_product_id || "—"}`;
|
const mappings = item.product_mappings || [];
|
||||||
const boost = item.boost ? `+${item.boost}` : "";
|
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 = `
|
el.innerHTML = `
|
||||||
<div class="item-alias">"${item.alias}"</div>
|
<div class="item-alias">"${item.alias}" <span class="badge">${mappings.length || (item.woo_product_id ? 1 : 0)} productos</span></div>
|
||||||
<div class="item-product">→ ${productName} ${boost ? `<span class="item-boost">(boost: ${boost})</span>` : ""}</div>
|
${productsHtml}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
el.onclick = () => {
|
el.onclick = () => {
|
||||||
this.selected = item;
|
this.selected = item;
|
||||||
this.editMode = "edit";
|
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.renderList();
|
||||||
this.renderForm();
|
this.renderForm();
|
||||||
};
|
};
|
||||||
@@ -151,6 +211,7 @@ class AliasesCrud extends HTMLElement {
|
|||||||
showCreateForm() {
|
showCreateForm() {
|
||||||
this.selected = null;
|
this.selected = null;
|
||||||
this.editMode = "create";
|
this.editMode = "create";
|
||||||
|
this.productMappings = [];
|
||||||
this.renderList();
|
this.renderList();
|
||||||
this.renderForm();
|
this.renderForm();
|
||||||
}
|
}
|
||||||
@@ -161,7 +222,7 @@ class AliasesCrud extends HTMLElement {
|
|||||||
|
|
||||||
if (!this.editMode) {
|
if (!this.editMode) {
|
||||||
title.textContent = "Detalle";
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,36 +230,49 @@ class AliasesCrud extends HTMLElement {
|
|||||||
title.textContent = isCreate ? "Nuevo Alias" : "Editar Alias";
|
title.textContent = isCreate ? "Nuevo Alias" : "Editar Alias";
|
||||||
|
|
||||||
const alias = this.selected?.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 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 = `
|
form.innerHTML = `
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Alias (lo que dice el usuario)</label>
|
<label>Alias (lo que dice el usuario)</label>
|
||||||
<input type="text" id="aliasInput" value="${alias}" ${isCreate ? "" : "disabled"} placeholder="ej: chimi, vacio, bife" />
|
<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 class="field-hint">Sin tildes, en minusculas. Ej: "chimi" mapea a "Chimichurri"</div>
|
||||||
</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">
|
<div class="field">
|
||||||
<label>Categoria hint (opcional)</label>
|
<label>Categoria hint (opcional)</label>
|
||||||
<input type="text" id="categoryInput" value="${categoryHint}" placeholder="ej: carnes, bebidas" />
|
<input type="text" id="categoryInput" value="${categoryHint}" placeholder="ej: carnes, bebidas" />
|
||||||
</div>
|
</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">
|
<div class="actions">
|
||||||
<button id="saveBtn">${isCreate ? "Crear" : "Guardar"}</button>
|
<button id="saveBtn">${isCreate ? "Crear" : "Guardar"}</button>
|
||||||
${!isCreate ? `<button id="deleteBtn" class="danger">Eliminar</button>` : ""}
|
${!isCreate ? `<button id="deleteBtn" class="danger">Eliminar</button>` : ""}
|
||||||
@@ -211,28 +285,147 @@ class AliasesCrud extends HTMLElement {
|
|||||||
if (!isCreate) {
|
if (!isCreate) {
|
||||||
this.shadowRoot.getElementById("deleteBtn").onclick = () => this.delete();
|
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() {
|
async save() {
|
||||||
const aliasInput = this.shadowRoot.getElementById("aliasInput").value.trim().toLowerCase();
|
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();
|
const categoryInput = this.shadowRoot.getElementById("categoryInput").value.trim();
|
||||||
|
|
||||||
if (!aliasInput) {
|
if (!aliasInput) {
|
||||||
alert("El alias es requerido");
|
alert("El alias es requerido");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!productInput) {
|
|
||||||
alert("Seleccioná un producto");
|
if (!this.productMappings.length) {
|
||||||
|
alert("Agrega al menos un producto");
|
||||||
return;
|
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 = {
|
const data = {
|
||||||
alias: aliasInput,
|
alias: aliasInput,
|
||||||
woo_product_id: parseInt(productInput, 10),
|
woo_product_id: primaryProduct.woo_product_id,
|
||||||
boost: boostInput,
|
boost: primaryProduct.score || 1.0,
|
||||||
category_hint: categoryInput || null,
|
category_hint: categoryInput || null,
|
||||||
|
product_mappings: this.productMappings,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -243,6 +436,7 @@ class AliasesCrud extends HTMLElement {
|
|||||||
}
|
}
|
||||||
this.editMode = null;
|
this.editMode = null;
|
||||||
this.selected = null;
|
this.selected = null;
|
||||||
|
this.productMappings = [];
|
||||||
await this.load();
|
await this.load();
|
||||||
this.renderForm();
|
this.renderForm();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -259,6 +453,7 @@ class AliasesCrud extends HTMLElement {
|
|||||||
await api.deleteAlias(this.selected.alias);
|
await api.deleteAlias(this.selected.alias);
|
||||||
this.editMode = null;
|
this.editMode = null;
|
||||||
this.selected = null;
|
this.selected = null;
|
||||||
|
this.productMappings = [];
|
||||||
await this.load();
|
await this.load();
|
||||||
this.renderForm();
|
this.renderForm();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -270,6 +465,7 @@ class AliasesCrud extends HTMLElement {
|
|||||||
cancel() {
|
cancel() {
|
||||||
this.editMode = null;
|
this.editMode = null;
|
||||||
this.selected = null;
|
this.selected = null;
|
||||||
|
this.productMappings = [];
|
||||||
this.renderList();
|
this.renderList();
|
||||||
this.renderForm();
|
this.renderForm();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,14 +170,50 @@ class ConversationInspector extends HTMLElement {
|
|||||||
if (!list.length) return "—";
|
if (!list.length) return "—";
|
||||||
return list
|
return list
|
||||||
.map((it) => {
|
.map((it) => {
|
||||||
const label = it.label || it.name || `#${it.product_id}`;
|
const label = it.label || it.name || `#${it.product_id || it.woo_id}`;
|
||||||
const qty = it.quantity != null ? `${it.quantity}` : "?";
|
const qty = it.quantity ?? it.qty ?? "?";
|
||||||
const unit = it.unit || "";
|
const unit = it.unit || "";
|
||||||
return `${label} (${qty}${unit ? " " + unit : ""})`;
|
return `${label} (${qty}${unit ? " " + unit : ""})`;
|
||||||
})
|
})
|
||||||
.join(" · ");
|
.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 = []) {
|
toolSummary(tools = []) {
|
||||||
return tools.map((t) => ({
|
return tools.map((t) => ({
|
||||||
type: t.type || t.name || "tool",
|
type: t.type || t.name || "tool",
|
||||||
@@ -218,7 +254,10 @@ class ConversationInspector extends HTMLElement {
|
|||||||
const intent = run?.llm_output?.intent || "—";
|
const intent = run?.llm_output?.intent || "—";
|
||||||
const nextState = run?.llm_output?.next_state || "—";
|
const nextState = run?.llm_output?.next_state || "—";
|
||||||
const prevState = row.nextRun?.prev_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 tools = this.toolSummary(run?.tools || []);
|
||||||
|
|
||||||
const llmMeta = run?.llm_output?._llm || null;
|
const llmMeta = run?.llm_output?._llm || null;
|
||||||
@@ -240,7 +279,7 @@ class ConversationInspector extends HTMLElement {
|
|||||||
<div class="k">NLU</div>
|
<div class="k">NLU</div>
|
||||||
<div class="v">${dir === "out" && llmMeta ? llmNote : "—"}</div>
|
<div class="v">${dir === "out" && llmMeta ? llmNote : "—"}</div>
|
||||||
</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">
|
<div class="chips">
|
||||||
${tools
|
${tools
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class ConversationsCrud extends HTMLElement {
|
|||||||
<style>
|
<style>
|
||||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
* { 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 { 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; }
|
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ class OpsShell extends HTMLElement {
|
|||||||
<button class="nav-btn" data-view="users">Usuarios</button>
|
<button class="nav-btn" data-view="users">Usuarios</button>
|
||||||
<button class="nav-btn" data-view="products">Productos</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="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>
|
</nav>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<div class="status" id="sseStatus">SSE: connecting…</div>
|
<div class="status" id="sseStatus">SSE: connecting…</div>
|
||||||
@@ -81,9 +82,15 @@ class OpsShell extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="viewRecommendations" class="view">
|
<div id="viewCrosssell" class="view">
|
||||||
<div class="layout-crud">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class ProductsCrud extends HTMLElement {
|
|||||||
<style>
|
<style>
|
||||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
* { 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 { 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; }
|
.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;
|
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() {
|
renderList() {
|
||||||
const list = this.shadowRoot.getElementById("list");
|
const list = this.shadowRoot.getElementById("list");
|
||||||
|
|
||||||
@@ -195,7 +223,8 @@ class ProductsCrud extends HTMLElement {
|
|||||||
el.dataset.index = i;
|
el.dataset.index = i;
|
||||||
|
|
||||||
const price = item.price != null ? `$${Number(item.price).toLocaleString()}` : "—";
|
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 stock = item.stock_status || item.payload?.stock_status || "unknown";
|
||||||
const stockBadge = stock === "instock"
|
const stockBadge = stock === "instock"
|
||||||
? `<span class="badge stock">En stock</span>`
|
? `<span class="badge stock">En stock</span>`
|
||||||
@@ -221,6 +250,8 @@ class ProductsCrud extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleItemClick(e, item, index) {
|
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) {
|
if (e.shiftKey && this.lastClickedIndex >= 0) {
|
||||||
// Shift+Click: seleccionar rango
|
// Shift+Click: seleccionar rango
|
||||||
const start = Math.min(this.lastClickedIndex, index);
|
const start = Math.min(this.lastClickedIndex, index);
|
||||||
@@ -245,18 +276,38 @@ class ProductsCrud extends HTMLElement {
|
|||||||
this.selectedItems = [item];
|
this.selectedItems = [item];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("[products-crud] after click, selectedItems:", this.selectedItems.length, this.selectedItems.map(s => s.name));
|
||||||
|
|
||||||
this.lastClickedIndex = index;
|
this.lastClickedIndex = index;
|
||||||
this.renderList();
|
this.renderList();
|
||||||
|
try {
|
||||||
this.renderDetail();
|
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
|
// Scroll detail panel to top
|
||||||
const detail = this.shadowRoot.getElementById("detail");
|
const detail = this.shadowRoot.getElementById("detail");
|
||||||
if (detail) detail.scrollTop = 0;
|
if (detail) detail.scrollTop = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
escapeHtml(str) {
|
||||||
|
if (!str) return "";
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
renderDetail() {
|
renderDetail() {
|
||||||
const detail = this.shadowRoot.getElementById("detail");
|
const detail = this.shadowRoot.getElementById("detail");
|
||||||
|
|
||||||
|
console.log("[products-crud] renderDetail called, selectedItems:", this.selectedItems.length);
|
||||||
|
|
||||||
if (!this.selectedItems.length) {
|
if (!this.selectedItems.length) {
|
||||||
detail.innerHTML = `<div class="detail-empty">Seleccioná un producto para ver detalles</div>`;
|
detail.innerHTML = `<div class="detail-empty">Seleccioná un producto para ver detalles</div>`;
|
||||||
return;
|
return;
|
||||||
@@ -264,33 +315,54 @@ class ProductsCrud extends HTMLElement {
|
|||||||
|
|
||||||
// Si hay múltiples seleccionados, mostrar vista de edición masiva
|
// Si hay múltiples seleccionados, mostrar vista de edición masiva
|
||||||
if (this.selectedItems.length > 1) {
|
if (this.selectedItems.length > 1) {
|
||||||
|
console.log("[products-crud] rendering multi detail");
|
||||||
this.renderMultiDetail();
|
this.renderMultiDetail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const p = this.selectedItems[0];
|
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)
|
// Categorías: pueden estar en p.categories (string JSON) o p.payload.categories (array)
|
||||||
let categoriesArray = [];
|
let categoriesArray = [];
|
||||||
if (p.categories) {
|
|
||||||
try {
|
try {
|
||||||
|
if (p.categories) {
|
||||||
const cats = typeof p.categories === 'string' ? JSON.parse(p.categories) : 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)];
|
categoriesArray = Array.isArray(cats) ? cats.map(c => c.name || c) : [String(cats)];
|
||||||
} catch { categoriesArray = [String(p.categories)]; }
|
} else if (p.payload?.categories && Array.isArray(p.payload.categories)) {
|
||||||
} else if (p.payload?.categories) {
|
|
||||||
categoriesArray = p.payload.categories.map(c => c.name || c);
|
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)
|
// Determinar unidad actual (de payload o inferida)
|
||||||
const currentUnit = p.sell_unit || p.payload?._sell_unit_override || this.inferUnit(p);
|
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 = `
|
detail.innerHTML = `
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Nombre</label>
|
<label>Nombre</label>
|
||||||
<div class="field-value">${p.name || "—"}</div>
|
<div class="field-value">${productName}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>ID WooCommerce</label>
|
<label>ID WooCommerce</label>
|
||||||
@@ -298,12 +370,18 @@ class ProductsCrud extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>SKU</label>
|
<label>SKU</label>
|
||||||
<div class="field-value">${p.sku || "—"}</div>
|
<div class="field-value">${productSku}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Precio</label>
|
<label>Precio</label>
|
||||||
<div class="field-value">${p.price != null ? `$${Number(p.price).toLocaleString()} ${p.currency || ""}` : "—"}</div>
|
<div class="field-value">${p.price != null ? `$${Number(p.price).toLocaleString()} ${p.currency || ""}` : "—"}</div>
|
||||||
</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">
|
<div class="field">
|
||||||
<label>Unidad de venta</label>
|
<label>Unidad de venta</label>
|
||||||
<div style="display:flex;gap:8px;align-items:center;">
|
<div style="display:flex;gap:8px;align-items:center;">
|
||||||
@@ -317,15 +395,32 @@ class ProductsCrud extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Categorías (separadas por coma)</label>
|
<label>Categorías actuales</label>
|
||||||
<input type="text" id="categoriesInput" value="${categoriesText}" placeholder="ej: Carnes, Vacuno, Premium" style="width:100%;" />
|
<div id="currentCategories" style="display:flex;flex-wrap:wrap;gap:6px;min-height:30px;margin-bottom:8px;">
|
||||||
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
|
${categoriesArray.length > 0
|
||||||
Categorías del producto, separadas por coma
|
? 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;">×</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>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Atributos</label>
|
<label>Atributos</label>
|
||||||
<div class="field-value">${attributes}</div>
|
<div class="field-value">${this.escapeHtml(attributes)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<button id="saveProduct" style="width:100%;padding:10px;">Guardar cambios</button>
|
<button id="saveProduct" style="width:100%;padding:10px;">Guardar cambios</button>
|
||||||
@@ -336,12 +431,75 @@ class ProductsCrud extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Payload completo</label>
|
<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>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Bind save button
|
// Bind save button
|
||||||
this.shadowRoot.getElementById("saveProduct").onclick = () => this.saveProduct();
|
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;">×</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() {
|
async saveProduct() {
|
||||||
@@ -350,10 +508,9 @@ class ProductsCrud extends HTMLElement {
|
|||||||
const p = this.selectedItems[0];
|
const p = this.selectedItems[0];
|
||||||
const btn = this.shadowRoot.getElementById("saveProduct");
|
const btn = this.shadowRoot.getElementById("saveProduct");
|
||||||
const sellUnitSelect = this.shadowRoot.getElementById("sellUnit");
|
const sellUnitSelect = this.shadowRoot.getElementById("sellUnit");
|
||||||
const categoriesInput = this.shadowRoot.getElementById("categoriesInput");
|
|
||||||
|
|
||||||
const sell_unit = sellUnitSelect.value;
|
const sell_unit = sellUnitSelect.value;
|
||||||
const categories = categoriesInput.value.split(",").map(s => s.trim()).filter(Boolean);
|
const categories = this.getCurrentProductCategories();
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = "Guardando...";
|
btn.textContent = "Guardando...";
|
||||||
@@ -387,11 +544,18 @@ class ProductsCrud extends HTMLElement {
|
|||||||
const names = this.selectedItems.slice(0, 5).map(p => p.name).join(", ");
|
const names = this.selectedItems.slice(0, 5).map(p => p.name).join(", ");
|
||||||
const moreText = count > 5 ? ` y ${count - 5} más...` : "";
|
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 = `
|
detail.innerHTML = `
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Productos seleccionados</label>
|
<label>Productos seleccionados</label>
|
||||||
<div class="field-value" style="color:#2ecc71;font-weight:600;">${count} productos</div>
|
<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;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>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Unidad de venta (para todos)</label>
|
<label>Unidad de venta (para todos)</label>
|
||||||
@@ -400,18 +564,32 @@ class ProductsCrud extends HTMLElement {
|
|||||||
<option value="kg">Por peso (kg)</option>
|
<option value="kg">Por peso (kg)</option>
|
||||||
<option value="unit">Por unidad</option>
|
<option value="unit">Por unidad</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="saveUnit" style="padding:8px 16px;">Guardar para ${count}</button>
|
<button id="saveUnit" style="padding:8px 16px;">Aplicar</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
|
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
|
||||||
Se aplicará a todos los productos seleccionados
|
Se aplicará a todos los productos seleccionados
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="field">
|
||||||
<button id="clearSelection" class="secondary" style="width:100%;">Limpiar selección</button>
|
<button id="clearSelection" class="secondary" style="width:100%;">Limpiar selección</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.shadowRoot.getElementById("saveUnit").onclick = () => this.saveProductUnit();
|
this.shadowRoot.getElementById("saveUnit").onclick = () => this.saveProductUnit();
|
||||||
|
this.shadowRoot.getElementById("addCategory").onclick = () => this.addCategoryToSelected();
|
||||||
this.shadowRoot.getElementById("clearSelection").onclick = () => {
|
this.shadowRoot.getElementById("clearSelection").onclick = () => {
|
||||||
this.selectedItems = [];
|
this.selectedItems = [];
|
||||||
this.lastClickedIndex = -1;
|
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) {
|
inferUnit(p) {
|
||||||
const name = String(p.name || "").toLowerCase();
|
const name = String(p.name || "").toLowerCase();
|
||||||
const cats = (p.payload?.categories || []).map(c => String(c.name || c.slug || "").toLowerCase());
|
const cats = (p.payload?.categories || []).map(c => String(c.name || c.slug || "").toLowerCase());
|
||||||
|
|||||||
340
public/components/quantities-crud.js
Normal file
340
public/components/quantities-crud.js
Normal 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);
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { api } from "../lib/api.js";
|
import { api } from "../lib/api.js";
|
||||||
|
|
||||||
class RecommendationsCrud extends HTMLElement {
|
class RecommendationsCrud extends HTMLElement {
|
||||||
|
static get observedAttributes() {
|
||||||
|
return ["rule-type"];
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: "open" });
|
this.attachShadow({ mode: "open" });
|
||||||
@@ -18,11 +22,21 @@ class RecommendationsCrud extends HTMLElement {
|
|||||||
this.selectedTriggerProducts = [];
|
this.selectedTriggerProducts = [];
|
||||||
this.selectedRecommendedProducts = [];
|
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 = `
|
this.shadowRoot.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
* { 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 { 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; }
|
.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.secondary:hover { background:#2d3e52; }
|
||||||
button.danger { background:#e74c3c; }
|
button.danger { background:#e74c3c; }
|
||||||
button.danger:hover { background:#c0392b; }
|
button.danger:hover { background:#c0392b; }
|
||||||
|
button.small { padding:4px 8px; font-size:11px; }
|
||||||
|
|
||||||
.list { flex:1; overflow-y:auto; }
|
.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 { 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.active { background:#0f2a1a; color:#2ecc71; }
|
||||||
.badge.inactive { background:#241214; color:#e74c3c; }
|
.badge.inactive { background:#241214; color:#e74c3c; }
|
||||||
.badge.priority { background:#253245; color:#8aa0b5; }
|
.badge.priority { background:#253245; color:#8aa0b5; }
|
||||||
|
.badge.type { background:#1a2a4a; color:#5dade2; }
|
||||||
|
|
||||||
.form { flex:1; overflow-y:auto; }
|
.form { flex:1; overflow-y:auto; }
|
||||||
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
||||||
@@ -95,11 +111,36 @@ class RecommendationsCrud extends HTMLElement {
|
|||||||
.product-chip .remove:hover { background:#c0392b; }
|
.product-chip .remove:hover { background:#c0392b; }
|
||||||
|
|
||||||
.empty-hint { color:#8aa0b5; font-size:12px; font-style:italic; }
|
.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>
|
</style>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-title">Reglas de Recomendacion</div>
|
<div class="panel-title" id="listTitle">Reglas</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<input type="text" id="search" placeholder="Buscar regla..." style="flex:1" />
|
<input type="text" id="search" placeholder="Buscar regla..." style="flex:1" />
|
||||||
<button id="newBtn">+ Nueva</button>
|
<button id="newBtn">+ Nueva</button>
|
||||||
@@ -120,6 +161,20 @@ class RecommendationsCrud extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
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.shadowRoot.getElementById("search").oninput = (e) => {
|
||||||
this.searchQuery = e.target.value;
|
this.searchQuery = e.target.value;
|
||||||
clearTimeout(this._searchTimer);
|
clearTimeout(this._searchTimer);
|
||||||
@@ -132,6 +187,14 @@ class RecommendationsCrud extends HTMLElement {
|
|||||||
this.loadProducts();
|
this.loadProducts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(name, oldValue, newValue) {
|
||||||
|
if (name === "rule-type" && oldValue !== newValue) {
|
||||||
|
this.filterRuleType = newValue;
|
||||||
|
this.currentRuleType = newValue || "crosssell";
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadProducts() {
|
async loadProducts() {
|
||||||
if (this.productsLoaded) return;
|
if (this.productsLoaded) return;
|
||||||
try {
|
try {
|
||||||
@@ -150,7 +213,14 @@ class RecommendationsCrud extends HTMLElement {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.recommendations({ q: this.searchQuery, limit: 200 });
|
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.loading = false;
|
||||||
this.renderList();
|
this.renderList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -166,6 +236,10 @@ class RecommendationsCrud extends HTMLElement {
|
|||||||
return p?.name || `Producto #${id}`;
|
return p?.name || `Producto #${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getProduct(id) {
|
||||||
|
return this.allProducts.find(x => x.woo_product_id === id);
|
||||||
|
}
|
||||||
|
|
||||||
renderList() {
|
renderList() {
|
||||||
const list = this.shadowRoot.getElementById("list");
|
const list = this.shadowRoot.getElementById("list");
|
||||||
|
|
||||||
@@ -184,44 +258,79 @@ class RecommendationsCrud extends HTMLElement {
|
|||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = "item" + (this.selected?.id === item.id ? " active" : "");
|
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 triggerIds = item.trigger_product_ids || [];
|
||||||
const triggerNames = triggerIds.slice(0, 3).map(id => this.getProductName(id)).join(", ");
|
const triggerNames = triggerIds.slice(0, 3).map(id => this.getProductName(id)).join(", ");
|
||||||
const triggerMore = triggerIds.length > 3 ? ` (+${triggerIds.length - 3})` : "";
|
const triggerMore = triggerIds.length > 3 ? ` (+${triggerIds.length - 3})` : "";
|
||||||
|
|
||||||
// Mostrar productos recomendados
|
|
||||||
const recoIds = item.recommended_product_ids || [];
|
const recoIds = item.recommended_product_ids || [];
|
||||||
const recoNames = recoIds.slice(0, 3).map(id => this.getProductName(id)).join(", ");
|
const recoNames = recoIds.slice(0, 3).map(id => this.getProductName(id)).join(", ");
|
||||||
const recoMore = recoIds.length > 3 ? ` (+${recoIds.length - 3})` : "";
|
const recoMore = recoIds.length > 3 ? ` (+${recoIds.length - 3})` : "";
|
||||||
|
|
||||||
el.innerHTML = `
|
contentHtml = `
|
||||||
<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>
|
|
||||||
<div class="item-trigger">Cuando piden: ${triggerNames || "—"}${triggerMore}</div>
|
<div class="item-trigger">Cuando piden: ${triggerNames || "—"}${triggerMore}</div>
|
||||||
<div class="item-queries">→ Recomendar: ${recoNames || "—"}${recoMore}</div>
|
<div class="item-queries">→ Recomendar: ${recoNames || "—"}${recoMore}</div>
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
el.onclick = () => {
|
// Solo mostrar badge de tipo si no está filtrado
|
||||||
this.selected = item;
|
const typeBadge = !this.filterRuleType
|
||||||
this.editMode = "edit";
|
? `<span class="badge type">${ruleType === "qty_per_person" ? "Cantidades" : "Cross-sell"}</span>`
|
||||||
this.selectedTriggerProducts = [...(item.trigger_product_ids || [])];
|
: "";
|
||||||
this.selectedRecommendedProducts = [...(item.recommended_product_ids || [])];
|
|
||||||
this.renderList();
|
|
||||||
this.renderForm();
|
|
||||||
};
|
|
||||||
|
|
||||||
|
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);
|
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() {
|
showCreateForm() {
|
||||||
this.selected = null;
|
this.selected = null;
|
||||||
this.editMode = "create";
|
this.editMode = "create";
|
||||||
|
// Usar el tipo filtrado si está definido
|
||||||
|
this.currentRuleType = this.filterRuleType || "crosssell";
|
||||||
|
this.currentTriggerEvent = "";
|
||||||
this.selectedTriggerProducts = [];
|
this.selectedTriggerProducts = [];
|
||||||
this.selectedRecommendedProducts = [];
|
this.selectedRecommendedProducts = [];
|
||||||
|
this.ruleItems = [];
|
||||||
this.renderList();
|
this.renderList();
|
||||||
this.renderForm();
|
this.renderForm();
|
||||||
}
|
}
|
||||||
@@ -243,10 +352,29 @@ class RecommendationsCrud extends HTMLElement {
|
|||||||
const active = this.selected?.active !== false;
|
const active = this.selected?.active !== false;
|
||||||
const priority = this.selected?.priority || 100;
|
const priority = this.selected?.priority || 100;
|
||||||
|
|
||||||
|
// Solo mostrar selector de tipo si no está filtrado por atributo
|
||||||
|
const showTypeSelector = !this.filterRuleType;
|
||||||
|
|
||||||
form.innerHTML = `
|
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">
|
<div class="field">
|
||||||
<label>Nombre de la regla</label>
|
<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 class="field-hint">Identificador unico, sin espacios</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -264,6 +392,7 @@ class RecommendationsCrud extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="crosssellFields" style="display:${this.currentRuleType === "crosssell" ? "block" : "none"}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Cuando el cliente pide...</label>
|
<label>Cuando el cliente pide...</label>
|
||||||
<div class="product-selector" id="triggerSelector">
|
<div class="product-selector" id="triggerSelector">
|
||||||
@@ -283,6 +412,52 @@ class RecommendationsCrud extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
<div class="field-hint">Productos a sugerir al cliente</div>
|
<div class="field-hint">Productos a sugerir al cliente</div>
|
||||||
</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">
|
<div class="actions">
|
||||||
<button id="saveBtn">${isCreate ? "Crear" : "Guardar"}</button>
|
<button id="saveBtn">${isCreate ? "Crear" : "Guardar"}</button>
|
||||||
@@ -298,16 +473,186 @@ class RecommendationsCrud extends HTMLElement {
|
|||||||
this.shadowRoot.getElementById("deleteBtn").onclick = () => this.delete();
|
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("trigger", this.selectedTriggerProducts);
|
||||||
this.setupProductSelector("reco", this.selectedRecommendedProducts);
|
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) {
|
setupProductSelector(type, selectedIds) {
|
||||||
const searchInput = this.shadowRoot.getElementById(`${type}Search`);
|
const searchInput = this.shadowRoot.getElementById(`${type}Search`);
|
||||||
const dropdown = this.shadowRoot.getElementById(`${type}Dropdown`);
|
const dropdown = this.shadowRoot.getElementById(`${type}Dropdown`);
|
||||||
const selectedContainer = this.shadowRoot.getElementById(`${type}Selected`);
|
const selectedContainer = this.shadowRoot.getElementById(`${type}Selected`);
|
||||||
|
|
||||||
|
if (!searchInput || !dropdown || !selectedContainer) return;
|
||||||
|
|
||||||
const renderSelected = () => {
|
const renderSelected = () => {
|
||||||
const ids = type === "trigger" ? this.selectedTriggerProducts : this.selectedRecommendedProducts;
|
const ids = type === "trigger" ? this.selectedTriggerProducts : this.selectedRecommendedProducts;
|
||||||
if (!ids.length) {
|
if (!ids.length) {
|
||||||
@@ -342,7 +687,7 @@ class RecommendationsCrud extends HTMLElement {
|
|||||||
if (q) {
|
if (q) {
|
||||||
filtered = filtered.filter(p => p.name.toLowerCase().includes(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) {
|
if (!q && !filtered.length) {
|
||||||
dropdown.classList.remove("open");
|
dropdown.classList.remove("open");
|
||||||
@@ -391,7 +736,6 @@ class RecommendationsCrud extends HTMLElement {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Close dropdown on outside click
|
|
||||||
document.addEventListener("click", (e) => {
|
document.addEventListener("click", (e) => {
|
||||||
if (!this.shadowRoot.getElementById(`${type}Selector`)?.contains(e.target)) {
|
if (!this.shadowRoot.getElementById(`${type}Selector`)?.contains(e.target)) {
|
||||||
dropdown.classList.remove("open");
|
dropdown.classList.remove("open");
|
||||||
@@ -411,26 +755,50 @@ class RecommendationsCrud extends HTMLElement {
|
|||||||
return;
|
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) {
|
if (!this.selectedTriggerProducts.length) {
|
||||||
alert("Selecciona al menos un producto trigger");
|
alert("Selecciona al menos un producto trigger");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.selectedRecommendedProducts.length) {
|
if (!this.selectedRecommendedProducts.length) {
|
||||||
alert("Selecciona al menos un producto para recomendar");
|
alert("Selecciona al menos un producto para recomendar");
|
||||||
return;
|
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 = {
|
if (!this.ruleItems.length) {
|
||||||
rule_key: ruleKey,
|
alert("Agrega al menos un producto con cantidad");
|
||||||
trigger: {}, // Legacy field, keep empty
|
return;
|
||||||
queries: [], // Legacy field, keep empty
|
}
|
||||||
ask_slots: [],
|
|
||||||
active,
|
data.items = this.ruleItems.map((item, idx) => ({
|
||||||
priority,
|
woo_product_id: item.woo_product_id,
|
||||||
trigger_product_ids: this.selectedTriggerProducts,
|
audience_type: item.audience_type || "adult",
|
||||||
recommended_product_ids: this.selectedRecommendedProducts,
|
qty_per_person: item.qty_per_person,
|
||||||
};
|
unit: item.unit || "kg",
|
||||||
|
reason: item.reason || null,
|
||||||
|
display_order: idx,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.editMode === "create") {
|
if (this.editMode === "create") {
|
||||||
@@ -469,6 +837,7 @@ class RecommendationsCrud extends HTMLElement {
|
|||||||
this.selected = null;
|
this.selected = null;
|
||||||
this.selectedTriggerProducts = [];
|
this.selectedTriggerProducts = [];
|
||||||
this.selectedRecommendedProducts = [];
|
this.selectedRecommendedProducts = [];
|
||||||
|
this.ruleItems = [];
|
||||||
this.renderList();
|
this.renderList();
|
||||||
this.renderForm();
|
this.renderForm();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class UsersCrud extends HTMLElement {
|
|||||||
<style>
|
<style>
|
||||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
* { 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 { 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; }
|
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,11 @@ export const api = {
|
|||||||
return fetch(u).then(r => r.json());
|
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) {
|
async createRecommendation(data) {
|
||||||
return fetch("/recommendations", {
|
return fetch("/recommendations", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -156,4 +161,22 @@ export const api = {
|
|||||||
async deleteRecommendation(id) {
|
async deleteRecommendation(id) {
|
||||||
return fetch(`/recommendations/${encodeURIComponent(id)}`, { method: "DELETE" }).then(r => r.json());
|
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());
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ export const makeListAliases = (tenantIdOrFn) => async (req, res) => {
|
|||||||
export const makeCreateAlias = (tenantIdOrFn) => async (req, res) => {
|
export const makeCreateAlias = (tenantIdOrFn) => async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
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) {
|
if (!alias || !woo_product_id) {
|
||||||
return res.status(400).json({ ok: false, error: "alias_and_woo_product_id_required" });
|
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 });
|
res.json({ ok: true, item: result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -38,13 +38,13 @@ export const makeUpdateAlias = (tenantIdOrFn) => async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||||
const alias = req.params.alias;
|
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) {
|
if (!woo_product_id) {
|
||||||
return res.status(400).json({ ok: false, error: "woo_product_id_required" });
|
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) {
|
if (!result) {
|
||||||
return res.status(404).json({ ok: false, error: "alias_not_found" });
|
return res.status(404).json({ ok: false, error: "alias_not_found" });
|
||||||
}
|
}
|
||||||
|
|||||||
51
src/modules/0-ui/controllers/quantities.js
Normal file
51
src/modules/0-ui/controllers/quantities.js
Normal 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" });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -37,14 +37,18 @@ export const makeGetRecommendation = (tenantIdOrFn) => async (req, res) => {
|
|||||||
export const makeCreateRecommendation = (tenantIdOrFn) => async (req, res) => {
|
export const makeCreateRecommendation = (tenantIdOrFn) => async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
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) {
|
if (!rule_key) {
|
||||||
return res.status(400).json({ ok: false, error: "rule_key_required" });
|
return res.status(400).json({ ok: false, error: "rule_key_required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await handleCreateRecommendation({
|
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 });
|
res.json({ ok: true, item: result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -60,10 +64,14 @@ export const makeUpdateRecommendation = (tenantIdOrFn) => async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||||
const id = req.params.id;
|
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({
|
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) {
|
if (!result) {
|
||||||
return res.status(404).json({ ok: false, error: "recommendation_not_found" });
|
return res.status(404).json({ ok: false, error: "recommendation_not_found" });
|
||||||
|
|||||||
@@ -16,17 +16,19 @@ export async function listProducts({ tenantId, q = "", limit = 2000, offset = 0
|
|||||||
select
|
select
|
||||||
woo_id as woo_product_id,
|
woo_id as woo_product_id,
|
||||||
name,
|
name,
|
||||||
slug as sku,
|
coalesce(raw->>'SKU', raw->>'sku', slug) as sku,
|
||||||
|
slug,
|
||||||
price_current as price,
|
price_current as price,
|
||||||
stock_status,
|
stock_status,
|
||||||
|
stock_qty,
|
||||||
categories,
|
categories,
|
||||||
attributes_normalized,
|
attributes_normalized,
|
||||||
updated_at as refreshed_at,
|
updated_at as refreshed_at,
|
||||||
raw as payload,
|
raw as payload,
|
||||||
raw->>'_sell_unit_override' as sell_unit
|
coalesce(raw->>'_sell_unit_override', 'kg') as sell_unit
|
||||||
from woo_products_snapshot
|
from woo_products_snapshot
|
||||||
where tenant_id = $1
|
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
|
order by name asc
|
||||||
limit $3 offset $4
|
limit $3 offset $4
|
||||||
`;
|
`;
|
||||||
@@ -36,14 +38,16 @@ export async function listProducts({ tenantId, q = "", limit = 2000, offset = 0
|
|||||||
select
|
select
|
||||||
woo_id as woo_product_id,
|
woo_id as woo_product_id,
|
||||||
name,
|
name,
|
||||||
slug as sku,
|
coalesce(raw->>'SKU', raw->>'sku', slug) as sku,
|
||||||
|
slug,
|
||||||
price_current as price,
|
price_current as price,
|
||||||
stock_status,
|
stock_status,
|
||||||
|
stock_qty,
|
||||||
categories,
|
categories,
|
||||||
attributes_normalized,
|
attributes_normalized,
|
||||||
updated_at as refreshed_at,
|
updated_at as refreshed_at,
|
||||||
raw as payload,
|
raw as payload,
|
||||||
raw->>'_sell_unit_override' as sell_unit
|
coalesce(raw->>'_sell_unit_override', 'kg') as sell_unit
|
||||||
from woo_products_snapshot
|
from woo_products_snapshot
|
||||||
where tenant_id = $1
|
where tenant_id = $1
|
||||||
order by name asc
|
order by name asc
|
||||||
@@ -61,14 +65,16 @@ export async function getProductByWooId({ tenantId, wooProductId }) {
|
|||||||
select
|
select
|
||||||
woo_id as woo_product_id,
|
woo_id as woo_product_id,
|
||||||
name,
|
name,
|
||||||
slug as sku,
|
coalesce(raw->>'sku', slug) as sku,
|
||||||
|
slug,
|
||||||
price_current as price,
|
price_current as price,
|
||||||
stock_status,
|
stock_status,
|
||||||
|
stock_qty,
|
||||||
categories,
|
categories,
|
||||||
attributes_normalized,
|
attributes_normalized,
|
||||||
updated_at as refreshed_at,
|
updated_at as refreshed_at,
|
||||||
raw as payload,
|
raw as payload,
|
||||||
raw->>'_sell_unit_override' as sell_unit
|
coalesce(raw->>'_sell_unit_override', 'kg') as sell_unit
|
||||||
from woo_products_snapshot
|
from woo_products_snapshot
|
||||||
where tenant_id = $1 and woo_id = $2
|
where tenant_id = $1 and woo_id = $2
|
||||||
limit 1
|
limit 1
|
||||||
@@ -101,29 +107,37 @@ export async function bulkUpdateProductSellUnit({ tenantId, wooProductIds, sell_
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProduct({ tenantId, wooProductId, sell_unit, categories }) {
|
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 updates = [];
|
||||||
let params = [tenantId, wooProductId];
|
let params = [tenantId, wooProductId];
|
||||||
let paramIdx = 3;
|
let paramIdx = 3;
|
||||||
|
|
||||||
|
// Build the raw column update by chaining jsonb_set calls
|
||||||
|
let rawExpr = "coalesce(raw, '{}'::jsonb)";
|
||||||
|
|
||||||
if (sell_unit) {
|
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));
|
params.push(JSON.stringify(sell_unit));
|
||||||
paramIdx++;
|
paramIdx++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (categories) {
|
if (categories) {
|
||||||
// Also update the categories column if it exists
|
// Update categories column
|
||||||
updates.push(`categories = $${paramIdx}::jsonb`);
|
updates.push(`categories = $${paramIdx}::jsonb`);
|
||||||
params.push(JSON.stringify(categories.map(name => ({ name }))));
|
params.push(JSON.stringify(categories.map(name => ({ name }))));
|
||||||
paramIdx++;
|
paramIdx++;
|
||||||
|
|
||||||
// Also store in raw for persistence
|
// Chain the categories override into raw
|
||||||
updates.push(`raw = jsonb_set(coalesce(raw, '{}'::jsonb), '{_categories_override}', $${paramIdx}::jsonb)`);
|
rawExpr = `jsonb_set(${rawExpr}, '{_categories_override}', $${paramIdx}::jsonb)`;
|
||||||
params.push(JSON.stringify(categories));
|
params.push(JSON.stringify(categories));
|
||||||
paramIdx++;
|
paramIdx++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only add raw update if we modified it
|
||||||
|
if (sell_unit || categories) {
|
||||||
|
updates.push(`raw = ${rawExpr}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!updates.length) return null;
|
if (!updates.length) return null;
|
||||||
|
|
||||||
const sql = `
|
const sql = `
|
||||||
@@ -185,6 +199,31 @@ export async function listAliases({ tenantId, q = "", woo_product_id = null, lim
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { rows } = await pool.query(sql, params);
|
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;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,9 +275,62 @@ export async function updateAlias({ tenantId, alias, woo_product_id, boost = 0,
|
|||||||
export async function deleteAlias({ tenantId, alias }) {
|
export async function deleteAlias({ tenantId, alias }) {
|
||||||
const sql = `delete from product_aliases where tenant_id = $1 and alias = $2 returning 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()]);
|
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;
|
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
|
// Recommendations
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
@@ -252,7 +344,7 @@ export async function listRecommendations({ tenantId, q = "", limit = 200 }) {
|
|||||||
const like = `%${query}%`;
|
const like = `%${query}%`;
|
||||||
sql = `
|
sql = `
|
||||||
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority,
|
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
|
from product_reco_rules
|
||||||
where tenant_id = $1 and rule_key ilike $2
|
where tenant_id = $1 and rule_key ilike $2
|
||||||
order by priority desc, rule_key asc
|
order by priority desc, rule_key asc
|
||||||
@@ -262,7 +354,7 @@ export async function listRecommendations({ tenantId, q = "", limit = 200 }) {
|
|||||||
} else {
|
} else {
|
||||||
sql = `
|
sql = `
|
||||||
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority,
|
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
|
from product_reco_rules
|
||||||
where tenant_id = $1
|
where tenant_id = $1
|
||||||
order by priority desc, rule_key asc
|
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 }) {
|
export async function getRecommendationById({ tenantId, id }) {
|
||||||
const sql = `
|
const sql = `
|
||||||
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority,
|
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
|
from product_reco_rules
|
||||||
where tenant_id = $1 and id = $2
|
where tenant_id = $1 and id = $2
|
||||||
limit 1
|
limit 1
|
||||||
`;
|
`;
|
||||||
const { rows } = await pool.query(sql, [tenantId, id]);
|
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({
|
export async function insertRecommendation({
|
||||||
@@ -298,11 +401,14 @@ export async function insertRecommendation({
|
|||||||
priority = 100,
|
priority = 100,
|
||||||
trigger_product_ids = [],
|
trigger_product_ids = [],
|
||||||
recommended_product_ids = [],
|
recommended_product_ids = [],
|
||||||
|
rule_type = "crosssell",
|
||||||
|
trigger_event = null,
|
||||||
|
items = [],
|
||||||
}) {
|
}) {
|
||||||
const sql = `
|
const sql = `
|
||||||
insert into product_reco_rules (tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority, trigger_product_ids, recommended_product_ids)
|
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)
|
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, 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, [
|
const { rows } = await pool.query(sql, [
|
||||||
@@ -316,9 +422,18 @@ export async function insertRecommendation({
|
|||||||
priority || 100,
|
priority || 100,
|
||||||
trigger_product_ids || [],
|
trigger_product_ids || [],
|
||||||
recommended_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({
|
export async function updateRecommendation({
|
||||||
@@ -332,6 +447,9 @@ export async function updateRecommendation({
|
|||||||
priority,
|
priority,
|
||||||
trigger_product_ids,
|
trigger_product_ids,
|
||||||
recommended_product_ids,
|
recommended_product_ids,
|
||||||
|
rule_type,
|
||||||
|
trigger_event,
|
||||||
|
items,
|
||||||
}) {
|
}) {
|
||||||
const sql = `
|
const sql = `
|
||||||
update product_reco_rules
|
update product_reco_rules
|
||||||
@@ -344,9 +462,11 @@ export async function updateRecommendation({
|
|||||||
priority = $8,
|
priority = $8,
|
||||||
trigger_product_ids = $9,
|
trigger_product_ids = $9,
|
||||||
recommended_product_ids = $10,
|
recommended_product_ids = $10,
|
||||||
|
rule_type = $11,
|
||||||
|
trigger_event = $12,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
where tenant_id = $1 and id = $2
|
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, [
|
const { rows } = await pool.query(sql, [
|
||||||
@@ -360,13 +480,147 @@ export async function updateRecommendation({
|
|||||||
priority || 100,
|
priority || 100,
|
||||||
trigger_product_ids || [],
|
trigger_product_ids || [],
|
||||||
recommended_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;
|
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 }) {
|
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 sql = `delete from product_reco_rules where tenant_id = $1 and id = $2 returning id`;
|
||||||
const { rows } = await pool.query(sql, [tenantId, id]);
|
const { rows } = await pool.query(sql, [tenantId, id]);
|
||||||
return rows.length > 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }) {
|
export async function handleListAliases({ tenantId, q = "", woo_product_id = null, limit = 200 }) {
|
||||||
const items = await listAliases({ tenantId, q, woo_product_id, limit });
|
const items = await listAliases({ tenantId, q, woo_product_id, limit });
|
||||||
return { items };
|
return { items };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleCreateAlias({ tenantId, alias, woo_product_id, boost = 0, category_hint = null, metadata = {} }) {
|
export async function handleCreateAlias({
|
||||||
return insertAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
|
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 result;
|
||||||
return updateAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
|
}
|
||||||
|
|
||||||
|
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 }) {
|
export async function handleDeleteAlias({ tenantId, alias }) {
|
||||||
const deleted = await deleteAlias({ tenantId, alias });
|
const deleted = await deleteAlias({ tenantId, alias });
|
||||||
return { deleted };
|
return { deleted };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function handleGetAliasMappings({ tenantId, alias }) {
|
||||||
|
const mappings = await listAliasMappings({ tenantId, alias });
|
||||||
|
return { mappings };
|
||||||
|
}
|
||||||
|
|||||||
23
src/modules/0-ui/handlers/quantities.js
Normal file
23
src/modules/0-ui/handlers/quantities.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -26,8 +26,14 @@ export async function handleCreateRecommendation({
|
|||||||
priority = 100,
|
priority = 100,
|
||||||
trigger_product_ids = [],
|
trigger_product_ids = [],
|
||||||
recommended_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({
|
export async function handleUpdateRecommendation({
|
||||||
@@ -41,8 +47,14 @@ export async function handleUpdateRecommendation({
|
|||||||
priority,
|
priority,
|
||||||
trigger_product_ids,
|
trigger_product_ids,
|
||||||
recommended_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 }) {
|
export async function handleDeleteRecommendation({ tenantId, id }) {
|
||||||
|
|||||||
@@ -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 { 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 { makeListAliases, makeCreateAlias, makeUpdateAlias, makeDeleteAlias } from "../../0-ui/controllers/aliases.js";
|
||||||
import { makeListRecommendations, makeGetRecommendation, makeCreateRecommendation, makeUpdateRecommendation, makeDeleteRecommendation } from "../../0-ui/controllers/recommendations.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";
|
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js";
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
@@ -70,6 +71,10 @@ export function createSimulatorRouter({ tenantId }) {
|
|||||||
router.put("/recommendations/:id", makeUpdateRecommendation(getTenantId));
|
router.put("/recommendations/:id", makeUpdateRecommendation(getTenantId));
|
||||||
router.delete("/recommendations/:id", makeDeleteRecommendation(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.get("/users", makeListUsers(getTenantId));
|
||||||
router.delete("/users/:chat_id", makeDeleteUser(getTenantId));
|
router.delete("/users/:chat_id", makeDeleteUser(getTenantId));
|
||||||
|
|
||||||
|
|||||||
@@ -594,7 +594,7 @@ export async function getRecoRulesByProductIds({ tenant_id, product_ids = [] })
|
|||||||
export async function getRecoRuleByKey({ tenant_id, rule_key }) {
|
export async function getRecoRuleByKey({ tenant_id, rule_key }) {
|
||||||
const sql = `
|
const sql = `
|
||||||
select id, tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority,
|
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
|
from product_reco_rules
|
||||||
where tenant_id=$1 and rule_key=$2
|
where tenant_id=$1 and rule_key=$2
|
||||||
limit 1
|
limit 1
|
||||||
@@ -603,6 +603,95 @@ export async function getRecoRuleByKey({ tenant_id, rule_key }) {
|
|||||||
return rows[0] || null;
|
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 }) {
|
export async function getProductEmbedding({ tenant_id, content_hash }) {
|
||||||
const sql = `
|
const sql = `
|
||||||
select tenant_id, content_hash, content_text, embedding, model, updated_at
|
select tenant_id, content_hash, content_text, embedding, model, updated_at
|
||||||
|
|||||||
@@ -338,7 +338,13 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
|||||||
railguard: { simulated: isSimulated, source: meta?.source || null },
|
railguard: { simulated: isSimulated, source: meta?.source || null },
|
||||||
woo_customer_error: wooCustomerError,
|
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;
|
plan.next_state = nextState;
|
||||||
|
|
||||||
const stateRow = await upsertConversationState({
|
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 });
|
await updateRunLatency({ tenant_id: tenantId, run_id, latency_ms: end_to_end_ms });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Incluir carrito completo para la UI
|
// Incluir carrito completo para la UI (nuevo formato order.cart o legacy order_basket)
|
||||||
const fullBasket = context?.order_basket?.items || [];
|
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", {
|
sseSend("run.created", {
|
||||||
run_id,
|
run_id,
|
||||||
@@ -377,6 +392,8 @@ const nextState = safeNextState(prev_state, context, { requested_checkout: plan.
|
|||||||
status: runStatus,
|
status: runStatus,
|
||||||
prev_state,
|
prev_state,
|
||||||
input: { text },
|
input: { text },
|
||||||
|
// Incluir order completo para la UI
|
||||||
|
order: orderData,
|
||||||
llm_output: { ...plan, _llm: llmMeta, full_basket: { items: fullBasket } },
|
llm_output: { ...plan, _llm: llmMeta, full_basket: { items: fullBasket } },
|
||||||
tools,
|
tools,
|
||||||
invariants,
|
invariants,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
searchProductAliases,
|
searchProductAliases,
|
||||||
getProductEmbedding,
|
getProductEmbedding,
|
||||||
upsertProductEmbedding,
|
upsertProductEmbedding,
|
||||||
|
getAllAliasProductMappings,
|
||||||
} from "../2-identity/db/repo.js";
|
} from "../2-identity/db/repo.js";
|
||||||
|
|
||||||
function getOpenAiKey() {
|
function getOpenAiKey() {
|
||||||
@@ -141,6 +142,37 @@ export async function retrieveCandidates({
|
|||||||
const aliases = await searchProductAliases({ tenant_id: tenantId, q, limit: 20 });
|
const aliases = await searchProductAliases({ tenant_id: tenantId, q, limit: 20 });
|
||||||
const aliasBoostByProduct = new Map();
|
const aliasBoostByProduct = new Map();
|
||||||
const aliasProductIds = new Set();
|
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) {
|
for (const a of aliases) {
|
||||||
if (a?.woo_product_id) {
|
if (a?.woo_product_id) {
|
||||||
const id = Number(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.aliases = aliases.length;
|
||||||
|
audit.sources.alias_mappings = aliasProductIds.size;
|
||||||
|
|
||||||
// 2) Buscar productos por nombre/slug (búsqueda literal)
|
// 2) Buscar productos por nombre/slug (búsqueda literal)
|
||||||
const { items: wooItems, source: wooSource } = await searchSnapshotItems({
|
const { items: wooItems, source: wooSource } = await searchSnapshotItems({
|
||||||
|
|||||||
@@ -1,219 +1,193 @@
|
|||||||
/**
|
/**
|
||||||
* FSM autoritativa (server-side) para el flujo conversacional.
|
* FSM simplificada para el flujo conversacional.
|
||||||
*
|
*
|
||||||
* Principios:
|
* Estados lineales: IDLE → CART → SHIPPING → PAYMENT → WAITING_WEBHOOKS
|
||||||
* - El LLM NO decide estados. Solo NLU.
|
* Regla universal: add_to_cart SIEMPRE vuelve a CART desde cualquier estado.
|
||||||
* - El backend deriva el estado objetivo a partir del contexto + acciones.
|
|
||||||
* - Validamos transiciones y, si algo queda inconsistente, caemos a ERROR_RECOVERY.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const ConversationState = Object.freeze({
|
export const ConversationState = Object.freeze({
|
||||||
IDLE: "IDLE",
|
IDLE: "IDLE",
|
||||||
BROWSING: "BROWSING",
|
CART: "CART",
|
||||||
CLARIFYING_ITEMS: "CLARIFYING_ITEMS", // Clarificando items pendientes uno por uno
|
SHIPPING: "SHIPPING",
|
||||||
AWAITING_QUANTITY: "AWAITING_QUANTITY",
|
PAYMENT: "PAYMENT",
|
||||||
CART_ACTIVE: "CART_ACTIVE",
|
WAITING_WEBHOOKS: "WAITING_WEBHOOKS",
|
||||||
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",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ALL_STATES = Object.freeze(Object.values(ConversationState));
|
export const ALL_STATES = Object.freeze(Object.values(ConversationState));
|
||||||
|
|
||||||
function hasBasketItems(ctx) {
|
// Intents válidos por estado
|
||||||
const items = ctx?.basket?.items || ctx?.order_basket?.items;
|
export const INTENTS_BY_STATE = Object.freeze({
|
||||||
return Array.isArray(items) && items.length > 0;
|
[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;
|
* Verifica si el usuario quiere agregar productos (debe volver a CART).
|
||||||
return Boolean(pc?.candidates?.length) || Boolean(pc?.options?.length);
|
*/
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
const intent = nlu?.intent;
|
||||||
function hasPendingItem(ctx) {
|
// Si el intent es add_to_cart, browse, price_query, o recommend → volver a CART
|
||||||
return Boolean(ctx?.pending_item?.product_id || ctx?.pending_item?.sku);
|
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).
|
* Helpers para verificar estado de la orden
|
||||||
* Un item pendiente tiene status "needs_type" o "needs_quantity".
|
|
||||||
*/
|
*/
|
||||||
function hasPendingItems(ctx) {
|
export function hasCartItems(order) {
|
||||||
const items = ctx?.pending_items;
|
return Array.isArray(order?.cart) && order.cart.length > 0;
|
||||||
if (!Array.isArray(items) || items.length === 0) return false;
|
|
||||||
return items.some(i => i.status === "needs_type" || i.status === "needs_quantity");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasAddress(ctx) {
|
export function hasPendingItems(order) {
|
||||||
return Boolean(ctx?.delivery_address?.text || ctx?.address?.text || ctx?.address_text);
|
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) {
|
export function hasReadyPendingItems(order) {
|
||||||
return Boolean(ctx?.woo_order_id || ctx?.last_order_id);
|
if (!Array.isArray(order?.pending)) return false;
|
||||||
|
return order.pending.some(i => i.status === "READY");
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasPaymentLink(ctx) {
|
export function hasShippingInfo(order) {
|
||||||
return Boolean(ctx?.mp?.init_point || ctx?.payment?.init_point || ctx?.payment_link);
|
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) {
|
export function hasPaymentInfo(order) {
|
||||||
const st =
|
return order?.payment_type === "cash" || order?.payment_type === "link";
|
||||||
ctx?.mp?.payment_status ||
|
}
|
||||||
ctx?.payment?.status ||
|
|
||||||
ctx?.payment_status ||
|
export function isPaid(order) {
|
||||||
null;
|
return order?.is_paid === true;
|
||||||
return st === "approved" || st === "paid";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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) {
|
export function deriveNextState(prevState, order = {}, signals = {}) {
|
||||||
return ctx?.checkout_step === "payment_method";
|
// Regla 0: Si se fuerza volver a CART
|
||||||
}
|
if (signals.return_to_cart) {
|
||||||
|
return ConversationState.CART;
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regla 1: Si está pagado, completado (volver a IDLE para nueva conversación)
|
||||||
|
if (isPaid(order)) {
|
||||||
return ConversationState.IDLE;
|
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({
|
const ALLOWED = Object.freeze({
|
||||||
[ConversationState.IDLE]: [
|
[ConversationState.IDLE]: [
|
||||||
ConversationState.IDLE,
|
ConversationState.IDLE,
|
||||||
ConversationState.BROWSING,
|
ConversationState.CART,
|
||||||
ConversationState.CLARIFYING_ITEMS,
|
|
||||||
ConversationState.AWAITING_QUANTITY,
|
|
||||||
ConversationState.CART_ACTIVE,
|
|
||||||
ConversationState.ERROR_RECOVERY,
|
|
||||||
],
|
],
|
||||||
[ConversationState.BROWSING]: [
|
[ConversationState.CART]: [
|
||||||
ConversationState.BROWSING,
|
ConversationState.CART,
|
||||||
ConversationState.CLARIFYING_ITEMS,
|
ConversationState.SHIPPING,
|
||||||
ConversationState.AWAITING_QUANTITY,
|
ConversationState.IDLE, // Si vacía el carrito
|
||||||
ConversationState.CART_ACTIVE,
|
|
||||||
ConversationState.IDLE,
|
|
||||||
ConversationState.ERROR_RECOVERY,
|
|
||||||
],
|
],
|
||||||
[ConversationState.CLARIFYING_ITEMS]: [
|
[ConversationState.SHIPPING]: [
|
||||||
ConversationState.CLARIFYING_ITEMS,
|
ConversationState.SHIPPING,
|
||||||
ConversationState.CART_ACTIVE,
|
ConversationState.PAYMENT,
|
||||||
ConversationState.BROWSING,
|
ConversationState.CART, // Volver a agregar productos
|
||||||
ConversationState.IDLE,
|
|
||||||
ConversationState.ERROR_RECOVERY,
|
|
||||||
],
|
],
|
||||||
[ConversationState.AWAITING_QUANTITY]: [
|
[ConversationState.PAYMENT]: [
|
||||||
ConversationState.AWAITING_QUANTITY,
|
ConversationState.PAYMENT,
|
||||||
ConversationState.CLARIFYING_ITEMS,
|
ConversationState.WAITING_WEBHOOKS,
|
||||||
ConversationState.CART_ACTIVE,
|
ConversationState.CART, // Volver a agregar productos
|
||||||
ConversationState.BROWSING,
|
|
||||||
ConversationState.ERROR_RECOVERY,
|
|
||||||
],
|
],
|
||||||
[ConversationState.CART_ACTIVE]: [
|
[ConversationState.WAITING_WEBHOOKS]: [
|
||||||
ConversationState.CART_ACTIVE,
|
ConversationState.WAITING_WEBHOOKS,
|
||||||
ConversationState.CLARIFYING_ITEMS,
|
ConversationState.IDLE, // Pago completado
|
||||||
ConversationState.CLARIFYING_PAYMENT,
|
ConversationState.CART, // Agregar más productos
|
||||||
ConversationState.AWAITING_ADDRESS,
|
|
||||||
ConversationState.AWAITING_PAYMENT,
|
|
||||||
ConversationState.ERROR_RECOVERY,
|
|
||||||
ConversationState.BROWSING,
|
|
||||||
],
|
],
|
||||||
[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) {
|
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 };
|
return ok ? { ok: true } : { ok: false, reason: "invalid_transition", prev: p, next: n };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function safeNextState(prevState, ctx, signals) {
|
export function safeNextState(prevState, order, signals) {
|
||||||
const desired = deriveNextState(prevState, ctx, signals);
|
const desired = deriveNextState(prevState, order, signals);
|
||||||
const v = validateTransition(prevState, desired);
|
const v = validateTransition(prevState, desired);
|
||||||
if (v.ok) return { next_state: desired, validation: v };
|
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 } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" +
|
"- 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" +
|
"- 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" +
|
"- 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" +
|
"- needs.catalog_lookup debe ser true para intents price_query|browse|add_to_cart|recommend.\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" +
|
"\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" +
|
"JERARQUÍA DE DECISIÓN (en orden de prioridad):\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" +
|
"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" +
|
"- 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" +
|
"- 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" +
|
"- 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 || [] },
|
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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
251
src/modules/3-turn-engine/orderModel.js
Normal file
251
src/modules/3-turn-engine/orderModel.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { getRecoRules, getRecoRulesByProductIds } from "../2-identity/db/repo.js";
|
import { getRecoRules, getRecoRulesByProductIds, getProductQtyRulesByEvent } from "../2-identity/db/repo.js";
|
||||||
import { getSnapshotItemsByIds } from "../shared/wooSnapshot.js";
|
import { getSnapshotItemsByIds, searchSnapshotItems } from "../shared/wooSnapshot.js";
|
||||||
import { buildPagedOptions } from "./turnEngineV3.pendingSelection.js";
|
import { buildPagedOptions } from "./turnEngineV3.pendingSelection.js";
|
||||||
|
import { llmPlanningRecommend } from "./openai.js";
|
||||||
|
import { retrieveCandidates } from "./catalogRetrieval.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extrae los IDs de productos del carrito.
|
* Extrae los IDs de productos del carrito.
|
||||||
@@ -8,7 +10,7 @@ import { buildPagedOptions } from "./turnEngineV3.pendingSelection.js";
|
|||||||
function getBasketProductIds(basket_items) {
|
function getBasketProductIds(basket_items) {
|
||||||
const items = Array.isArray(basket_items) ? basket_items : [];
|
const items = Array.isArray(basket_items) ? basket_items : [];
|
||||||
return 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)
|
.filter(id => id != null)
|
||||||
.map(Number);
|
.map(Number);
|
||||||
}
|
}
|
||||||
@@ -30,29 +32,356 @@ function collectRecommendedIds(rules, excludeIds = []) {
|
|||||||
return [...ids];
|
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,
|
tenantId,
|
||||||
text,
|
wooProductIds: productIds,
|
||||||
prev_context = {},
|
});
|
||||||
basket_items = [],
|
|
||||||
limit = 9,
|
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 context_patch = {};
|
||||||
const audit = { basket_product_ids: [], rules_used: [], recommended_ids: [] };
|
|
||||||
|
|
||||||
// 1. Obtener IDs de productos en el carrito
|
// 1. Obtener IDs de productos en el carrito
|
||||||
const basketProductIds = getBasketProductIds(basket_items);
|
const basketProductIds = getBasketProductIds(basket_items);
|
||||||
audit.basket_product_ids = basketProductIds;
|
audit.basket_product_ids = basketProductIds;
|
||||||
|
|
||||||
if (!basketProductIds.length) {
|
if (!basketProductIds.length) {
|
||||||
return {
|
// No hay items, delegar a planificación
|
||||||
reply: "Primero agregá algo al carrito y después te sugiero complementos perfectos.",
|
return null;
|
||||||
actions: [],
|
|
||||||
context_patch,
|
|
||||||
audit,
|
|
||||||
asked_slot: null,
|
|
||||||
candidates: [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Buscar reglas que matcheen con los productos del carrito
|
// 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
|
// Fallback: no hay reglas configuradas para estos productos
|
||||||
const basketNames = basket_items.map(i => i.label || i.name).filter(Boolean).slice(0, 3).join(", ");
|
const basketNames = basket_items.map(i => i.label || i.name).filter(Boolean).slice(0, 3).join(", ");
|
||||||
return {
|
return {
|
||||||
|
plan: {
|
||||||
reply: `Por ahora no tengo recomendaciones especiales para ${basketNames}. ¿Te interesa algo más?`,
|
reply: `Por ahora no tengo recomendaciones especiales para ${basketNames}. ¿Te interesa algo más?`,
|
||||||
actions: [],
|
next_state: null,
|
||||||
context_patch,
|
intent: "recommend",
|
||||||
audit,
|
missing_fields: [],
|
||||||
asked_slot: null,
|
order_action: "none",
|
||||||
candidates: [],
|
},
|
||||||
|
decision: { actions: [], order, audit, context_patch },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,26 +409,31 @@ export async function handleRecommend({
|
|||||||
|
|
||||||
if (!recommendedIds.length) {
|
if (!recommendedIds.length) {
|
||||||
return {
|
return {
|
||||||
|
plan: {
|
||||||
reply: "No encontré complementos adicionales para tu pedido. ¿Necesitás algo más?",
|
reply: "No encontré complementos adicionales para tu pedido. ¿Necesitás algo más?",
|
||||||
actions: [],
|
next_state: null,
|
||||||
context_patch,
|
intent: "recommend",
|
||||||
audit,
|
missing_fields: [],
|
||||||
asked_slot: null,
|
order_action: "none",
|
||||||
candidates: [],
|
},
|
||||||
|
decision: { actions: [], order, audit, context_patch },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Obtener detalles de los productos recomendados
|
// 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) {
|
if (!recommendedProducts.length) {
|
||||||
return {
|
return {
|
||||||
|
plan: {
|
||||||
reply: "No encontré los productos recomendados disponibles. ¿Querés ver algo más?",
|
reply: "No encontré los productos recomendados disponibles. ¿Querés ver algo más?",
|
||||||
actions: [],
|
next_state: null,
|
||||||
context_patch,
|
intent: "recommend",
|
||||||
audit,
|
missing_fields: [],
|
||||||
asked_slot: null,
|
order_action: "none",
|
||||||
candidates: [],
|
},
|
||||||
|
decision: { actions: [], order, audit, context_patch },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,11 +452,60 @@ export async function handleRecommend({
|
|||||||
context_patch.pending_item = null;
|
context_patch.pending_item = null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
plan: {
|
||||||
reply,
|
reply,
|
||||||
|
next_state: null,
|
||||||
|
intent: "recommend",
|
||||||
|
missing_fields: [],
|
||||||
|
order_action: "none",
|
||||||
|
},
|
||||||
|
decision: {
|
||||||
actions: [{ type: "show_options", payload: { count: pending.options?.length || 0 } }],
|
actions: [{ type: "show_options", payload: { count: pending.options?.length || 0 } }],
|
||||||
context_patch,
|
order,
|
||||||
audit,
|
audit,
|
||||||
asked_slot: null,
|
context_patch,
|
||||||
candidates: recommendedProducts.slice(0, limit),
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 });
|
||||||
|
}
|
||||||
|
|||||||
858
src/modules/3-turn-engine/stateHandlers.js
Normal file
858
src/modules/3-turn-engine/stateHandlers.js
Normal 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
@@ -111,13 +111,17 @@ function normalizeWooProduct(p) {
|
|||||||
function snapshotRowToItem(row) {
|
function snapshotRowToItem(row) {
|
||||||
const categories = Array.isArray(row?.categories) ? row.categories : [];
|
const categories = Array.isArray(row?.categories) ? row.categories : [];
|
||||||
const attributes = row?.attributes_normalized && typeof row.attributes_normalized === "object" ? row.attributes_normalized : {};
|
const attributes = row?.attributes_normalized && typeof row.attributes_normalized === "object" ? row.attributes_normalized : {};
|
||||||
|
const raw = row?.raw || {};
|
||||||
return {
|
return {
|
||||||
woo_product_id: row?.woo_id,
|
woo_product_id: row?.woo_id,
|
||||||
name: row?.name || "",
|
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,
|
price: row?.price_current != null ? Number(row.price_current) : null,
|
||||||
currency: null,
|
currency: null,
|
||||||
type: row?.type || 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) })),
|
categories: categories.map((c) => ({ id: null, name: String(c), slug: String(c) })),
|
||||||
attributes: Object.entries(attributes).map(([name, options]) => ({
|
attributes: Object.entries(attributes).map(([name, options]) => ({
|
||||||
name,
|
name,
|
||||||
|
|||||||
Reference in New Issue
Block a user