productos, equivalencias, cross-sell y cantidades

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,251 @@
/**
* Modelo unificado de orden para el contexto de conversación.
*
* Reemplaza: order_basket, pending_items, pending_clarification, pending_item,
* checkout_step, payment_method, shipping_method, delivery_address
*/
// Status de items pendientes
export const PendingStatus = Object.freeze({
NEEDS_TYPE: "NEEDS_TYPE", // Necesita seleccionar producto de opciones
NEEDS_QUANTITY: "NEEDS_QUANTITY", // Necesita especificar cantidad
READY: "READY", // Listo para mover a cart
});
/**
* Crea una orden vacía
*/
export function createEmptyOrder() {
return {
cart: [], // Items confirmados: [{ woo_id, qty, unit, name, price }]
pending: [], // Items por clarificar
payment_type: null, // "link" | "cash" | null
is_delivery: null, // true | false | null
shipping_address: null,
woo_order_id: null,
is_paid: false,
};
}
/**
* Crea un item de carrito confirmado
*/
export function createCartItem({ woo_id, qty, unit, name = null, price = null }) {
return {
woo_id,
qty: Number(qty) || 1,
unit: unit || "unit", // "kg" | "g" | "unit"
name,
price,
};
}
/**
* Crea un item pendiente de clarificación
*/
export function createPendingItem({
id,
query,
candidates = [],
selected_woo_id = null,
selected_name = null,
selected_price = null,
selected_unit = null,
qty = null,
unit = null,
status = PendingStatus.NEEDS_TYPE
}) {
return {
id: id || `pending_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
query,
candidates, // [{ woo_id, name, price, display_unit }]
selected_woo_id, // Producto elegido (null si NEEDS_TYPE)
selected_name,
selected_price,
selected_unit, // Unidad del producto seleccionado
qty, // Cantidad (null si NEEDS_QUANTITY)
unit, // Unidad elegida por usuario
status,
};
}
/**
* Mueve items READY de pending a cart
*/
export function moveReadyToCart(order) {
if (!order) return createEmptyOrder();
const newCart = [...(order.cart || [])];
const newPending = [];
for (const item of (order.pending || [])) {
if (item.status === PendingStatus.READY && item.selected_woo_id && item.qty) {
// Buscar si ya existe en cart
const existingIdx = newCart.findIndex(c => c.woo_id === item.selected_woo_id);
const cartItem = createCartItem({
woo_id: item.selected_woo_id,
qty: item.qty,
unit: item.unit || item.selected_unit || "unit",
name: item.selected_name,
price: item.selected_price,
});
if (existingIdx >= 0) {
// Actualizar cantidad existente
newCart[existingIdx] = {
...newCart[existingIdx],
qty: cartItem.qty,
unit: cartItem.unit,
};
} else {
newCart.push(cartItem);
}
} else {
newPending.push(item);
}
}
return {
...order,
cart: newCart,
pending: newPending,
};
}
/**
* Obtiene el primer item pendiente que necesita clarificación
*/
export function getNextPendingItem(order) {
if (!Array.isArray(order?.pending)) return null;
return order.pending.find(i =>
i.status === PendingStatus.NEEDS_TYPE ||
i.status === PendingStatus.NEEDS_QUANTITY
) || null;
}
/**
* Actualiza un item pendiente por ID
*/
export function updatePendingItem(order, itemId, updates) {
if (!order?.pending) return order;
const newPending = order.pending.map(item => {
if (item.id === itemId) {
return { ...item, ...updates };
}
return item;
});
return { ...order, pending: newPending };
}
/**
* Agrega un nuevo item pendiente
*/
export function addPendingItem(order, pendingItem) {
const current = order || createEmptyOrder();
return {
...current,
pending: [...(current.pending || []), pendingItem],
};
}
/**
* Convierte orden vieja (order_basket, pending_items, etc.) a nuevo formato
*/
export function migrateOldContext(ctx) {
if (!ctx) return createEmptyOrder();
// Si ya tiene el nuevo formato
if (ctx.order && (Array.isArray(ctx.order.cart) || Array.isArray(ctx.order.pending))) {
return ctx.order;
}
const order = createEmptyOrder();
// Migrar order_basket
if (ctx.order_basket?.items) {
order.cart = ctx.order_basket.items.map(item => createCartItem({
woo_id: item.product_id || item.woo_id || item.woo_product_id,
qty: item.quantity || item.qty || 1,
unit: item.unit || "unit",
name: item.label || item.name,
price: item.price,
}));
}
// Migrar pending_items
if (Array.isArray(ctx.pending_items)) {
order.pending = ctx.pending_items.map(item => createPendingItem({
id: item.id,
query: item.query,
candidates: (item.candidates || []).map(c => ({
woo_id: c.woo_product_id || c.woo_id,
name: c.name,
price: c.price,
display_unit: c.display_unit,
})),
selected_woo_id: item.resolved_product?.woo_product_id || item.resolved_product?.woo_id,
selected_name: item.resolved_product?.name,
selected_price: item.resolved_product?.price,
selected_unit: item.resolved_product?.display_unit,
qty: item.quantity,
unit: item.unit,
status: item.status === "needs_type" ? PendingStatus.NEEDS_TYPE :
item.status === "needs_quantity" ? PendingStatus.NEEDS_QUANTITY :
item.status === "ready" ? PendingStatus.READY :
item.status?.toUpperCase() || PendingStatus.NEEDS_TYPE,
}));
}
// Migrar checkout info
order.payment_type = ctx.payment_method || null;
order.is_delivery = ctx.shipping_method === "delivery" ? true :
ctx.shipping_method === "pickup" ? false : null;
order.shipping_address = ctx.delivery_address?.text || ctx.address?.text || ctx.address_text || null;
order.woo_order_id = ctx.woo_order_id || ctx.last_order_id || null;
order.is_paid = ctx.mp?.payment_status === "approved" ||
ctx.payment?.status === "paid" ||
ctx.payment_status === "approved" || false;
return order;
}
/**
* Formatea el carrito para mostrar al usuario
*/
export function formatCartForDisplay(order, locale = "es-AR") {
if (!order?.cart?.length) {
return "Tu carrito está vacío.";
}
const lines = order.cart.map(item => {
const qtyStr = item.unit === "kg" ? `${item.qty}kg` :
item.unit === "g" ? `${item.qty}g` :
`${item.qty}`;
return `- ${qtyStr} de ${item.name || `Producto #${item.woo_id}`}`;
});
return "Tenés anotado:\n" + lines.join("\n");
}
/**
* Formatea opciones de un pending item para mostrar al usuario
*/
export function formatOptionsForDisplay(pendingItem, pageSize = 9) {
if (!pendingItem?.candidates?.length) {
return { question: `No encontré "${pendingItem?.query}". ¿Podrías ser más específico?`, options: [] };
}
const options = pendingItem.candidates.slice(0, pageSize);
const hasMore = pendingItem.candidates.length > pageSize;
const lines = options.map((c, i) => `${i + 1}) ${c.name}`);
if (hasMore) {
lines.push(`${pageSize + 1}) Mostrame más...`);
}
const question = `Para "${pendingItem.query}", ¿cuál de estos querés?\n` + lines.join("\n") + "\n\nRespondé con el número.";
return { question, options };
}

View File

@@ -1,6 +1,8 @@
import { getRecoRules, getRecoRulesByProductIds } from "../2-identity/db/repo.js";
import { getSnapshotItemsByIds } from "../shared/wooSnapshot.js";
import { getRecoRules, getRecoRulesByProductIds, getProductQtyRulesByEvent } from "../2-identity/db/repo.js";
import { getSnapshotItemsByIds, searchSnapshotItems } from "../shared/wooSnapshot.js";
import { buildPagedOptions } from "./turnEngineV3.pendingSelection.js";
import { llmPlanningRecommend } from "./openai.js";
import { retrieveCandidates } from "./catalogRetrieval.js";
/**
* Extrae los IDs de productos del carrito.
@@ -8,7 +10,7 @@ import { buildPagedOptions } from "./turnEngineV3.pendingSelection.js";
function getBasketProductIds(basket_items) {
const items = Array.isArray(basket_items) ? basket_items : [];
return items
.map(item => item.product_id || item.woo_product_id)
.map(item => item.product_id || item.woo_product_id || item.woo_id)
.filter(id => id != null)
.map(Number);
}
@@ -30,29 +32,356 @@ function collectRecommendedIds(rules, excludeIds = []) {
return [...ids];
}
export async function handleRecommend({
tenantId,
text,
prev_context = {},
basket_items = [],
limit = 9,
} = {}) {
/**
* 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,
wooProductIds: productIds,
});
const productMap = new Map();
for (const p of products) {
productMap.set(p.woo_product_id, p);
}
// Convertir a pendingItems
const pendingItems = [];
for (const [wooId, data] of productQtyMap) {
const product = productMap.get(wooId);
if (!product) continue;
const roundedQty = Math.round(data.qty * 100) / 100; // Redondear a 2 decimales
pendingItems.push({
query: product.name,
suggested_qty: roundedQty,
suggested_unit: data.unit,
reason: "",
candidates: [{
woo_id: product.woo_product_id,
name: product.name,
price: product.price,
}],
});
}
audit.pending_items_created = pendingItems.length;
// Construir respuesta con detalle de adultos/niños si aplica
let headerLine = "";
if (childrenCount > 0) {
headerLine = `Para ${adultsCount} adulto${adultsCount > 1 ? "s" : ""} y ${childrenCount} niño${childrenCount > 1 ? "s" : ""}, te recomiendo:`;
} else {
headerLine = `Para ${totalPeople} persona${totalPeople > 1 ? "s" : ""}, te recomiendo:`;
}
let reply = headerLine + "\n\n";
const lines = pendingItems.map(item => {
const qtyStr = item.suggested_unit === "unidad"
? `${item.suggested_qty} unidad${item.suggested_qty > 1 ? "es" : ""}`
: `${item.suggested_qty}${item.suggested_unit}`;
return `${item.candidates[0]?.name}: ${qtyStr}`;
});
reply += lines.join("\n");
// Agregar precios si están disponibles
const itemsWithPrices = pendingItems.filter(item => item.candidates[0]?.price != null);
if (itemsWithPrices.length > 0) {
reply += "\n\n¿Querés que te arme el pedido?";
const priceLines = itemsWithPrices.map(item => {
const unitLabel = item.suggested_unit === "unidad" ? "/u" : `/${item.suggested_unit}`;
return `${item.candidates[0].name}: $${item.candidates[0].price}${unitLabel}`;
}).join("\n");
reply += "\n\n📋 *Precios actuales:*\n" + priceLines;
}
return {
plan: {
reply,
next_state: null,
intent: "recommend",
missing_fields: [],
order_action: "none",
},
decision: {
actions: [],
order,
audit,
context_patch: {
planning_suggestions: pendingItems,
},
},
};
}
// 2) Fallback: usar LLM si no hay reglas configuradas
audit.fallback_to_llm = true;
// Obtener categorías disponibles para contexto
const categoryResult = await searchSnapshotItems({ tenantId, q: "", limit: 50 });
const categories = new Set();
for (const item of (categoryResult?.items || [])) {
for (const cat of (item.categories || [])) {
if (cat?.name) categories.add(cat.name);
}
}
// Llamar al LLM de planificación
const llmResult = await llmPlanningRecommend({
user_message: text,
event_type: planningInfo.event_type,
people_count: planningInfo.people_count,
cooking_method: planningInfo.cooking_method,
mentioned_products: planningInfo.mentioned_products,
available_categories: [...categories],
});
audit.planning_llm = {
model: llmResult.model,
usage: llmResult.usage,
suggested_count: llmResult.suggested_items?.length || 0,
validation: llmResult.validation,
};
// Si hay items sugeridos, buscar en el catálogo y crear pending items
const suggestedItems = llmResult.suggested_items || [];
const pendingItems = [];
for (const suggestion of suggestedItems.slice(0, 8)) {
const searchResult = await retrieveCandidates({
tenantId,
query: suggestion.product_query,
limit: 5
});
const candidates = searchResult?.candidates || [];
if (candidates.length > 0) {
pendingItems.push({
query: suggestion.product_query,
suggested_qty: suggestion.suggested_qty,
suggested_unit: suggestion.unit,
reason: suggestion.reason,
candidates: candidates.slice(0, 5).map(c => ({
woo_id: c.woo_product_id,
name: c.name,
price: c.price,
})),
});
}
}
audit.pending_items_created = pendingItems.length;
// Usar el reply del LLM directamente (ya incluye la lista de productos)
let reply = llmResult.reply || "Te ayudo con eso.";
// Si encontramos items en el catálogo, agregar precios reales
if (pendingItems.length > 0) {
// Solo agregar info de precios si el catálogo tiene datos
const itemsWithPrices = pendingItems.filter(item => item.candidates[0]?.price != null);
if (itemsWithPrices.length > 0) {
// Si el reply NO termina con pregunta de si quiere agregar, añadirla
if (!/\?[\s]*$/.test(reply)) {
reply += "\n\n¿Querés que te arme el pedido?";
}
// Agregar precios reales del catálogo
const priceLines = itemsWithPrices.map(item => {
const unitLabel = item.suggested_unit === "unidad" ? "/u" : `/${item.suggested_unit || "kg"}`;
return `${item.candidates[0].name}: $${item.candidates[0].price}${unitLabel}`;
}).join("\n");
reply += "\n\n📋 *Precios actuales:*\n" + priceLines;
}
}
return {
plan: {
reply,
next_state: null, // Se determinará por el caller
intent: "recommend",
missing_fields: [],
order_action: "none",
},
decision: {
actions: [],
order,
audit,
context_patch: {
planning_suggestions: pendingItems,
},
},
};
}
/**
* Maneja recomendaciones de cross-sell basadas en el carrito.
*/
async function handleCrossSellRecommend({ tenantId, text, order, basket_items, limit, audit }) {
const context_patch = {};
const audit = { basket_product_ids: [], rules_used: [], recommended_ids: [] };
// 1. Obtener IDs de productos en el carrito
const basketProductIds = getBasketProductIds(basket_items);
audit.basket_product_ids = basketProductIds;
if (!basketProductIds.length) {
return {
reply: "Primero agregá algo al carrito y después te sugiero complementos perfectos.",
actions: [],
context_patch,
audit,
asked_slot: null,
candidates: [],
};
// No hay items, delegar a planificación
return null;
}
// 2. Buscar reglas que matcheen con los productos del carrito
@@ -63,12 +392,14 @@ export async function handleRecommend({
// Fallback: no hay reglas configuradas para estos productos
const basketNames = basket_items.map(i => i.label || i.name).filter(Boolean).slice(0, 3).join(", ");
return {
reply: `Por ahora no tengo recomendaciones especiales para ${basketNames}. ¿Te interesa algo más?`,
actions: [],
context_patch,
audit,
asked_slot: null,
candidates: [],
plan: {
reply: `Por ahora no tengo recomendaciones especiales para ${basketNames}. ¿Te interesa algo más?`,
next_state: null,
intent: "recommend",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order, audit, context_patch },
};
}
@@ -78,26 +409,31 @@ export async function handleRecommend({
if (!recommendedIds.length) {
return {
reply: "No encontré complementos adicionales para tu pedido. ¿Necesitás algo más?",
actions: [],
context_patch,
audit,
asked_slot: null,
candidates: [],
plan: {
reply: "No encontré complementos adicionales para tu pedido. ¿Necesitás algo más?",
next_state: null,
intent: "recommend",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order, audit, context_patch },
};
}
// 4. Obtener detalles de los productos recomendados
const recommendedProducts = await getSnapshotItemsByIds({ tenantId, wooProductIds: recommendedIds.slice(0, limit) });
const recommendedResult = await getSnapshotItemsByIds({ tenantId, wooProductIds: recommendedIds.slice(0, limit) });
const recommendedProducts = recommendedResult?.items || [];
if (!recommendedProducts.length) {
return {
reply: "No encontré los productos recomendados disponibles. ¿Querés ver algo más?",
actions: [],
context_patch,
audit,
asked_slot: null,
candidates: [],
plan: {
reply: "No encontré los productos recomendados disponibles. ¿Querés ver algo más?",
next_state: null,
intent: "recommend",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order, audit, context_patch },
};
}
@@ -116,11 +452,60 @@ export async function handleRecommend({
context_patch.pending_item = null;
return {
reply,
actions: [{ type: "show_options", payload: { count: pending.options?.length || 0 } }],
context_patch,
audit,
asked_slot: null,
candidates: recommendedProducts.slice(0, limit),
plan: {
reply,
next_state: null,
intent: "recommend",
missing_fields: [],
order_action: "none",
},
decision: {
actions: [{ type: "show_options", payload: { count: pending.options?.length || 0 } }],
order,
audit,
context_patch,
},
};
}
/**
* Handler principal de recomendaciones.
* Detecta si es planificación o cross-sell y delega al handler apropiado.
*/
export async function handleRecommend({
tenantId,
text,
nlu,
order,
prevContext = {},
basket_items = [],
limit = 9,
audit = {},
} = {}) {
audit.recommendation_type = null;
// Extraer basket_items del order si no se pasan explícitamente
const cartItems = basket_items.length > 0
? basket_items
: (order?.cart || []).map(item => ({
woo_product_id: item.woo_id,
name: item.name,
label: item.name,
}));
// Detectar si es planificación
const isPlanningRequest = detectPlanningRequest(text, nlu);
// Si hay items en el carrito y no es claramente planificación, intentar cross-sell primero
if (cartItems.length > 0 && !isPlanningRequest) {
audit.recommendation_type = "cross_sell";
const crossSellResult = await handleCrossSellRecommend({
tenantId, text, order, basket_items: cartItems, limit, audit
});
if (crossSellResult) return crossSellResult;
}
// Planificación (carrito vacío o solicitud explícita de consejo)
audit.recommendation_type = "planning";
return handlePlanningRecommend({ tenantId, text, nlu, order, audit });
}

View File

@@ -0,0 +1,858 @@
/**
* Handlers por estado para el flujo conversacional simplificado.
* Cada handler recibe params y retorna { plan, decision }
*/
import { retrieveCandidates } from "./catalogRetrieval.js";
import { ConversationState, safeNextState, hasCartItems, hasPendingItems } from "./fsm.js";
import {
createEmptyOrder,
createPendingItem,
createCartItem,
PendingStatus,
moveReadyToCart,
getNextPendingItem,
updatePendingItem,
addPendingItem,
migrateOldContext,
formatCartForDisplay,
formatOptionsForDisplay,
} from "./orderModel.js";
import { handleRecommend } from "./recommendations.js";
// ─────────────────────────────────────────────────────────────
// Utilidades
// ─────────────────────────────────────────────────────────────
function inferDefaultUnit({ name, categories }) {
const n = String(name || "").toLowerCase();
const cats = Array.isArray(categories) ? categories : [];
const hay = (re) =>
cats.some((c) => re.test(String(c?.name || "")) || re.test(String(c?.slug || ""))) || re.test(n);
if (hay(/\b(chimichurri|provoleta|queso|pan|salsa|aderezo|condimento|especia|especias)\b/i)) {
return "unit";
}
if (hay(/\b(proveedur[ií]a|almac[eé]n|almacen|sal\s+pimienta|aderezos)\b/i)) {
return "unit";
}
if (hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i)) {
return "unit";
}
return "kg";
}
function parseIndexSelection(text) {
const t = String(text || "").toLowerCase();
const m = /\b(\d{1,2})\b/.exec(t);
if (m) return parseInt(m[1], 10);
if (/\bprimera\b|\bprimero\b/.test(t)) return 1;
if (/\bsegunda\b|\bsegundo\b/.test(t)) return 2;
if (/\btercera\b|\btercero\b/.test(t)) return 3;
if (/\bcuarta\b|\bcuarto\b/.test(t)) return 4;
if (/\bquinta\b|\bquinto\b/.test(t)) return 5;
if (/\bsexta\b|\bsexto\b/.test(t)) return 6;
if (/\bs[eé]ptima\b|\bs[eé]ptimo\b/.test(t)) return 7;
if (/\boctava\b|\boctavo\b/.test(t)) return 8;
if (/\bnovena\b|\bnoveno\b/.test(t)) return 9;
if (/\bd[eé]cima\b|\bd[eé]cimo\b/.test(t)) return 10;
return null;
}
function isShowMoreRequest(text) {
const t = String(text || "").toLowerCase();
return (
/\bmostr(a|ame)\s+m[aá]s\b/.test(t) ||
/\bmas\s+opciones\b/.test(t) ||
(/\bm[aá]s\b/.test(t) && /\b(opciones|productos|variedades|tipos)\b/.test(t)) ||
/\bsiguiente(s)?\b/.test(t)
);
}
function normalizeUnit(unit) {
if (!unit) return null;
const u = String(unit).toLowerCase();
if (u === "kg" || u === "kilo" || u === "kilos") return "kg";
if (u === "g" || u === "gramo" || u === "gramos") return "g";
if (u === "unidad" || u === "unidades" || u === "unit") return "unit";
return null;
}
function unitAskFor(displayUnit) {
if (displayUnit === "unit") return "¿Cuántas unidades querés?";
if (displayUnit === "g") return "¿Cuántos gramos querés?";
return "¿Cuántos kilos querés?";
}
// ─────────────────────────────────────────────────────────────
// Handler: IDLE
// ─────────────────────────────────────────────────────────────
export async function handleIdleState({ tenantId, text, nlu, order, audit }) {
const intent = nlu?.intent || "other";
const actions = [];
// Greeting
if (intent === "greeting") {
return {
plan: {
reply: "¡Hola! ¿En qué te puedo ayudar hoy?",
next_state: ConversationState.IDLE,
intent: "greeting",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order, audit },
};
}
// Cualquier intent relacionado con productos → ir a CART
if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) {
// Delegar a handleCartState
return handleCartState({ tenantId, text, nlu, order, audit, fromIdle: true });
}
// Other
return {
plan: {
reply: "¿En qué te puedo ayudar? Podés preguntarme por productos o agregar cosas al carrito.",
next_state: ConversationState.IDLE,
intent: "other",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order, audit },
};
}
// ─────────────────────────────────────────────────────────────
// Handler: CART
// ─────────────────────────────────────────────────────────────
export async function handleCartState({ tenantId, text, nlu, order, audit, fromIdle = false }) {
const intent = nlu?.intent || "other";
let currentOrder = order || createEmptyOrder();
const actions = [];
// 1) Si hay pending items sin resolver, procesar clarificación
const pendingItem = getNextPendingItem(currentOrder);
if (pendingItem) {
const result = await processPendingClarification({ tenantId, text, nlu, order: currentOrder, pendingItem, audit });
if (result) return result;
}
// 2) view_cart: mostrar carrito actual
if (intent === "view_cart") {
const cartDisplay = formatCartForDisplay(currentOrder);
const pendingCount = currentOrder.pending?.filter(p => p.status !== PendingStatus.READY).length || 0;
let reply = cartDisplay;
if (pendingCount > 0) {
reply += `\n\n(Tenés ${pendingCount} producto(s) pendiente(s) de confirmar)`;
}
reply += "\n\n¿Algo más?";
return {
plan: {
reply,
next_state: ConversationState.CART,
intent: "view_cart",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// 3) confirm_order: ir a SHIPPING si hay items
if (intent === "confirm_order") {
// Primero mover pending READY a cart
currentOrder = moveReadyToCart(currentOrder);
if (!hasCartItems(currentOrder)) {
return {
plan: {
reply: "Tu carrito está vacío. ¿Qué querés agregar?",
next_state: ConversationState.CART,
intent: "confirm_order",
missing_fields: ["cart_items"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// Si hay pending items sin resolver, clarificarlos primero
if (hasPendingItems(currentOrder)) {
const nextPending = getNextPendingItem(currentOrder);
const { question } = formatOptionsForDisplay(nextPending);
return {
plan: {
reply: `Antes de cerrar, tenemos que confirmar algunos productos.\n\n${question}`,
next_state: ConversationState.CART,
intent: "confirm_order",
missing_fields: ["pending_items"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// Todo listo, ir a SHIPPING
const { next_state } = safeNextState(ConversationState.CART, currentOrder, { confirm_order: true });
return {
plan: {
reply: "Perfecto. ¿Es para delivery o pasás a retirar?\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal",
next_state,
intent: "confirm_order",
missing_fields: ["shipping_method"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// 4) recommend
if (intent === "recommend") {
try {
const recoResult = await handleRecommend({
tenantId,
text,
nlu,
order: currentOrder,
prevContext: { order: currentOrder },
audit
});
if (recoResult?.plan?.reply) {
// Merge context_patch si existe
const newOrder = recoResult.decision?.order || currentOrder;
const contextPatch = recoResult.decision?.context_patch || {};
return {
plan: {
...recoResult.plan,
next_state: ConversationState.CART,
},
decision: {
actions: recoResult.decision?.actions || [],
order: newOrder,
audit,
context_patch: contextPatch,
},
};
}
} catch (e) {
audit.recommend_error = String(e?.message || e);
}
}
// 4.5) price_query - consulta de precios
if (intent === "price_query") {
const productQueries = extractProductQueries(nlu);
if (productQueries.length === 0) {
return {
plan: {
reply: "¿De qué producto querés saber el precio?",
next_state: ConversationState.CART,
intent: "price_query",
missing_fields: ["product_query"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// Buscar productos y mostrar precios
const priceResults = [];
for (const pq of productQueries.slice(0, 5)) {
const searchResult = await retrieveCandidates({ tenantId, query: pq.query, limit: 3 });
const candidates = searchResult?.candidates || [];
audit.price_search = audit.price_search || [];
audit.price_search.push({ query: pq.query, count: candidates.length });
for (const c of candidates.slice(0, 2)) {
const unit = inferDefaultUnit({ name: c.name, categories: c.categories });
const priceStr = c.price != null ? `$${c.price}` : "consultar";
const unitStr = unit === "unit" ? "/unidad" : "/kg";
priceResults.push(`${c.name}: ${priceStr}${unitStr}`);
}
}
if (priceResults.length === 0) {
return {
plan: {
reply: "No encontré ese producto. ¿Podés ser más específico?",
next_state: ConversationState.CART,
intent: "price_query",
missing_fields: ["product_query"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
const reply = "Estos son los precios:\n\n" + priceResults.join("\n") + "\n\n¿Querés agregar alguno al carrito?";
return {
plan: {
reply,
next_state: ConversationState.CART,
intent: "price_query",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// 5) add_to_cart / browse / price_query: buscar productos
if (["add_to_cart", "browse", "price_query"].includes(intent) || fromIdle) {
const productQueries = extractProductQueries(nlu);
if (productQueries.length === 0) {
return {
plan: {
reply: "¿Qué producto querés agregar?",
next_state: ConversationState.CART,
intent,
missing_fields: ["product_query"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// Buscar candidatos para cada query
for (const pq of productQueries) {
const searchResult = await retrieveCandidates({ tenantId, query: pq.query, limit: 20 });
const candidates = searchResult?.candidates || [];
audit.catalog_search = audit.catalog_search || [];
audit.catalog_search.push({ query: pq.query, count: candidates.length });
const pendingItem = createPendingItemFromSearch({
query: pq.query,
quantity: pq.quantity,
unit: pq.unit,
candidates,
});
currentOrder = addPendingItem(currentOrder, pendingItem);
}
// Mover items READY directamente al cart
currentOrder = moveReadyToCart(currentOrder);
// Si hay pending items, pedir clarificación del primero
const nextPending = getNextPendingItem(currentOrder);
if (nextPending) {
if (nextPending.status === PendingStatus.NEEDS_TYPE) {
const { question } = formatOptionsForDisplay(nextPending);
return {
plan: {
reply: question,
next_state: ConversationState.CART,
intent,
missing_fields: ["product_selection"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
if (nextPending.status === PendingStatus.NEEDS_QUANTITY) {
const unitQuestion = unitAskFor(nextPending.selected_unit || "kg");
return {
plan: {
reply: `Para ${nextPending.selected_name || nextPending.query}, ${unitQuestion}`,
next_state: ConversationState.CART,
intent,
missing_fields: ["quantity"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
}
// Todo resuelto, confirmar agregado
const lastAdded = currentOrder.cart[currentOrder.cart.length - 1];
if (lastAdded) {
const qtyStr = lastAdded.unit === "unit" ? lastAdded.qty : `${lastAdded.qty}${lastAdded.unit}`;
return {
plan: {
reply: `Perfecto, anoto ${qtyStr} de ${lastAdded.name}. ¿Algo más?`,
next_state: ConversationState.CART,
intent: "add_to_cart",
missing_fields: [],
order_action: "add_to_cart",
},
decision: { actions: [{ type: "add_to_cart", payload: lastAdded }], order: currentOrder, audit },
};
}
}
// Default
return {
plan: {
reply: "¿Qué más querés agregar?",
next_state: ConversationState.CART,
intent: "other",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// ─────────────────────────────────────────────────────────────
// Handler: SHIPPING
// ─────────────────────────────────────────────────────────────
export async function handleShippingState({ tenantId, text, nlu, order, audit }) {
const intent = nlu?.intent || "other";
let currentOrder = order || createEmptyOrder();
// Detectar selección de shipping (delivery/pickup)
let shippingMethod = nlu?.entities?.shipping_method;
// Detectar por número o texto
if (!shippingMethod) {
const t = String(text || "").toLowerCase();
const idx = parseIndexSelection(text);
if (idx === 1 || /delivery|envío|envio|traigan|llev/i.test(t)) {
shippingMethod = "delivery";
} else if (idx === 2 || /retiro|retira|buscar|sucursal|paso/i.test(t)) {
shippingMethod = "pickup";
}
}
if (shippingMethod) {
currentOrder = { ...currentOrder, is_delivery: shippingMethod === "delivery" };
if (shippingMethod === "pickup") {
// Pickup: ir directo a PAYMENT
const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {});
return {
plan: {
reply: "Perfecto, retiro en sucursal. ¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.",
next_state,
intent: "select_shipping",
missing_fields: ["payment_method"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// Delivery: pedir dirección si no la tiene
if (!currentOrder.shipping_address) {
return {
plan: {
reply: "Perfecto, delivery. ¿Me pasás la dirección de entrega?",
next_state: ConversationState.SHIPPING,
intent: "select_shipping",
missing_fields: ["address"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
}
// Si ya eligió delivery y ahora da dirección
if (currentOrder.is_delivery === true && !currentOrder.shipping_address) {
// Extraer dirección del texto (el usuario probablemente escribió la dirección)
const address = nlu?.entities?.address || (text?.length > 5 ? text.trim() : null);
if (address) {
currentOrder = { ...currentOrder, shipping_address: address };
const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {});
return {
plan: {
reply: `Anotado: ${address}.\n\n¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.`,
next_state,
intent: "provide_address",
missing_fields: ["payment_method"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
return {
plan: {
reply: "Necesito la dirección de entrega. ¿Me la pasás?",
next_state: ConversationState.SHIPPING,
intent: "other",
missing_fields: ["address"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// view_cart
if (intent === "view_cart") {
const cartDisplay = formatCartForDisplay(currentOrder);
return {
plan: {
reply: cartDisplay + "\n\n¿Es para delivery o retiro?",
next_state: ConversationState.SHIPPING,
intent: "view_cart",
missing_fields: ["shipping_method"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// Default: preguntar de nuevo
return {
plan: {
reply: "¿Es para delivery o pasás a retirar?\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal",
next_state: ConversationState.SHIPPING,
intent: "other",
missing_fields: ["shipping_method"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// ─────────────────────────────────────────────────────────────
// Handler: PAYMENT
// ─────────────────────────────────────────────────────────────
export async function handlePaymentState({ tenantId, text, nlu, order, audit }) {
const intent = nlu?.intent || "other";
let currentOrder = order || createEmptyOrder();
const actions = [];
// Detectar selección de pago
let paymentMethod = nlu?.entities?.payment_method;
if (!paymentMethod) {
const t = String(text || "").toLowerCase();
const idx = parseIndexSelection(text);
if (idx === 1 || /efectivo|cash|plata/i.test(t)) {
paymentMethod = "cash";
} else if (idx === 2 || /link|tarjeta|transfer|qr|mercado\s*pago/i.test(t)) {
paymentMethod = "link";
}
}
if (paymentMethod) {
currentOrder = { ...currentOrder, payment_type: paymentMethod };
actions.push({ type: "create_order", payload: { payment: paymentMethod } });
if (paymentMethod === "link") {
actions.push({ type: "send_payment_link", payload: {} });
}
const { next_state } = safeNextState(ConversationState.PAYMENT, currentOrder, { payment_selected: true });
const paymentLabel = paymentMethod === "cash" ? "efectivo" : "link de pago";
const deliveryInfo = currentOrder.is_delivery
? `Te lo llevamos a ${currentOrder.shipping_address || "la dirección indicada"}.`
: "Retiro en sucursal.";
const paymentInfo = paymentMethod === "link"
? "Te paso el link de pago en un momento."
: "Pagás en " + (currentOrder.is_delivery ? "la entrega" : "sucursal") + ".";
return {
plan: {
reply: `Listo, pagás en ${paymentLabel}. ${deliveryInfo}\n\n${paymentInfo}\n\n¡Gracias por tu pedido!`,
next_state,
intent: "select_payment",
missing_fields: [],
order_action: "create_order",
},
decision: { actions, order: currentOrder, audit },
};
}
// view_cart
if (intent === "view_cart") {
const cartDisplay = formatCartForDisplay(currentOrder);
return {
plan: {
reply: cartDisplay + "\n\n¿Cómo preferís pagar?",
next_state: ConversationState.PAYMENT,
intent: "view_cart",
missing_fields: ["payment_method"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// Default
return {
plan: {
reply: "¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.",
next_state: ConversationState.PAYMENT,
intent: "other",
missing_fields: ["payment_method"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// ─────────────────────────────────────────────────────────────
// Handler: WAITING_WEBHOOKS
// ─────────────────────────────────────────────────────────────
export async function handleWaitingState({ tenantId, text, nlu, order, audit }) {
const intent = nlu?.intent || "other";
const currentOrder = order || createEmptyOrder();
// view_cart
if (intent === "view_cart") {
const cartDisplay = formatCartForDisplay(currentOrder);
const status = currentOrder.is_paid ? "✓ Pagado" : "Esperando pago...";
return {
plan: {
reply: `${cartDisplay}\n\nEstado: ${status}`,
next_state: ConversationState.WAITING_WEBHOOKS,
intent: "view_cart",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// Default
const reply = currentOrder.payment_type === "link"
? "Tu pedido está en proceso. Avisame si necesitás algo más o esperá la confirmación de pago."
: "Tu pedido está listo. Avisame si necesitás algo más.";
return {
plan: {
reply,
next_state: ConversationState.WAITING_WEBHOOKS,
intent: "other",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// ─────────────────────────────────────────────────────────────
// Helpers internos
// ─────────────────────────────────────────────────────────────
function extractProductQueries(nlu) {
const queries = [];
// Multi-items
if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.length > 0) {
for (const item of nlu.entities.items) {
if (item.product_query) {
queries.push({
query: item.product_query,
quantity: item.quantity,
unit: item.unit,
});
}
}
return queries;
}
// Single item
if (nlu?.entities?.product_query) {
queries.push({
query: nlu.entities.product_query,
quantity: nlu.entities.quantity,
unit: nlu.entities.unit,
});
}
return queries;
}
function createPendingItemFromSearch({ query, quantity, unit, candidates }) {
const cands = (candidates || []).filter(c => c && c.woo_product_id);
if (cands.length === 0) {
return createPendingItem({
query,
candidates: [],
status: PendingStatus.NEEDS_TYPE, // Will show "not found" message
});
}
// Check for strong match
const best = cands[0];
const second = cands[1];
const isStrong = cands.length === 1 ||
(best?._score >= 0.9 && (!second || best._score - (second?._score || 0) >= 0.2));
if (isStrong) {
const displayUnit = inferDefaultUnit({ name: best.name, categories: best.categories });
const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0;
const sellsByWeight = displayUnit !== "unit";
const hasExplicitUnit = unit != null && unit !== "";
const quantityIsGeneric = hasQty && Number(quantity) <= 2 && !hasExplicitUnit;
const needsQuantity = sellsByWeight && (!hasQty || quantityIsGeneric);
return createPendingItem({
query,
candidates: [],
selected_woo_id: best.woo_product_id,
selected_name: best.name,
selected_price: best.price,
selected_unit: displayUnit,
qty: needsQuantity ? null : (hasQty ? Number(quantity) : 1),
unit: normalizeUnit(unit) || displayUnit,
status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY,
});
}
// Multiple candidates, needs selection
return createPendingItem({
query,
candidates: cands.slice(0, 20).map(c => ({
woo_id: c.woo_product_id,
name: c.name,
price: c.price,
display_unit: inferDefaultUnit({ name: c.name, categories: c.categories }),
})),
status: PendingStatus.NEEDS_TYPE,
});
}
async function processPendingClarification({ tenantId, text, nlu, order, pendingItem, audit }) {
// Si necesita seleccionar tipo
if (pendingItem.status === PendingStatus.NEEDS_TYPE) {
const idx = parseIndexSelection(text);
// Show more
if (isShowMoreRequest(text)) {
// TODO: implement pagination
const { question } = formatOptionsForDisplay(pendingItem);
return {
plan: {
reply: question,
next_state: ConversationState.CART,
intent: "other",
missing_fields: ["product_selection"],
order_action: "none",
},
decision: { actions: [], order, audit },
};
}
// Selection by index
if (idx && pendingItem.candidates && idx <= pendingItem.candidates.length) {
const selected = pendingItem.candidates[idx - 1];
const displayUnit = selected.display_unit || inferDefaultUnit({ name: selected.name, categories: [] });
const needsQuantity = displayUnit !== "unit";
const updatedOrder = updatePendingItem(order, pendingItem.id, {
selected_woo_id: selected.woo_id,
selected_name: selected.name,
selected_price: selected.price,
selected_unit: displayUnit,
candidates: [],
status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY,
qty: needsQuantity ? null : 1,
unit: displayUnit,
});
// Si necesita cantidad, preguntar
if (needsQuantity) {
const unitQuestion = unitAskFor(displayUnit);
return {
plan: {
reply: `Para ${selected.name}, ${unitQuestion}`,
next_state: ConversationState.CART,
intent: "add_to_cart",
missing_fields: ["quantity"],
order_action: "none",
},
decision: { actions: [], order: updatedOrder, audit },
};
}
// Listo, mover al cart
const finalOrder = moveReadyToCart(updatedOrder);
return {
plan: {
reply: `Perfecto, anoto 1 ${selected.name}. ¿Algo más?`,
next_state: ConversationState.CART,
intent: "add_to_cart",
missing_fields: [],
order_action: "add_to_cart",
},
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
};
}
// No entendió, volver a preguntar
const { question } = formatOptionsForDisplay(pendingItem);
return {
plan: {
reply: "No entendí. " + question,
next_state: ConversationState.CART,
intent: "other",
missing_fields: ["product_selection"],
order_action: "none",
},
decision: { actions: [], order, audit },
};
}
// Si necesita cantidad
if (pendingItem.status === PendingStatus.NEEDS_QUANTITY) {
const qty = nlu?.entities?.quantity;
const unit = nlu?.entities?.unit;
// Try to parse quantity from text
let parsedQty = qty;
if (parsedQty == null) {
const m = /(\d+(?:[.,]\d+)?)\s*(kg|kilo|kilos|g|gramo|gramos|unidad|unidades)?/i.exec(text || "");
if (m) {
parsedQty = parseFloat(m[1].replace(",", "."));
}
}
if (parsedQty != null && Number.isFinite(parsedQty) && parsedQty > 0) {
const finalUnit = normalizeUnit(unit) || pendingItem.selected_unit || "kg";
const updatedOrder = updatePendingItem(order, pendingItem.id, {
qty: parsedQty,
unit: finalUnit,
status: PendingStatus.READY,
});
const finalOrder = moveReadyToCart(updatedOrder);
const qtyStr = finalUnit === "unit" ? parsedQty : `${parsedQty}${finalUnit}`;
return {
plan: {
reply: `Perfecto, anoto ${qtyStr} de ${pendingItem.selected_name}. ¿Algo más?`,
next_state: ConversationState.CART,
intent: "add_to_cart",
missing_fields: [],
order_action: "add_to_cart",
},
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
};
}
// No entendió cantidad
const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg");
return {
plan: {
reply: `No entendí la cantidad. ${unitQuestion}`,
next_state: ConversationState.CART,
intent: "other",
missing_fields: ["quantity"],
order_action: "none",
},
decision: { actions: [], order, audit },
};
}
return null;
}

File diff suppressed because it is too large Load Diff

View File

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