productos, equivalencias, cross-sell y cantidades
This commit is contained in:
@@ -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" });
|
||||
}
|
||||
|
||||
51
src/modules/0-ui/controllers/quantities.js
Normal file
51
src/modules/0-ui/controllers/quantities.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
handleListProductQtyRules,
|
||||
handleGetProductQtyRules,
|
||||
handleSaveProductQtyRules
|
||||
} from "../handlers/quantities.js";
|
||||
|
||||
export const makeListProductQtyRules = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const result = await handleListProductQtyRules({ tenantId });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ ok: false, error: "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
export const makeGetProductQtyRules = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const wooProductId = parseInt(req.params.wooProductId, 10);
|
||||
|
||||
if (!wooProductId) {
|
||||
return res.status(400).json({ ok: false, error: "woo_product_id_required" });
|
||||
}
|
||||
|
||||
const result = await handleGetProductQtyRules({ tenantId, wooProductId });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ ok: false, error: "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
export const makeSaveProductQtyRules = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const wooProductId = parseInt(req.params.wooProductId, 10);
|
||||
const { rules } = req.body || {};
|
||||
|
||||
if (!wooProductId) {
|
||||
return res.status(400).json({ ok: false, error: "woo_product_id_required" });
|
||||
}
|
||||
|
||||
const result = await handleSaveProductQtyRules({ tenantId, wooProductId, rules: rules || [] });
|
||||
res.json({ ok: true, ...result });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ ok: false, error: "internal_error" });
|
||||
}
|
||||
};
|
||||
@@ -37,14 +37,18 @@ export const makeGetRecommendation = (tenantIdOrFn) => async (req, res) => {
|
||||
export const makeCreateRecommendation = (tenantIdOrFn) => async (req, res) => {
|
||||
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" });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
23
src/modules/0-ui/handlers/quantities.js
Normal file
23
src/modules/0-ui/handlers/quantities.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
listProductQtyRules,
|
||||
getProductQtyRules,
|
||||
saveProductQtyRules,
|
||||
countQtyRulesByProduct,
|
||||
} from "../db/repo.js";
|
||||
|
||||
export async function handleListProductQtyRules({ tenantId }) {
|
||||
const rules = await listProductQtyRules({ tenantId });
|
||||
const counts = await countQtyRulesByProduct({ tenantId });
|
||||
return { rules, counts };
|
||||
}
|
||||
|
||||
export async function handleGetProductQtyRules({ tenantId, wooProductId }) {
|
||||
const rules = await getProductQtyRules({ tenantId, wooProductId });
|
||||
return { rules };
|
||||
}
|
||||
|
||||
export async function handleSaveProductQtyRules({ tenantId, wooProductId, rules }) {
|
||||
await saveProductQtyRules({ tenantId, wooProductId, rules });
|
||||
const updated = await getProductQtyRules({ tenantId, wooProductId });
|
||||
return { rules: updated };
|
||||
}
|
||||
@@ -26,8 +26,14 @@ export async function handleCreateRecommendation({
|
||||
priority = 100,
|
||||
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 }) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
251
src/modules/3-turn-engine/orderModel.js
Normal file
251
src/modules/3-turn-engine/orderModel.js
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Modelo unificado de orden para el contexto de conversación.
|
||||
*
|
||||
* Reemplaza: order_basket, pending_items, pending_clarification, pending_item,
|
||||
* checkout_step, payment_method, shipping_method, delivery_address
|
||||
*/
|
||||
|
||||
// Status de items pendientes
|
||||
export const PendingStatus = Object.freeze({
|
||||
NEEDS_TYPE: "NEEDS_TYPE", // Necesita seleccionar producto de opciones
|
||||
NEEDS_QUANTITY: "NEEDS_QUANTITY", // Necesita especificar cantidad
|
||||
READY: "READY", // Listo para mover a cart
|
||||
});
|
||||
|
||||
/**
|
||||
* Crea una orden vacía
|
||||
*/
|
||||
export function createEmptyOrder() {
|
||||
return {
|
||||
cart: [], // Items confirmados: [{ woo_id, qty, unit, name, price }]
|
||||
pending: [], // Items por clarificar
|
||||
payment_type: null, // "link" | "cash" | null
|
||||
is_delivery: null, // true | false | null
|
||||
shipping_address: null,
|
||||
woo_order_id: null,
|
||||
is_paid: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un item de carrito confirmado
|
||||
*/
|
||||
export function createCartItem({ woo_id, qty, unit, name = null, price = null }) {
|
||||
return {
|
||||
woo_id,
|
||||
qty: Number(qty) || 1,
|
||||
unit: unit || "unit", // "kg" | "g" | "unit"
|
||||
name,
|
||||
price,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un item pendiente de clarificación
|
||||
*/
|
||||
export function createPendingItem({
|
||||
id,
|
||||
query,
|
||||
candidates = [],
|
||||
selected_woo_id = null,
|
||||
selected_name = null,
|
||||
selected_price = null,
|
||||
selected_unit = null,
|
||||
qty = null,
|
||||
unit = null,
|
||||
status = PendingStatus.NEEDS_TYPE
|
||||
}) {
|
||||
return {
|
||||
id: id || `pending_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
query,
|
||||
candidates, // [{ woo_id, name, price, display_unit }]
|
||||
selected_woo_id, // Producto elegido (null si NEEDS_TYPE)
|
||||
selected_name,
|
||||
selected_price,
|
||||
selected_unit, // Unidad del producto seleccionado
|
||||
qty, // Cantidad (null si NEEDS_QUANTITY)
|
||||
unit, // Unidad elegida por usuario
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mueve items READY de pending a cart
|
||||
*/
|
||||
export function moveReadyToCart(order) {
|
||||
if (!order) return createEmptyOrder();
|
||||
|
||||
const newCart = [...(order.cart || [])];
|
||||
const newPending = [];
|
||||
|
||||
for (const item of (order.pending || [])) {
|
||||
if (item.status === PendingStatus.READY && item.selected_woo_id && item.qty) {
|
||||
// Buscar si ya existe en cart
|
||||
const existingIdx = newCart.findIndex(c => c.woo_id === item.selected_woo_id);
|
||||
const cartItem = createCartItem({
|
||||
woo_id: item.selected_woo_id,
|
||||
qty: item.qty,
|
||||
unit: item.unit || item.selected_unit || "unit",
|
||||
name: item.selected_name,
|
||||
price: item.selected_price,
|
||||
});
|
||||
|
||||
if (existingIdx >= 0) {
|
||||
// Actualizar cantidad existente
|
||||
newCart[existingIdx] = {
|
||||
...newCart[existingIdx],
|
||||
qty: cartItem.qty,
|
||||
unit: cartItem.unit,
|
||||
};
|
||||
} else {
|
||||
newCart.push(cartItem);
|
||||
}
|
||||
} else {
|
||||
newPending.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...order,
|
||||
cart: newCart,
|
||||
pending: newPending,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el primer item pendiente que necesita clarificación
|
||||
*/
|
||||
export function getNextPendingItem(order) {
|
||||
if (!Array.isArray(order?.pending)) return null;
|
||||
return order.pending.find(i =>
|
||||
i.status === PendingStatus.NEEDS_TYPE ||
|
||||
i.status === PendingStatus.NEEDS_QUANTITY
|
||||
) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza un item pendiente por ID
|
||||
*/
|
||||
export function updatePendingItem(order, itemId, updates) {
|
||||
if (!order?.pending) return order;
|
||||
|
||||
const newPending = order.pending.map(item => {
|
||||
if (item.id === itemId) {
|
||||
return { ...item, ...updates };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
return { ...order, pending: newPending };
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrega un nuevo item pendiente
|
||||
*/
|
||||
export function addPendingItem(order, pendingItem) {
|
||||
const current = order || createEmptyOrder();
|
||||
return {
|
||||
...current,
|
||||
pending: [...(current.pending || []), pendingItem],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convierte orden vieja (order_basket, pending_items, etc.) a nuevo formato
|
||||
*/
|
||||
export function migrateOldContext(ctx) {
|
||||
if (!ctx) return createEmptyOrder();
|
||||
|
||||
// Si ya tiene el nuevo formato
|
||||
if (ctx.order && (Array.isArray(ctx.order.cart) || Array.isArray(ctx.order.pending))) {
|
||||
return ctx.order;
|
||||
}
|
||||
|
||||
const order = createEmptyOrder();
|
||||
|
||||
// Migrar order_basket
|
||||
if (ctx.order_basket?.items) {
|
||||
order.cart = ctx.order_basket.items.map(item => createCartItem({
|
||||
woo_id: item.product_id || item.woo_id || item.woo_product_id,
|
||||
qty: item.quantity || item.qty || 1,
|
||||
unit: item.unit || "unit",
|
||||
name: item.label || item.name,
|
||||
price: item.price,
|
||||
}));
|
||||
}
|
||||
|
||||
// Migrar pending_items
|
||||
if (Array.isArray(ctx.pending_items)) {
|
||||
order.pending = ctx.pending_items.map(item => createPendingItem({
|
||||
id: item.id,
|
||||
query: item.query,
|
||||
candidates: (item.candidates || []).map(c => ({
|
||||
woo_id: c.woo_product_id || c.woo_id,
|
||||
name: c.name,
|
||||
price: c.price,
|
||||
display_unit: c.display_unit,
|
||||
})),
|
||||
selected_woo_id: item.resolved_product?.woo_product_id || item.resolved_product?.woo_id,
|
||||
selected_name: item.resolved_product?.name,
|
||||
selected_price: item.resolved_product?.price,
|
||||
selected_unit: item.resolved_product?.display_unit,
|
||||
qty: item.quantity,
|
||||
unit: item.unit,
|
||||
status: item.status === "needs_type" ? PendingStatus.NEEDS_TYPE :
|
||||
item.status === "needs_quantity" ? PendingStatus.NEEDS_QUANTITY :
|
||||
item.status === "ready" ? PendingStatus.READY :
|
||||
item.status?.toUpperCase() || PendingStatus.NEEDS_TYPE,
|
||||
}));
|
||||
}
|
||||
|
||||
// Migrar checkout info
|
||||
order.payment_type = ctx.payment_method || null;
|
||||
order.is_delivery = ctx.shipping_method === "delivery" ? true :
|
||||
ctx.shipping_method === "pickup" ? false : null;
|
||||
order.shipping_address = ctx.delivery_address?.text || ctx.address?.text || ctx.address_text || null;
|
||||
order.woo_order_id = ctx.woo_order_id || ctx.last_order_id || null;
|
||||
order.is_paid = ctx.mp?.payment_status === "approved" ||
|
||||
ctx.payment?.status === "paid" ||
|
||||
ctx.payment_status === "approved" || false;
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea el carrito para mostrar al usuario
|
||||
*/
|
||||
export function formatCartForDisplay(order, locale = "es-AR") {
|
||||
if (!order?.cart?.length) {
|
||||
return "Tu carrito está vacío.";
|
||||
}
|
||||
|
||||
const lines = order.cart.map(item => {
|
||||
const qtyStr = item.unit === "kg" ? `${item.qty}kg` :
|
||||
item.unit === "g" ? `${item.qty}g` :
|
||||
`${item.qty}`;
|
||||
return `- ${qtyStr} de ${item.name || `Producto #${item.woo_id}`}`;
|
||||
});
|
||||
|
||||
return "Tenés anotado:\n" + lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea opciones de un pending item para mostrar al usuario
|
||||
*/
|
||||
export function formatOptionsForDisplay(pendingItem, pageSize = 9) {
|
||||
if (!pendingItem?.candidates?.length) {
|
||||
return { question: `No encontré "${pendingItem?.query}". ¿Podrías ser más específico?`, options: [] };
|
||||
}
|
||||
|
||||
const options = pendingItem.candidates.slice(0, pageSize);
|
||||
const hasMore = pendingItem.candidates.length > pageSize;
|
||||
|
||||
const lines = options.map((c, i) => `${i + 1}) ${c.name}`);
|
||||
if (hasMore) {
|
||||
lines.push(`${pageSize + 1}) Mostrame más...`);
|
||||
}
|
||||
|
||||
const question = `Para "${pendingItem.query}", ¿cuál de estos querés?\n` + lines.join("\n") + "\n\nRespondé con el número.";
|
||||
|
||||
return { question, options };
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { getRecoRules, getRecoRulesByProductIds } from "../2-identity/db/repo.js";
|
||||
import { 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 });
|
||||
}
|
||||
|
||||
858
src/modules/3-turn-engine/stateHandlers.js
Normal file
858
src/modules/3-turn-engine/stateHandlers.js
Normal file
@@ -0,0 +1,858 @@
|
||||
/**
|
||||
* Handlers por estado para el flujo conversacional simplificado.
|
||||
* Cada handler recibe params y retorna { plan, decision }
|
||||
*/
|
||||
|
||||
import { retrieveCandidates } from "./catalogRetrieval.js";
|
||||
import { ConversationState, safeNextState, hasCartItems, hasPendingItems } from "./fsm.js";
|
||||
import {
|
||||
createEmptyOrder,
|
||||
createPendingItem,
|
||||
createCartItem,
|
||||
PendingStatus,
|
||||
moveReadyToCart,
|
||||
getNextPendingItem,
|
||||
updatePendingItem,
|
||||
addPendingItem,
|
||||
migrateOldContext,
|
||||
formatCartForDisplay,
|
||||
formatOptionsForDisplay,
|
||||
} from "./orderModel.js";
|
||||
import { handleRecommend } from "./recommendations.js";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Utilidades
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function inferDefaultUnit({ name, categories }) {
|
||||
const n = String(name || "").toLowerCase();
|
||||
const cats = Array.isArray(categories) ? categories : [];
|
||||
const hay = (re) =>
|
||||
cats.some((c) => re.test(String(c?.name || "")) || re.test(String(c?.slug || ""))) || re.test(n);
|
||||
if (hay(/\b(chimichurri|provoleta|queso|pan|salsa|aderezo|condimento|especia|especias)\b/i)) {
|
||||
return "unit";
|
||||
}
|
||||
if (hay(/\b(proveedur[ií]a|almac[eé]n|almacen|sal\s+pimienta|aderezos)\b/i)) {
|
||||
return "unit";
|
||||
}
|
||||
if (hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i)) {
|
||||
return "unit";
|
||||
}
|
||||
return "kg";
|
||||
}
|
||||
|
||||
function parseIndexSelection(text) {
|
||||
const t = String(text || "").toLowerCase();
|
||||
const m = /\b(\d{1,2})\b/.exec(t);
|
||||
if (m) return parseInt(m[1], 10);
|
||||
if (/\bprimera\b|\bprimero\b/.test(t)) return 1;
|
||||
if (/\bsegunda\b|\bsegundo\b/.test(t)) return 2;
|
||||
if (/\btercera\b|\btercero\b/.test(t)) return 3;
|
||||
if (/\bcuarta\b|\bcuarto\b/.test(t)) return 4;
|
||||
if (/\bquinta\b|\bquinto\b/.test(t)) return 5;
|
||||
if (/\bsexta\b|\bsexto\b/.test(t)) return 6;
|
||||
if (/\bs[eé]ptima\b|\bs[eé]ptimo\b/.test(t)) return 7;
|
||||
if (/\boctava\b|\boctavo\b/.test(t)) return 8;
|
||||
if (/\bnovena\b|\bnoveno\b/.test(t)) return 9;
|
||||
if (/\bd[eé]cima\b|\bd[eé]cimo\b/.test(t)) return 10;
|
||||
return null;
|
||||
}
|
||||
|
||||
function isShowMoreRequest(text) {
|
||||
const t = String(text || "").toLowerCase();
|
||||
return (
|
||||
/\bmostr(a|ame)\s+m[aá]s\b/.test(t) ||
|
||||
/\bmas\s+opciones\b/.test(t) ||
|
||||
(/\bm[aá]s\b/.test(t) && /\b(opciones|productos|variedades|tipos)\b/.test(t)) ||
|
||||
/\bsiguiente(s)?\b/.test(t)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeUnit(unit) {
|
||||
if (!unit) return null;
|
||||
const u = String(unit).toLowerCase();
|
||||
if (u === "kg" || u === "kilo" || u === "kilos") return "kg";
|
||||
if (u === "g" || u === "gramo" || u === "gramos") return "g";
|
||||
if (u === "unidad" || u === "unidades" || u === "unit") return "unit";
|
||||
return null;
|
||||
}
|
||||
|
||||
function unitAskFor(displayUnit) {
|
||||
if (displayUnit === "unit") return "¿Cuántas unidades querés?";
|
||||
if (displayUnit === "g") return "¿Cuántos gramos querés?";
|
||||
return "¿Cuántos kilos querés?";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Handler: IDLE
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleIdleState({ tenantId, text, nlu, order, audit }) {
|
||||
const intent = nlu?.intent || "other";
|
||||
const actions = [];
|
||||
|
||||
// Greeting
|
||||
if (intent === "greeting") {
|
||||
return {
|
||||
plan: {
|
||||
reply: "¡Hola! ¿En qué te puedo ayudar hoy?",
|
||||
next_state: ConversationState.IDLE,
|
||||
intent: "greeting",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Cualquier intent relacionado con productos → ir a CART
|
||||
if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) {
|
||||
// Delegar a handleCartState
|
||||
return handleCartState({ tenantId, text, nlu, order, audit, fromIdle: true });
|
||||
}
|
||||
|
||||
// Other
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿En qué te puedo ayudar? Podés preguntarme por productos o agregar cosas al carrito.",
|
||||
next_state: ConversationState.IDLE,
|
||||
intent: "other",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Handler: CART
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleCartState({ tenantId, text, nlu, order, audit, fromIdle = false }) {
|
||||
const intent = nlu?.intent || "other";
|
||||
let currentOrder = order || createEmptyOrder();
|
||||
const actions = [];
|
||||
|
||||
// 1) Si hay pending items sin resolver, procesar clarificación
|
||||
const pendingItem = getNextPendingItem(currentOrder);
|
||||
if (pendingItem) {
|
||||
const result = await processPendingClarification({ tenantId, text, nlu, order: currentOrder, pendingItem, audit });
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
// 2) view_cart: mostrar carrito actual
|
||||
if (intent === "view_cart") {
|
||||
const cartDisplay = formatCartForDisplay(currentOrder);
|
||||
const pendingCount = currentOrder.pending?.filter(p => p.status !== PendingStatus.READY).length || 0;
|
||||
let reply = cartDisplay;
|
||||
if (pendingCount > 0) {
|
||||
reply += `\n\n(Tenés ${pendingCount} producto(s) pendiente(s) de confirmar)`;
|
||||
}
|
||||
reply += "\n\n¿Algo más?";
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "view_cart",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// 3) confirm_order: ir a SHIPPING si hay items
|
||||
if (intent === "confirm_order") {
|
||||
// Primero mover pending READY a cart
|
||||
currentOrder = moveReadyToCart(currentOrder);
|
||||
|
||||
if (!hasCartItems(currentOrder)) {
|
||||
return {
|
||||
plan: {
|
||||
reply: "Tu carrito está vacío. ¿Qué querés agregar?",
|
||||
next_state: ConversationState.CART,
|
||||
intent: "confirm_order",
|
||||
missing_fields: ["cart_items"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Si hay pending items sin resolver, clarificarlos primero
|
||||
if (hasPendingItems(currentOrder)) {
|
||||
const nextPending = getNextPendingItem(currentOrder);
|
||||
const { question } = formatOptionsForDisplay(nextPending);
|
||||
return {
|
||||
plan: {
|
||||
reply: `Antes de cerrar, tenemos que confirmar algunos productos.\n\n${question}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "confirm_order",
|
||||
missing_fields: ["pending_items"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Todo listo, ir a SHIPPING
|
||||
const { next_state } = safeNextState(ConversationState.CART, currentOrder, { confirm_order: true });
|
||||
return {
|
||||
plan: {
|
||||
reply: "Perfecto. ¿Es para delivery o pasás a retirar?\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal",
|
||||
next_state,
|
||||
intent: "confirm_order",
|
||||
missing_fields: ["shipping_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// 4) recommend
|
||||
if (intent === "recommend") {
|
||||
try {
|
||||
const recoResult = await handleRecommend({
|
||||
tenantId,
|
||||
text,
|
||||
nlu,
|
||||
order: currentOrder,
|
||||
prevContext: { order: currentOrder },
|
||||
audit
|
||||
});
|
||||
if (recoResult?.plan?.reply) {
|
||||
// Merge context_patch si existe
|
||||
const newOrder = recoResult.decision?.order || currentOrder;
|
||||
const contextPatch = recoResult.decision?.context_patch || {};
|
||||
return {
|
||||
plan: {
|
||||
...recoResult.plan,
|
||||
next_state: ConversationState.CART,
|
||||
},
|
||||
decision: {
|
||||
actions: recoResult.decision?.actions || [],
|
||||
order: newOrder,
|
||||
audit,
|
||||
context_patch: contextPatch,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
audit.recommend_error = String(e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// 4.5) price_query - consulta de precios
|
||||
if (intent === "price_query") {
|
||||
const productQueries = extractProductQueries(nlu);
|
||||
|
||||
if (productQueries.length === 0) {
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿De qué producto querés saber el precio?",
|
||||
next_state: ConversationState.CART,
|
||||
intent: "price_query",
|
||||
missing_fields: ["product_query"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Buscar productos y mostrar precios
|
||||
const priceResults = [];
|
||||
for (const pq of productQueries.slice(0, 5)) {
|
||||
const searchResult = await retrieveCandidates({ tenantId, query: pq.query, limit: 3 });
|
||||
const candidates = searchResult?.candidates || [];
|
||||
audit.price_search = audit.price_search || [];
|
||||
audit.price_search.push({ query: pq.query, count: candidates.length });
|
||||
|
||||
for (const c of candidates.slice(0, 2)) {
|
||||
const unit = inferDefaultUnit({ name: c.name, categories: c.categories });
|
||||
const priceStr = c.price != null ? `$${c.price}` : "consultar";
|
||||
const unitStr = unit === "unit" ? "/unidad" : "/kg";
|
||||
priceResults.push(`• ${c.name}: ${priceStr}${unitStr}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (priceResults.length === 0) {
|
||||
return {
|
||||
plan: {
|
||||
reply: "No encontré ese producto. ¿Podés ser más específico?",
|
||||
next_state: ConversationState.CART,
|
||||
intent: "price_query",
|
||||
missing_fields: ["product_query"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
const reply = "Estos son los precios:\n\n" + priceResults.join("\n") + "\n\n¿Querés agregar alguno al carrito?";
|
||||
return {
|
||||
plan: {
|
||||
reply,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "price_query",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// 5) add_to_cart / browse / price_query: buscar productos
|
||||
if (["add_to_cart", "browse", "price_query"].includes(intent) || fromIdle) {
|
||||
const productQueries = extractProductQueries(nlu);
|
||||
|
||||
if (productQueries.length === 0) {
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿Qué producto querés agregar?",
|
||||
next_state: ConversationState.CART,
|
||||
intent,
|
||||
missing_fields: ["product_query"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Buscar candidatos para cada query
|
||||
for (const pq of productQueries) {
|
||||
const searchResult = await retrieveCandidates({ tenantId, query: pq.query, limit: 20 });
|
||||
const candidates = searchResult?.candidates || [];
|
||||
audit.catalog_search = audit.catalog_search || [];
|
||||
audit.catalog_search.push({ query: pq.query, count: candidates.length });
|
||||
|
||||
const pendingItem = createPendingItemFromSearch({
|
||||
query: pq.query,
|
||||
quantity: pq.quantity,
|
||||
unit: pq.unit,
|
||||
candidates,
|
||||
});
|
||||
|
||||
currentOrder = addPendingItem(currentOrder, pendingItem);
|
||||
}
|
||||
|
||||
// Mover items READY directamente al cart
|
||||
currentOrder = moveReadyToCart(currentOrder);
|
||||
|
||||
// Si hay pending items, pedir clarificación del primero
|
||||
const nextPending = getNextPendingItem(currentOrder);
|
||||
if (nextPending) {
|
||||
if (nextPending.status === PendingStatus.NEEDS_TYPE) {
|
||||
const { question } = formatOptionsForDisplay(nextPending);
|
||||
return {
|
||||
plan: {
|
||||
reply: question,
|
||||
next_state: ConversationState.CART,
|
||||
intent,
|
||||
missing_fields: ["product_selection"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
if (nextPending.status === PendingStatus.NEEDS_QUANTITY) {
|
||||
const unitQuestion = unitAskFor(nextPending.selected_unit || "kg");
|
||||
return {
|
||||
plan: {
|
||||
reply: `Para ${nextPending.selected_name || nextPending.query}, ${unitQuestion}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent,
|
||||
missing_fields: ["quantity"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Todo resuelto, confirmar agregado
|
||||
const lastAdded = currentOrder.cart[currentOrder.cart.length - 1];
|
||||
if (lastAdded) {
|
||||
const qtyStr = lastAdded.unit === "unit" ? lastAdded.qty : `${lastAdded.qty}${lastAdded.unit}`;
|
||||
return {
|
||||
plan: {
|
||||
reply: `Perfecto, anoto ${qtyStr} de ${lastAdded.name}. ¿Algo más?`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: [],
|
||||
order_action: "add_to_cart",
|
||||
},
|
||||
decision: { actions: [{ type: "add_to_cart", payload: lastAdded }], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Default
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿Qué más querés agregar?",
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Handler: SHIPPING
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleShippingState({ tenantId, text, nlu, order, audit }) {
|
||||
const intent = nlu?.intent || "other";
|
||||
let currentOrder = order || createEmptyOrder();
|
||||
|
||||
// Detectar selección de shipping (delivery/pickup)
|
||||
let shippingMethod = nlu?.entities?.shipping_method;
|
||||
|
||||
// Detectar por número o texto
|
||||
if (!shippingMethod) {
|
||||
const t = String(text || "").toLowerCase();
|
||||
const idx = parseIndexSelection(text);
|
||||
if (idx === 1 || /delivery|envío|envio|traigan|llev/i.test(t)) {
|
||||
shippingMethod = "delivery";
|
||||
} else if (idx === 2 || /retiro|retira|buscar|sucursal|paso/i.test(t)) {
|
||||
shippingMethod = "pickup";
|
||||
}
|
||||
}
|
||||
|
||||
if (shippingMethod) {
|
||||
currentOrder = { ...currentOrder, is_delivery: shippingMethod === "delivery" };
|
||||
|
||||
if (shippingMethod === "pickup") {
|
||||
// Pickup: ir directo a PAYMENT
|
||||
const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {});
|
||||
return {
|
||||
plan: {
|
||||
reply: "Perfecto, retiro en sucursal. ¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.",
|
||||
next_state,
|
||||
intent: "select_shipping",
|
||||
missing_fields: ["payment_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Delivery: pedir dirección si no la tiene
|
||||
if (!currentOrder.shipping_address) {
|
||||
return {
|
||||
plan: {
|
||||
reply: "Perfecto, delivery. ¿Me pasás la dirección de entrega?",
|
||||
next_state: ConversationState.SHIPPING,
|
||||
intent: "select_shipping",
|
||||
missing_fields: ["address"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Si ya eligió delivery y ahora da dirección
|
||||
if (currentOrder.is_delivery === true && !currentOrder.shipping_address) {
|
||||
// Extraer dirección del texto (el usuario probablemente escribió la dirección)
|
||||
const address = nlu?.entities?.address || (text?.length > 5 ? text.trim() : null);
|
||||
|
||||
if (address) {
|
||||
currentOrder = { ...currentOrder, shipping_address: address };
|
||||
const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {});
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: `Anotado: ${address}.\n\n¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.`,
|
||||
next_state,
|
||||
intent: "provide_address",
|
||||
missing_fields: ["payment_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: "Necesito la dirección de entrega. ¿Me la pasás?",
|
||||
next_state: ConversationState.SHIPPING,
|
||||
intent: "other",
|
||||
missing_fields: ["address"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// view_cart
|
||||
if (intent === "view_cart") {
|
||||
const cartDisplay = formatCartForDisplay(currentOrder);
|
||||
return {
|
||||
plan: {
|
||||
reply: cartDisplay + "\n\n¿Es para delivery o retiro?",
|
||||
next_state: ConversationState.SHIPPING,
|
||||
intent: "view_cart",
|
||||
missing_fields: ["shipping_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Default: preguntar de nuevo
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿Es para delivery o pasás a retirar?\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal",
|
||||
next_state: ConversationState.SHIPPING,
|
||||
intent: "other",
|
||||
missing_fields: ["shipping_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Handler: PAYMENT
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handlePaymentState({ tenantId, text, nlu, order, audit }) {
|
||||
const intent = nlu?.intent || "other";
|
||||
let currentOrder = order || createEmptyOrder();
|
||||
const actions = [];
|
||||
|
||||
// Detectar selección de pago
|
||||
let paymentMethod = nlu?.entities?.payment_method;
|
||||
|
||||
if (!paymentMethod) {
|
||||
const t = String(text || "").toLowerCase();
|
||||
const idx = parseIndexSelection(text);
|
||||
if (idx === 1 || /efectivo|cash|plata/i.test(t)) {
|
||||
paymentMethod = "cash";
|
||||
} else if (idx === 2 || /link|tarjeta|transfer|qr|mercado\s*pago/i.test(t)) {
|
||||
paymentMethod = "link";
|
||||
}
|
||||
}
|
||||
|
||||
if (paymentMethod) {
|
||||
currentOrder = { ...currentOrder, payment_type: paymentMethod };
|
||||
actions.push({ type: "create_order", payload: { payment: paymentMethod } });
|
||||
|
||||
if (paymentMethod === "link") {
|
||||
actions.push({ type: "send_payment_link", payload: {} });
|
||||
}
|
||||
|
||||
const { next_state } = safeNextState(ConversationState.PAYMENT, currentOrder, { payment_selected: true });
|
||||
|
||||
const paymentLabel = paymentMethod === "cash" ? "efectivo" : "link de pago";
|
||||
const deliveryInfo = currentOrder.is_delivery
|
||||
? `Te lo llevamos a ${currentOrder.shipping_address || "la dirección indicada"}.`
|
||||
: "Retiro en sucursal.";
|
||||
|
||||
const paymentInfo = paymentMethod === "link"
|
||||
? "Te paso el link de pago en un momento."
|
||||
: "Pagás en " + (currentOrder.is_delivery ? "la entrega" : "sucursal") + ".";
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: `Listo, pagás en ${paymentLabel}. ${deliveryInfo}\n\n${paymentInfo}\n\n¡Gracias por tu pedido!`,
|
||||
next_state,
|
||||
intent: "select_payment",
|
||||
missing_fields: [],
|
||||
order_action: "create_order",
|
||||
},
|
||||
decision: { actions, order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// view_cart
|
||||
if (intent === "view_cart") {
|
||||
const cartDisplay = formatCartForDisplay(currentOrder);
|
||||
return {
|
||||
plan: {
|
||||
reply: cartDisplay + "\n\n¿Cómo preferís pagar?",
|
||||
next_state: ConversationState.PAYMENT,
|
||||
intent: "view_cart",
|
||||
missing_fields: ["payment_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Default
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.",
|
||||
next_state: ConversationState.PAYMENT,
|
||||
intent: "other",
|
||||
missing_fields: ["payment_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Handler: WAITING_WEBHOOKS
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleWaitingState({ tenantId, text, nlu, order, audit }) {
|
||||
const intent = nlu?.intent || "other";
|
||||
const currentOrder = order || createEmptyOrder();
|
||||
|
||||
// view_cart
|
||||
if (intent === "view_cart") {
|
||||
const cartDisplay = formatCartForDisplay(currentOrder);
|
||||
const status = currentOrder.is_paid ? "✓ Pagado" : "Esperando pago...";
|
||||
return {
|
||||
plan: {
|
||||
reply: `${cartDisplay}\n\nEstado: ${status}`,
|
||||
next_state: ConversationState.WAITING_WEBHOOKS,
|
||||
intent: "view_cart",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Default
|
||||
const reply = currentOrder.payment_type === "link"
|
||||
? "Tu pedido está en proceso. Avisame si necesitás algo más o esperá la confirmación de pago."
|
||||
: "Tu pedido está listo. Avisame si necesitás algo más.";
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply,
|
||||
next_state: ConversationState.WAITING_WEBHOOKS,
|
||||
intent: "other",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helpers internos
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function extractProductQueries(nlu) {
|
||||
const queries = [];
|
||||
|
||||
// Multi-items
|
||||
if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.length > 0) {
|
||||
for (const item of nlu.entities.items) {
|
||||
if (item.product_query) {
|
||||
queries.push({
|
||||
query: item.product_query,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
});
|
||||
}
|
||||
}
|
||||
return queries;
|
||||
}
|
||||
|
||||
// Single item
|
||||
if (nlu?.entities?.product_query) {
|
||||
queries.push({
|
||||
query: nlu.entities.product_query,
|
||||
quantity: nlu.entities.quantity,
|
||||
unit: nlu.entities.unit,
|
||||
});
|
||||
}
|
||||
|
||||
return queries;
|
||||
}
|
||||
|
||||
function createPendingItemFromSearch({ query, quantity, unit, candidates }) {
|
||||
const cands = (candidates || []).filter(c => c && c.woo_product_id);
|
||||
|
||||
if (cands.length === 0) {
|
||||
return createPendingItem({
|
||||
query,
|
||||
candidates: [],
|
||||
status: PendingStatus.NEEDS_TYPE, // Will show "not found" message
|
||||
});
|
||||
}
|
||||
|
||||
// Check for strong match
|
||||
const best = cands[0];
|
||||
const second = cands[1];
|
||||
const isStrong = cands.length === 1 ||
|
||||
(best?._score >= 0.9 && (!second || best._score - (second?._score || 0) >= 0.2));
|
||||
|
||||
if (isStrong) {
|
||||
const displayUnit = inferDefaultUnit({ name: best.name, categories: best.categories });
|
||||
const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0;
|
||||
const sellsByWeight = displayUnit !== "unit";
|
||||
const hasExplicitUnit = unit != null && unit !== "";
|
||||
const quantityIsGeneric = hasQty && Number(quantity) <= 2 && !hasExplicitUnit;
|
||||
const needsQuantity = sellsByWeight && (!hasQty || quantityIsGeneric);
|
||||
|
||||
return createPendingItem({
|
||||
query,
|
||||
candidates: [],
|
||||
selected_woo_id: best.woo_product_id,
|
||||
selected_name: best.name,
|
||||
selected_price: best.price,
|
||||
selected_unit: displayUnit,
|
||||
qty: needsQuantity ? null : (hasQty ? Number(quantity) : 1),
|
||||
unit: normalizeUnit(unit) || displayUnit,
|
||||
status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY,
|
||||
});
|
||||
}
|
||||
|
||||
// Multiple candidates, needs selection
|
||||
return createPendingItem({
|
||||
query,
|
||||
candidates: cands.slice(0, 20).map(c => ({
|
||||
woo_id: c.woo_product_id,
|
||||
name: c.name,
|
||||
price: c.price,
|
||||
display_unit: inferDefaultUnit({ name: c.name, categories: c.categories }),
|
||||
})),
|
||||
status: PendingStatus.NEEDS_TYPE,
|
||||
});
|
||||
}
|
||||
|
||||
async function processPendingClarification({ tenantId, text, nlu, order, pendingItem, audit }) {
|
||||
// Si necesita seleccionar tipo
|
||||
if (pendingItem.status === PendingStatus.NEEDS_TYPE) {
|
||||
const idx = parseIndexSelection(text);
|
||||
|
||||
// Show more
|
||||
if (isShowMoreRequest(text)) {
|
||||
// TODO: implement pagination
|
||||
const { question } = formatOptionsForDisplay(pendingItem);
|
||||
return {
|
||||
plan: {
|
||||
reply: question,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: ["product_selection"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Selection by index
|
||||
if (idx && pendingItem.candidates && idx <= pendingItem.candidates.length) {
|
||||
const selected = pendingItem.candidates[idx - 1];
|
||||
const displayUnit = selected.display_unit || inferDefaultUnit({ name: selected.name, categories: [] });
|
||||
const needsQuantity = displayUnit !== "unit";
|
||||
|
||||
const updatedOrder = updatePendingItem(order, pendingItem.id, {
|
||||
selected_woo_id: selected.woo_id,
|
||||
selected_name: selected.name,
|
||||
selected_price: selected.price,
|
||||
selected_unit: displayUnit,
|
||||
candidates: [],
|
||||
status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY,
|
||||
qty: needsQuantity ? null : 1,
|
||||
unit: displayUnit,
|
||||
});
|
||||
|
||||
// Si necesita cantidad, preguntar
|
||||
if (needsQuantity) {
|
||||
const unitQuestion = unitAskFor(displayUnit);
|
||||
return {
|
||||
plan: {
|
||||
reply: `Para ${selected.name}, ${unitQuestion}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: ["quantity"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: updatedOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Listo, mover al cart
|
||||
const finalOrder = moveReadyToCart(updatedOrder);
|
||||
return {
|
||||
plan: {
|
||||
reply: `Perfecto, anoto 1 ${selected.name}. ¿Algo más?`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: [],
|
||||
order_action: "add_to_cart",
|
||||
},
|
||||
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// No entendió, volver a preguntar
|
||||
const { question } = formatOptionsForDisplay(pendingItem);
|
||||
return {
|
||||
plan: {
|
||||
reply: "No entendí. " + question,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: ["product_selection"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Si necesita cantidad
|
||||
if (pendingItem.status === PendingStatus.NEEDS_QUANTITY) {
|
||||
const qty = nlu?.entities?.quantity;
|
||||
const unit = nlu?.entities?.unit;
|
||||
|
||||
// Try to parse quantity from text
|
||||
let parsedQty = qty;
|
||||
if (parsedQty == null) {
|
||||
const m = /(\d+(?:[.,]\d+)?)\s*(kg|kilo|kilos|g|gramo|gramos|unidad|unidades)?/i.exec(text || "");
|
||||
if (m) {
|
||||
parsedQty = parseFloat(m[1].replace(",", "."));
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedQty != null && Number.isFinite(parsedQty) && parsedQty > 0) {
|
||||
const finalUnit = normalizeUnit(unit) || pendingItem.selected_unit || "kg";
|
||||
const updatedOrder = updatePendingItem(order, pendingItem.id, {
|
||||
qty: parsedQty,
|
||||
unit: finalUnit,
|
||||
status: PendingStatus.READY,
|
||||
});
|
||||
|
||||
const finalOrder = moveReadyToCart(updatedOrder);
|
||||
const qtyStr = finalUnit === "unit" ? parsedQty : `${parsedQty}${finalUnit}`;
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: `Perfecto, anoto ${qtyStr} de ${pendingItem.selected_name}. ¿Algo más?`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: [],
|
||||
order_action: "add_to_cart",
|
||||
},
|
||||
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// No entendió cantidad
|
||||
const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg");
|
||||
return {
|
||||
plan: {
|
||||
reply: `No entendí la cantidad. ${unitQuestion}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: ["quantity"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -111,13 +111,17 @@ function normalizeWooProduct(p) {
|
||||
function snapshotRowToItem(row) {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user