added Mercado Pago integration with new payment handling functions and updated app routing

This commit is contained in:
Lucas Tettamanti
2026-01-15 13:06:37 -03:00
parent 29fa2d127e
commit eedd16afdb
13 changed files with 1652 additions and 5 deletions

View File

@@ -0,0 +1,19 @@
-- migrate:up
create table if not exists product_aliases (
tenant_id uuid not null references tenants(id) on delete cascade,
alias text not null,
normalized_alias text not null,
woo_product_id integer null,
category_hint text null,
boost numeric(6,3) not null default 0.0,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (tenant_id, alias)
);
create index if not exists product_aliases_tenant_norm_idx
on product_aliases (tenant_id, normalized_alias);
-- migrate:down
drop table if exists product_aliases;

View File

@@ -0,0 +1,16 @@
-- migrate:up
create table if not exists product_embeddings_cache (
tenant_id uuid not null references tenants(id) on delete cascade,
content_hash text not null,
content_text text not null,
embedding jsonb not null,
model text not null,
updated_at timestamptz not null default now(),
primary key (tenant_id, content_hash)
);
create index if not exists product_embeddings_cache_tenant_idx
on product_embeddings_cache (tenant_id);
-- migrate:down
drop table if exists product_embeddings_cache;

View File

@@ -0,0 +1,19 @@
-- migrate:up
create table if not exists mp_payments (
tenant_id uuid not null references tenants(id) on delete cascade,
woo_order_id bigint null,
preference_id text null,
payment_id text null,
status text null,
paid_at timestamptz null,
raw jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (tenant_id, payment_id)
);
create index if not exists mp_payments_tenant_order_idx
on mp_payments (tenant_id, woo_order_id);
-- migrate:down
drop table if exists mp_payments;

View File

@@ -0,0 +1,46 @@
-- migrate:up
-- Seed básico de regionalismos AR para aliases de producto.
-- Nota: estos alias no fijan woo_product_id (se resuelven por retrieval).
insert into product_aliases (tenant_id, alias, normalized_alias, woo_product_id, category_hint, boost, metadata)
select t.id, v.alias, v.normalized_alias, null, v.category_hint, v.boost, v.metadata
from tenants t
cross join (
values
('asado', 'asado', 'vacuno', 0.20, '{"region":"AR","notes":"corte general"}'::jsonb),
('tira de asado', 'tira de asado', 'vacuno', 0.25, '{"region":"AR"}'::jsonb),
('asado banderita', 'asado banderita', 'vacuno', 0.25, '{"region":"AR"}'::jsonb),
('vacio', 'vacio', 'vacuno', 0.25, '{"region":"AR","alt":"vacío"}'::jsonb),
('vacío', 'vacio', 'vacuno', 0.25, '{"region":"AR","alt":"vacio"}'::jsonb),
('entraña', 'entrana', 'vacuno', 0.25, '{"region":"AR"}'::jsonb),
('matambre', 'matambre', 'vacuno', 0.20, '{"region":"AR"}'::jsonb),
('colita de cuadril', 'colita de cuadril', 'vacuno', 0.20, '{"region":"AR"}'::jsonb),
('tapa de asado', 'tapa de asado', 'vacuno', 0.20, '{"region":"AR"}'::jsonb),
('bife de chorizo', 'bife de chorizo', 'vacuno', 0.20, '{"region":"AR"}'::jsonb),
('ojo de bife', 'ojo de bife', 'vacuno', 0.20, '{"region":"AR"}'::jsonb),
('nalga', 'nalga', 'vacuno', 0.20, '{"region":"AR"}'::jsonb),
('bola de lomo', 'bola de lomo', 'vacuno', 0.20, '{"region":"AR"}'::jsonb),
('paleta', 'paleta', 'vacuno', 0.15, '{"region":"AR"}'::jsonb),
('roast beef', 'roast beef', 'vacuno', 0.15, '{"region":"AR","alt":"rosbif"}'::jsonb),
('rosbif', 'rosbif', 'vacuno', 0.15, '{"region":"AR","alt":"roast beef"}'::jsonb),
('peceto', 'peceto', 'vacuno', 0.20, '{"region":"AR"}'::jsonb),
('tapa de nalga', 'tapa de nalga', 'vacuno', 0.20, '{"region":"AR"}'::jsonb),
('tortuguita', 'tortuguita', 'vacuno', 0.20, '{"region":"AR"}'::jsonb),
('carre', 'carre', 'cerdo', 0.20, '{"region":"AR","alt":"carré"}'::jsonb),
('carré', 'carre', 'cerdo', 0.20, '{"region":"AR","alt":"carre"}'::jsonb),
('bondiola', 'bondiola', 'cerdo', 0.20, '{"region":"AR"}'::jsonb),
('matambrito de cerdo', 'matambrito de cerdo', 'cerdo', 0.20, '{"region":"AR"}'::jsonb),
('panceta', 'panceta', 'cerdo', 0.15, '{"region":"AR"}'::jsonb),
('chorizo', 'chorizo', 'embutidos', 0.10, '{"region":"AR"}'::jsonb),
('morcilla', 'morcilla', 'embutidos', 0.10, '{"region":"AR"}'::jsonb),
('salchicha parrillera', 'salchicha parrillera', 'embutidos', 0.10, '{"region":"AR"}'::jsonb),
('achuras', 'achuras', 'achuras', 0.10, '{"region":"AR"}'::jsonb),
('chinchulines', 'chinchulines', 'achuras', 0.10, '{"region":"AR"}'::jsonb),
('molleja', 'molleja', 'achuras', 0.10, '{"region":"AR"}'::jsonb),
('riñon', 'rinon', 'achuras', 0.10, '{"region":"AR","alt":"riñón"}'::jsonb),
('riñón', 'rinon', 'achuras', 0.10, '{"region":"AR","alt":"riñon"}'::jsonb)
) as v(alias, normalized_alias, category_hint, boost, metadata)
on conflict (tenant_id, alias) do nothing;
-- migrate:down
delete from product_aliases
where metadata->>'region' = 'AR';

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from "url";
import { createSimulatorRouter } from "./routes/simulator.js"; import { createSimulatorRouter } from "./routes/simulator.js";
import { createEvolutionRouter } from "./routes/evolution.js"; import { createEvolutionRouter } from "./routes/evolution.js";
import { createMercadoPagoRouter } from "./routes/mercadoPago.js";
export function createApp({ tenantId }) { export function createApp({ tenantId }) {
const app = express(); const app = express();
@@ -21,6 +22,7 @@ export function createApp({ tenantId }) {
// --- Integraciones / UI --- // --- Integraciones / UI ---
app.use(createSimulatorRouter({ tenantId })); app.use(createSimulatorRouter({ tenantId }));
app.use(createEvolutionRouter()); app.use(createEvolutionRouter());
app.use("/payments/meli", createMercadoPagoRouter());
// Home (UI) // Home (UI)
app.get("/", (req, res) => { app.get("/", (req, res) => {

View File

@@ -0,0 +1,41 @@
import { fetchPayment, reconcilePayment, verifyWebhookSignature } from "../services/mercadoPago.js";
export function makeMercadoPagoWebhook() {
return async function handleMercadoPagoWebhook(req, res) {
try {
const signature = verifyWebhookSignature({ headers: req.headers, query: req.query || {} });
if (!signature.ok) {
return res.status(401).json({ ok: false, error: "invalid_signature", reason: signature.reason });
}
const paymentId =
req?.query?.["data.id"] ||
req?.query?.data?.id ||
req?.body?.data?.id ||
null;
if (!paymentId) {
return res.status(400).json({ ok: false, error: "missing_payment_id" });
}
const payment = await fetchPayment({ paymentId });
const reconciled = await reconcilePayment({ payment });
return res.status(200).json({
ok: true,
payment_id: payment?.id || null,
status: payment?.status || null,
woo_order_id: reconciled?.woo_order_id || null,
});
} catch (e) {
return res.status(500).json({ ok: false, error: String(e?.message || e) });
}
};
}
export function makeMercadoPagoReturn() {
return function handleMercadoPagoReturn(req, res) {
const status = req.query?.status || "unknown";
res.status(200).send(`OK - ${status}`);
};
}

View File

@@ -619,4 +619,121 @@ export async function getWooProductCacheById({ tenant_id, woo_product_id }) {
payload: r.payload, payload: r.payload,
updated_at: r.updated_at, updated_at: r.updated_at,
}; };
}
export async function searchProductAliases({ tenant_id, q = "", limit = 20 }) {
const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 20));
const query = String(q || "").trim();
if (!query) return [];
const normalized = query.toLowerCase();
const like = `%${query}%`;
const nlike = `%${normalized}%`;
const sql = `
select tenant_id, alias, normalized_alias, woo_product_id, category_hint, boost, metadata, updated_at
from product_aliases
where tenant_id=$1
and (alias ilike $2 or normalized_alias ilike $3)
order by boost desc, updated_at desc
limit $4
`;
const { rows } = await pool.query(sql, [tenant_id, like, nlike, lim]);
return rows.map((r) => ({
tenant_id: r.tenant_id,
alias: r.alias,
normalized_alias: r.normalized_alias,
woo_product_id: r.woo_product_id,
category_hint: r.category_hint,
boost: r.boost,
metadata: r.metadata,
updated_at: r.updated_at,
}));
}
export async function getProductEmbedding({ tenant_id, content_hash }) {
const sql = `
select tenant_id, content_hash, content_text, embedding, model, updated_at
from product_embeddings_cache
where tenant_id=$1 and content_hash=$2
limit 1
`;
const { rows } = await pool.query(sql, [tenant_id, content_hash]);
return rows[0] || null;
}
export async function upsertProductEmbedding({
tenant_id,
content_hash,
content_text,
embedding,
model,
}) {
const sql = `
insert into product_embeddings_cache
(tenant_id, content_hash, content_text, embedding, model, updated_at)
values
($1, $2, $3, $4::jsonb, $5, now())
on conflict (tenant_id, content_hash)
do update set
content_text = excluded.content_text,
embedding = excluded.embedding,
model = excluded.model,
updated_at = now()
returning tenant_id, content_hash, content_text, embedding, model, updated_at
`;
const { rows } = await pool.query(sql, [
tenant_id,
content_hash,
content_text,
JSON.stringify(embedding ?? []),
model,
]);
return rows[0] || null;
}
export async function upsertMpPayment({
tenant_id,
woo_order_id = null,
preference_id = null,
payment_id = null,
status = null,
paid_at = null,
raw = {},
}) {
if (!payment_id) throw new Error("payment_id_required");
const sql = `
insert into mp_payments
(tenant_id, woo_order_id, preference_id, payment_id, status, paid_at, raw, created_at, updated_at)
values
($1, $2, $3, $4, $5, $6::timestamptz, $7::jsonb, now(), now())
on conflict (tenant_id, payment_id)
do update set
woo_order_id = excluded.woo_order_id,
preference_id = excluded.preference_id,
status = excluded.status,
paid_at = excluded.paid_at,
raw = excluded.raw,
updated_at = now()
returning tenant_id, woo_order_id, preference_id, payment_id, status, paid_at, raw, updated_at
`;
const { rows } = await pool.query(sql, [
tenant_id,
woo_order_id,
preference_id,
payment_id,
status,
paid_at,
JSON.stringify(raw ?? {}),
]);
return rows[0] || null;
}
export async function getMpPaymentById({ tenant_id, payment_id }) {
const sql = `
select tenant_id, woo_order_id, preference_id, payment_id, status, paid_at, raw, updated_at
from mp_payments
where tenant_id=$1 and payment_id=$2
limit 1
`;
const { rows } = await pool.query(sql, [tenant_id, payment_id]);
return rows[0] || null;
} }

View File

@@ -0,0 +1,9 @@
import express from "express";
import { makeMercadoPagoReturn, makeMercadoPagoWebhook } from "../controllers/mercadoPago.js";
export function createMercadoPagoRouter() {
const router = express.Router();
router.post("/webhook/mercadopago", makeMercadoPagoWebhook());
router.get("/return", makeMercadoPagoReturn());
return router;
}

View File

@@ -0,0 +1,210 @@
import crypto from "crypto";
import OpenAI from "openai";
import { debug as dbg } from "./debug.js";
import { searchProducts } from "./wooProducts.js";
import {
searchProductAliases,
getProductEmbedding,
upsertProductEmbedding,
} from "../db/repo.js";
function getOpenAiKey() {
return process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY || null;
}
function getEmbeddingsModel() {
return process.env.OPENAI_EMBEDDINGS_MODEL || "text-embedding-3-small";
}
function normalizeText(s) {
return String(s || "")
.toLowerCase()
.replace(/[¿?¡!.,;:()"]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function hashText(s) {
return crypto.createHash("sha256").update(String(s || "")).digest("hex");
}
function cosine(a, b) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || a.length === 0) return 0;
let dot = 0;
let na = 0;
let nb = 0;
for (let i = 0; i < a.length; i++) {
const x = Number(a[i]) || 0;
const y = Number(b[i]) || 0;
dot += x * y;
na += x * x;
nb += y * y;
}
if (na === 0 || nb === 0) return 0;
return dot / (Math.sqrt(na) * Math.sqrt(nb));
}
function candidateText(c) {
const parts = [c?.name || ""];
if (Array.isArray(c?.categories)) {
for (const cat of c.categories) {
if (cat?.name) parts.push(cat.name);
if (cat?.slug) parts.push(cat.slug);
}
}
if (Array.isArray(c?.attributes)) {
for (const a of c.attributes) {
if (a?.name) parts.push(a.name);
if (Array.isArray(a?.options)) parts.push(a.options.join(" "));
}
}
return parts.join(" ");
}
function literalScore(query, candidate) {
const q = normalizeText(query);
const n = normalizeText(candidate?.name || "");
if (!q || !n) return 0;
if (n === q) return 1.0;
if (n.includes(q)) return 0.7;
const qt = new Set(q.split(" ").filter(Boolean));
const nt = new Set(n.split(" ").filter(Boolean));
let hits = 0;
for (const w of qt) if (nt.has(w)) hits++;
return hits / Math.max(qt.size, 1);
}
async function embedText({ tenantId, text }) {
const key = getOpenAiKey();
if (!key) return { embedding: null, cached: false, model: null, error: "OPENAI_NO_KEY" };
const content = normalizeText(text);
const contentHash = hashText(content);
const cached = await getProductEmbedding({ tenant_id: tenantId, content_hash: contentHash });
if (cached?.embedding) {
return { embedding: cached.embedding, cached: true, model: cached.model || null };
}
const client = new OpenAI({ apiKey: key });
const model = getEmbeddingsModel();
const resp = await client.embeddings.create({
model,
input: content,
});
const vector = resp?.data?.[0]?.embedding || null;
if (Array.isArray(vector)) {
await upsertProductEmbedding({
tenant_id: tenantId,
content_hash: contentHash,
content_text: content,
embedding: vector,
model,
});
}
return { embedding: vector, cached: false, model };
}
function mergeCandidates(list) {
const map = new Map();
for (const c of list) {
if (!c?.woo_product_id) continue;
const id = Number(c.woo_product_id);
if (!map.has(id)) {
map.set(id, { ...c });
} else {
const prev = map.get(id);
map.set(id, { ...prev, ...c, _score: Math.max(prev._score || 0, c._score || 0) });
}
}
return [...map.values()];
}
/**
* retrieveCandidates: combina Woo literal + alias + embeddings.
*/
export async function retrieveCandidates({
tenantId,
query,
attributes = [],
preparation = [],
limit = 12,
}) {
const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 12));
const q = String(query || "").trim();
if (!q) {
return { candidates: [], audit: { reason: "empty_query" } };
}
const audit = { query: q, sources: {}, boosts: {}, embeddings: {} };
const aliases = await searchProductAliases({ tenant_id: tenantId, q, limit: 20 });
const aliasBoostByProduct = new Map();
for (const a of aliases) {
if (a?.woo_product_id) {
const id = Number(a.woo_product_id);
const boost = Number(a.boost || 0);
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost || 0));
}
}
audit.sources.aliases = aliases.length;
const { items: wooItems, source: wooSource } = await searchProducts({
tenantId,
q,
limit: lim,
forceWoo: true,
});
audit.sources.woo = { source: wooSource, count: wooItems?.length || 0 };
let candidates = (wooItems || []).map((c) => {
const lit = literalScore(q, c);
const boost = aliasBoostByProduct.get(Number(c.woo_product_id)) || 0;
return { ...c, _score: lit + boost, _score_detail: { literal: lit, alias_boost: boost } };
});
// embeddings: opcional, si hay key y tenemos candidatos
if (candidates.length) {
try {
const queryEmb = await embedText({ tenantId, text: q });
if (Array.isArray(queryEmb.embedding)) {
audit.embeddings.query = { cached: queryEmb.cached, model: queryEmb.model };
const enriched = [];
for (const c of candidates.slice(0, 25)) {
const text = candidateText(c);
const emb = await embedText({ tenantId, text });
const cos = Array.isArray(emb.embedding) ? cosine(queryEmb.embedding, emb.embedding) : 0;
const prev = c._score || 0;
enriched.push({
...c,
_score: prev + Math.max(0, cos),
_score_detail: { ...(c._score_detail || {}), cosine: cos, emb_cached: emb.cached },
});
}
// merge con el resto sin embeddings
const tail = candidates.slice(25);
candidates = mergeCandidates([...enriched, ...tail]);
} else {
audit.embeddings.query = { error: queryEmb.error || "no_embedding" };
}
} catch (e) {
audit.embeddings.error = String(e?.message || e);
}
}
candidates.sort((a, b) => (b._score || 0) - (a._score || 0));
const finalList = candidates.slice(0, lim);
if (dbg.resolve) {
console.log("[catalogRetrieval] candidates", {
query: q,
top: finalList.slice(0, 5).map((c) => ({
id: c.woo_product_id,
name: c.name,
score: c._score,
detail: c._score_detail,
})),
});
}
return { candidates: finalList, audit };
}

177
src/services/mercadoPago.js Normal file
View File

@@ -0,0 +1,177 @@
import crypto from "crypto";
import { upsertMpPayment } from "../db/repo.js";
import { updateOrderStatus } from "./wooOrders.js";
function getAccessToken() {
return process.env.MP_ACCESS_TOKEN || null;
}
function getWebhookSecret() {
return process.env.MP_WEBHOOK_SECRET || null;
}
function normalizeBaseUrl(base) {
if (!base) return null;
return base.endsWith("/") ? base : `${base}/`;
}
function getBaseUrl() {
return normalizeBaseUrl(process.env.MP_BASE_URL || process.env.MP_WEBHOOK_BASE_URL || null);
}
async function fetchMp({ url, method = "GET", body = null }) {
const token = getAccessToken();
if (!token) throw new Error("MP_ACCESS_TOKEN is not set");
const res = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let parsed;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
if (!res.ok) {
const err = new Error(`MP HTTP ${res.status}`);
err.status = res.status;
err.body = parsed;
throw err;
}
return parsed;
}
export async function createPreference({
tenantId,
wooOrderId,
amount,
payer = null,
items = null,
baseUrl = null,
}) {
const root = normalizeBaseUrl(baseUrl || getBaseUrl());
if (!root) throw new Error("MP_BASE_URL is not set");
const notificationUrl = `${root}webhook/mercadopago`;
const backUrls = {
success: `${root}return?status=success`,
failure: `${root}return?status=failure`,
pending: `${root}return?status=pending`,
};
const statementDescriptor = process.env.MP_STATEMENT_DESCRIPTOR || "Whatsapp Store";
const externalReference = `${tenantId}|${wooOrderId}`;
const unitPrice = Number(amount);
if (!Number.isFinite(unitPrice)) throw new Error("invalid_amount");
const payload = {
auto_return: "approved",
back_urls: backUrls,
statement_descriptor: statementDescriptor,
binary_mode: false,
external_reference: externalReference,
items: Array.isArray(items) && items.length
? items
: [
{
id: String(wooOrderId || "order"),
title: "Productos x whatsapp",
quantity: 1,
currency_id: "ARS",
unit_price: unitPrice,
},
],
notification_url: notificationUrl,
...(payer ? { payer } : {}),
};
const data = await fetchMp({
url: "https://api.mercadopago.com/checkout/preferences",
method: "POST",
body: payload,
});
return {
preference_id: data?.id || null,
init_point: data?.init_point || null,
sandbox_init_point: data?.sandbox_init_point || null,
raw: data,
};
}
function parseSignatureHeader(header) {
const h = String(header || "");
const parts = h.split(",");
let ts = null;
let v1 = null;
for (const p of parts) {
const [k, v] = p.split("=");
if (!k || !v) continue;
const key = k.trim();
const val = v.trim();
if (key === "ts") ts = val;
if (key === "v1") v1 = val;
}
return { ts, v1 };
}
export function verifyWebhookSignature({ headers = {}, query = {} }) {
const secret = getWebhookSecret();
if (!secret) return { ok: false, reason: "MP_WEBHOOK_SECRET is not set" };
const xSignature = headers["x-signature"] || headers["X-Signature"] || headers["x-signature"];
const xRequestId = headers["x-request-id"] || headers["X-Request-Id"] || headers["x-request-id"];
const { ts, v1 } = parseSignatureHeader(xSignature);
const dataId = query["data.id"] || query?.data?.id || null;
if (!xRequestId || !ts || !v1 || !dataId) {
return { ok: false, reason: "missing_signature_fields" };
}
const manifest = `id:${String(dataId).toLowerCase()};request-id:${xRequestId};ts:${ts};`;
const hmac = crypto.createHmac("sha256", secret);
hmac.update(manifest);
const hash = hmac.digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(v1));
return ok ? { ok: true } : { ok: false, reason: "invalid_signature" };
}
export async function fetchPayment({ paymentId }) {
if (!paymentId) throw new Error("missing_payment_id");
return await fetchMp({
url: `https://api.mercadopago.com/v1/payments/${encodeURIComponent(paymentId)}`,
method: "GET",
});
}
export function parseExternalReference(externalReference) {
if (!externalReference) return { tenantId: null, wooOrderId: null };
const parts = String(externalReference).split("|").filter(Boolean);
if (parts.length >= 2) {
return { tenantId: parts[0], wooOrderId: Number(parts[1]) || null };
}
return { tenantId: null, wooOrderId: Number(externalReference) || null };
}
export async function reconcilePayment({ tenantId, payment }) {
const status = payment?.status || null;
const paidAt = payment?.date_approved || payment?.date_created || null;
const { tenantId: refTenantId, wooOrderId } = parseExternalReference(payment?.external_reference);
const resolvedTenantId = tenantId || refTenantId;
if (!resolvedTenantId) throw new Error("tenant_id_missing_from_payment");
const saved = await upsertMpPayment({
tenant_id: resolvedTenantId,
woo_order_id: wooOrderId,
preference_id: payment?.order?.id || payment?.preference_id || null,
payment_id: String(payment?.id || ""),
status,
paid_at: paidAt,
raw: payment,
});
if (status === "approved" && wooOrderId) {
await updateOrderStatus({ tenantId: resolvedTenantId, wooOrderId, status: "processing" });
}
return { payment: saved, woo_order_id: wooOrderId, tenant_id: resolvedTenantId };
}

View File

@@ -16,6 +16,10 @@ import { llmExtract, llmPlan } from "./openai.js";
import { searchProducts } from "./wooProducts.js"; import { searchProducts } from "./wooProducts.js";
import { debug as dbg } from "./debug.js"; import { debug as dbg } from "./debug.js";
import { runTurnV2 } from "./turnEngineV2.js"; import { runTurnV2 } from "./turnEngineV2.js";
import { runTurnV3 } from "./turnEngineV3.js";
import { safeNextState } from "./fsm.js";
import { createOrder, updateOrder } from "./wooOrders.js";
import { createPreference } from "./mercadoPago.js";
function nowIso() { function nowIso() {
@@ -384,6 +388,61 @@ function isGreeting(text) {
return false; return false;
} }
async function ensureWooCustomerId({
tenantId,
chat_id,
displayName,
from,
externalCustomerId,
run_id,
}) {
let updatedId = externalCustomerId;
let error = null;
try {
if (updatedId) {
const found = await getWooCustomerById({ tenantId, id: updatedId });
if (!found) {
const phone = chat_id.replace(/@.+$/, "");
const name = displayName || from || phone;
const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name });
if (!created?.id) throw new Error("woo_customer_id_missing");
updatedId = await upsertExternalCustomerMap({
tenant_id: tenantId,
wa_chat_id: chat_id,
external_customer_id: created?.id,
provider: "woo",
});
} else {
updatedId = await upsertExternalCustomerMap({
tenant_id: tenantId,
wa_chat_id: chat_id,
external_customer_id: updatedId,
provider: "woo",
});
}
} else {
const phone = chat_id.replace(/@.+$/, "");
const name = displayName || from || phone;
const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name });
if (!created?.id) throw new Error("woo_customer_id_missing");
updatedId = await upsertExternalCustomerMap({
tenant_id: tenantId,
wa_chat_id: chat_id,
external_customer_id: created?.id,
provider: "woo",
});
}
} catch (e) {
error = {
message: String(e?.message || e),
status: e?.status || e?.cause?.status || null,
code: e?.body?.code || e?.cause?.body?.code || null,
run_id: run_id || null,
};
}
return { external_customer_id: updatedId, error };
}
function classifyIntent(text, prev_context = null) { function classifyIntent(text, prev_context = null) {
// clasificador "rápido" (reglas). Si después querés, lo reemplazamos por un LLM mini. // clasificador "rápido" (reglas). Si después querés, lo reemplazamos por un LLM mini.
const prev = prev_context && typeof prev_context === "object" ? prev_context : {}; const prev = prev_context && typeof prev_context === "object" ? prev_context : {};
@@ -1357,8 +1416,10 @@ export async function processMessage({
mark("after_insertMessage_in"); mark("after_insertMessage_in");
mark("before_classifyIntent"); mark("before_classifyIntent");
const useTurnV2 = String(process.env.TURN_ENGINE || "").toLowerCase() === "v2"; const turnEngine = String(process.env.TURN_ENGINE || "").toLowerCase();
const classification = useTurnV2 ? { kind: "other", intent_hint: "other" } : classifyIntent(text, prev?.context); const useTurnV2 = turnEngine === "v2";
const useTurnV3 = turnEngine === "v3";
const classification = useTurnV2 || useTurnV3 ? { kind: "other", intent_hint: "other" } : classifyIntent(text, prev?.context);
mark("after_classifyIntent"); mark("after_classifyIntent");
logStage(stageDebug, "classifyIntent", classification); logStage(stageDebug, "classifyIntent", classification);
@@ -1378,8 +1439,24 @@ export async function processMessage({
let plan; let plan;
let llmMeta; let llmMeta;
let resolvedBasket = null; let resolvedBasket = null;
let tools = [];
if (useTurnV2) { if (useTurnV3) {
mark("before_turn_v3");
const out = await runTurnV3({
tenantId,
chat_id,
text,
prev_state,
prev_context: reducedContext,
conversation_history: llmInput.conversation_history || [],
});
plan = out.plan;
decision = out.decision || { context_patch: {}, actions: [], audit: {} };
llmMeta = { kind: "nlu_v3", audit: decision.audit || null };
tools = [];
mark("after_turn_v3");
} else if (useTurnV2) {
mark("before_turn_v2"); mark("before_turn_v2");
const out = await runTurnV2({ const out = await runTurnV2({
tenantId, tenantId,
@@ -1469,6 +1546,94 @@ export async function processMessage({
}; };
mark("before_insertRun"); mark("before_insertRun");
// --- Ejecutar acciones determinísticas (solo v3) ---
let actionPatch = {};
if (useTurnV3 && Array.isArray(decision?.actions) && decision.actions.length) {
const newTools = [];
const actions = decision.actions;
const calcOrderTotal = (order) => {
const rawTotal = Number(order?.raw?.total);
if (Number.isFinite(rawTotal) && rawTotal > 0) return rawTotal;
const items = Array.isArray(order?.line_items) ? order.line_items : [];
let sum = 0;
for (const it of items) {
const t = Number(it?.total);
if (Number.isFinite(t)) sum += t;
}
return sum > 0 ? sum : null;
};
// Asegurar Woo customer si vamos a crear orden
const needsWoo = actions.some((a) => a.type === "create_order" || a.type === "update_order");
if (needsWoo) {
const ensured = await ensureWooCustomerId({
tenantId,
chat_id,
displayName,
from,
externalCustomerId,
});
externalCustomerId = ensured.external_customer_id;
if (ensured.error) {
newTools.push({ type: "ensure_woo_customer", ok: false, error: ensured.error });
} else {
newTools.push({ type: "ensure_woo_customer", ok: true, external_customer_id: externalCustomerId });
}
}
for (const act of actions) {
try {
if (act.type === "create_order") {
const order = await createOrder({
tenantId,
wooCustomerId: externalCustomerId,
basket: reducedContext?.order_basket || plan?.basket_resolved || { items: [] },
address: reducedContext?.delivery_address || reducedContext?.address || null,
run_id: null,
});
actionPatch.woo_order_id = order?.id || null;
actionPatch.order_total = calcOrderTotal(order);
newTools.push({ type: "create_order", ok: true, order_id: order?.id || null });
} else if (act.type === "update_order") {
const order = await updateOrder({
tenantId,
wooOrderId: reducedContext?.woo_order_id || prev?.last_order_id || null,
basket: reducedContext?.order_basket || plan?.basket_resolved || { items: [] },
address: reducedContext?.delivery_address || reducedContext?.address || null,
run_id: null,
});
actionPatch.woo_order_id = order?.id || null;
actionPatch.order_total = calcOrderTotal(order);
newTools.push({ type: "update_order", ok: true, order_id: order?.id || null });
} else if (act.type === "send_payment_link") {
const total = Number(actionPatch?.order_total || reducedContext?.order_total || 0) || null;
if (!total || total <= 0) {
throw new Error("order_total_missing");
}
const pref = await createPreference({
tenantId,
wooOrderId: actionPatch.woo_order_id || reducedContext?.woo_order_id || prev?.last_order_id,
amount: total || 0,
});
actionPatch.payment_link = pref?.init_point || null;
actionPatch.mp = {
preference_id: pref?.preference_id || null,
init_point: pref?.init_point || null,
};
newTools.push({ type: "send_payment_link", ok: true, preference_id: pref?.preference_id || null });
if (pref?.init_point) {
plan.reply = `Listo, acá tenés el link de pago: ${pref.init_point}\nAvisame cuando esté listo.`;
}
}
} catch (e) {
newTools.push({ type: act.type, ok: false, error: String(e?.message || e) });
}
}
tools = newTools;
}
const run_id = await insertRun({ const run_id = await insertRun({
tenant_id: tenantId, tenant_id: tenantId,
wa_chat_id: chat_id, wa_chat_id: chat_id,
@@ -1476,7 +1641,7 @@ export async function processMessage({
prev_state, prev_state,
user_text: text, user_text: text,
llm_output: { ...plan, _llm: llmMeta }, llm_output: { ...plan, _llm: llmMeta },
tools: [], tools,
invariants, invariants,
final_reply: plan.reply, final_reply: plan.reply,
status: runStatus, status: runStatus,
@@ -1588,6 +1753,7 @@ export async function processMessage({
const context = { const context = {
...(reducedContext || {}), ...(reducedContext || {}),
...(decision?.context_patch || {}), ...(decision?.context_patch || {}),
...(actionPatch || {}),
missing_fields: plan.missing_fields || [], missing_fields: plan.missing_fields || [],
basket_resolved: (reducedContext?.order_basket?.items?.length ? reducedContext.order_basket : plan.basket_resolved) || { items: [] }, basket_resolved: (reducedContext?.order_basket?.items?.length ? reducedContext.order_basket : plan.basket_resolved) || { items: [] },
external_customer_id: externalCustomerId ?? prev?.context?.external_customer_id ?? null, external_customer_id: externalCustomerId ?? prev?.context?.external_customer_id ?? null,
@@ -1595,10 +1761,15 @@ export async function processMessage({
woo_customer_error: wooCustomerError, woo_customer_error: wooCustomerError,
}; };
const nextState = useTurnV3
? safeNextState(prev_state, context, { requested_checkout: plan.intent === "checkout" }).next_state
: plan.next_state;
if (useTurnV3) plan.next_state = nextState;
const stateRow = await upsertConversationState({ const stateRow = await upsertConversationState({
tenant_id: tenantId, tenant_id: tenantId,
wa_chat_id: chat_id, wa_chat_id: chat_id,
state: plan.next_state, state: nextState,
last_intent: plan.intent, last_intent: plan.intent,
last_order_id: null, last_order_id: null,
context, context,

View File

@@ -0,0 +1,584 @@
import { llmNluV3 } from "./openai.js";
import { retrieveCandidates } from "./catalogRetrieval.js";
import { safeNextState } from "./fsm.js";
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?";
}
function unitDisplay(unit) {
if (unit === "unit") return "unidades";
if (unit === "g") return "gramos";
return "kilos";
}
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(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 normalizeText(s) {
return String(s || "")
.toLowerCase()
.replace(/[¿?¡!.,;:()"]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function scoreTextMatch(query, candidateName) {
const qt = new Set(normalizeText(query).split(" ").filter(Boolean));
const nt = new Set(normalizeText(candidateName).split(" ").filter(Boolean));
let hits = 0;
for (const w of qt) if (nt.has(w)) hits++;
return hits / Math.max(qt.size, 1);
}
function buildPagedOptions({ candidates, candidateOffset = 0, baseIdx = 1, pageSize = 9 }) {
const cands = (candidates || []).filter((c) => c && c.woo_product_id && c.name);
const off = Math.max(0, parseInt(candidateOffset, 10) || 0);
const size = Math.max(1, Math.min(20, parseInt(pageSize, 10) || 9));
const slice = cands.slice(off, off + size);
const options = slice.map((c, i) => ({
idx: baseIdx + i,
type: "product",
woo_product_id: c.woo_product_id,
name: c.name,
}));
const hasMore = off + size < cands.length;
if (hasMore) options.push({ idx: baseIdx + size, type: "more", name: "Mostrame más…" });
const list = options
.map((o) => (o.type === "more" ? `- ${o.idx}) ${o.name}` : `- ${o.idx}) ${o.name}`))
.join("\n");
const question = `¿Cuál de estos querés?\n${list}\n\nRespondé con el número.`;
const pending = {
candidates: cands,
options,
candidate_offset: off,
page_size: size,
base_idx: baseIdx,
has_more: hasMore,
next_candidate_offset: off + size,
next_base_idx: baseIdx + size + (hasMore ? 1 : 0),
};
return { question, pending, options, hasMore };
}
function resolvePendingSelection({ text, nlu, pending }) {
if (!pending?.candidates?.length) return { kind: "none" };
if (isShowMoreRequest(text)) {
const { question, pending: nextPending } = buildPagedOptions({
candidates: pending.candidates,
candidateOffset: pending.next_candidate_offset ?? ((pending.candidate_offset || 0) + (pending.page_size || 9)),
baseIdx: pending.next_base_idx ?? ((pending.base_idx || 1) + (pending.page_size || 9) + 1),
pageSize: pending.page_size || 9,
});
return { kind: "more", question, pending: nextPending };
}
const idx =
(nlu?.entities?.selection?.type === "index" ? parseInt(String(nlu.entities.selection.value), 10) : null) ??
parseIndexSelection(text);
if (idx && Array.isArray(pending.options)) {
const opt = pending.options.find((o) => o.idx === idx);
if (opt?.type === "more") return { kind: "more", question: null, pending };
if (opt?.woo_product_id) {
const chosen = pending.candidates.find((c) => c.woo_product_id === opt.woo_product_id) || null;
if (chosen) return { kind: "chosen", chosen };
}
}
const selText = nlu?.entities?.selection?.type === "text"
? String(nlu.entities.selection.value || "").trim()
: null;
const q = selText || nlu?.entities?.product_query || null;
if (q) {
const scored = pending.candidates
.map((c) => ({ c, s: scoreTextMatch(q, c?.name) }))
.sort((a, b) => b.s - a.s);
if (scored[0]?.s >= 0.6 && (!scored[1] || scored[0].s - scored[1].s >= 0.2)) {
return { kind: "chosen", chosen: scored[0].c };
}
}
return { kind: "ask" };
}
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 resolveQuantity({ quantity, unit, displayUnit }) {
if (quantity == null || !Number.isFinite(Number(quantity)) || Number(quantity) <= 0) return null;
const q = Number(quantity);
const u = normalizeUnit(unit) || (displayUnit === "unit" ? "unit" : displayUnit === "g" ? "g" : "kg");
if (u === "unit") return { quantity: Math.round(q), unit: "unit", display_quantity: Math.round(q), display_unit: "unit" };
if (u === "g") return { quantity: Math.round(q), unit: "g", display_quantity: Math.round(q), display_unit: "g" };
// kg -> gramos enteros
return {
quantity: Math.round(q * 1000),
unit: "g",
display_unit: "kg",
display_quantity: q,
};
}
function buildPendingItemFromCandidate(candidate) {
const displayUnit = inferDefaultUnit({ name: candidate.name, categories: candidate.categories });
return {
product_id: Number(candidate.woo_product_id),
variation_id: null,
name: candidate.name,
price: candidate.price ?? null,
categories: candidate.categories || [],
attributes: candidate.attributes || [],
display_unit: displayUnit,
};
}
function askClarificationReply() {
return "Dale, ¿qué producto querés exactamente?";
}
function shortSummary(history) {
if (!Array.isArray(history)) return "";
return history
.slice(-5)
.map((m) => `${m.role === "user" ? "U" : "A"}:${String(m.content || "").slice(0, 120)}`)
.join(" | ");
}
function hasAddress(ctx) {
return Boolean(ctx?.delivery_address?.text || ctx?.address?.text || ctx?.address_text);
}
export async function runTurnV3({
tenantId,
chat_id,
text,
prev_state,
prev_context,
conversation_history,
tenant_config = {},
} = {}) {
const prev = prev_context && typeof prev_context === "object" ? prev_context : {};
const actions = [];
const context_patch = {};
const audit = {};
const last_shown_options = Array.isArray(prev?.pending_clarification?.options)
? prev.pending_clarification.options.map((o) => ({ idx: o.idx, type: o.type, name: o.name, woo_product_id: o.woo_product_id || null }))
: [];
const nluInput = {
last_user_message: text,
conversation_state: prev_state || "IDLE",
memory_summary: shortSummary(conversation_history),
pending_context: {
pending_clarification: Boolean(prev?.pending_clarification?.candidates?.length),
pending_item: prev?.pending_item?.name || null,
},
last_shown_options,
locale: tenant_config?.locale || "es-AR",
};
const { nlu, raw_text, model, usage, validation } = await llmNluV3({ input: nluInput });
audit.nlu = { raw_text, model, usage, validation, parsed: nlu };
// 1) Resolver pending_clarification primero
if (prev?.pending_clarification?.candidates?.length) {
const resolved = resolvePendingSelection({ text, nlu, pending: prev.pending_clarification });
if (resolved.kind === "more") {
const nextPending = resolved.pending || prev.pending_clarification;
const reply = resolved.question || buildPagedOptions({ candidates: nextPending.candidates }).question;
context_patch.pending_clarification = nextPending;
context_patch.pending_item = null;
actions.push({ type: "show_options", payload: { count: nextPending.options?.length || 0 } });
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { did_show_options: true });
return {
plan: {
reply,
next_state,
intent: "browse",
missing_fields: ["product_selection"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
if (resolved.kind === "chosen" && resolved.chosen) {
const pendingItem = buildPendingItemFromCandidate(resolved.chosen);
const qty = resolveQuantity({
quantity: nlu?.entities?.quantity,
unit: nlu?.entities?.unit,
displayUnit: pendingItem.display_unit,
});
if (qty?.quantity) {
const item = {
product_id: pendingItem.product_id,
variation_id: pendingItem.variation_id,
quantity: qty.quantity,
unit: qty.unit,
label: pendingItem.name,
};
const prevItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : [];
context_patch.order_basket = { items: [...prevItems, item] };
context_patch.pending_item = null;
context_patch.pending_clarification = null;
actions.push({ type: "add_to_cart", payload: item });
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true });
const display = qty.display_unit === "kg"
? `${qty.display_quantity}kg`
: qty.display_unit === "unit"
? `${qty.display_quantity}u`
: `${qty.display_quantity}g`;
return {
plan: {
reply: `Perfecto, anoto ${display} de ${pendingItem.name}. ¿Algo más?`,
next_state,
intent: "add_to_cart",
missing_fields: [],
order_action: "none",
basket_resolved: { items: [item] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
context_patch.pending_item = pendingItem;
context_patch.pending_clarification = null;
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, {});
return {
plan: {
reply: unitAskFor(pendingItem.display_unit === "unit" ? "unit" : "kg"),
next_state,
intent: "add_to_cart",
missing_fields: ["quantity"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
const { question, pending } = buildPagedOptions({ candidates: prev.pending_clarification.candidates });
context_patch.pending_clarification = pending;
actions.push({ type: "show_options", payload: { count: pending.options?.length || 0 } });
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { did_show_options: true });
return {
plan: {
reply: question,
next_state,
intent: "browse",
missing_fields: ["product_selection"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
// 2) Si hay pending_item, esperamos cantidad
if (prev?.pending_item?.product_id) {
const pendingItem = prev.pending_item;
const qty = resolveQuantity({
quantity: nlu?.entities?.quantity,
unit: nlu?.entities?.unit,
displayUnit: pendingItem.display_unit || "kg",
});
if (qty?.quantity) {
const item = {
product_id: Number(pendingItem.product_id),
variation_id: pendingItem.variation_id ?? null,
quantity: qty.quantity,
unit: qty.unit,
label: pendingItem.name || "ese producto",
};
const prevItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : [];
context_patch.order_basket = { items: [...prevItems, item] };
context_patch.pending_item = null;
actions.push({ type: "add_to_cart", payload: item });
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true });
const display = qty.display_unit === "kg"
? `${qty.display_quantity}kg`
: qty.display_unit === "unit"
? `${qty.display_quantity}u`
: `${qty.display_quantity}g`;
return {
plan: {
reply: `Perfecto, anoto ${display} de ${item.label}. ¿Algo más?`,
next_state,
intent: "add_to_cart",
missing_fields: [],
order_action: "none",
basket_resolved: { items: [item] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
return {
plan: {
reply: unitAskFor(pendingItem.display_unit === "unit" ? "unit" : "kg"),
next_state,
intent: "add_to_cart",
missing_fields: ["quantity"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
// 3) Intento normal
const intent = nlu?.intent || "other";
const productQuery = String(nlu?.entities?.product_query || "").trim();
const needsCatalog = Boolean(nlu?.needs?.catalog_lookup);
if (intent === "greeting") {
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
return {
plan: {
reply: "¡Hola! ¿Qué te gustaría pedir hoy?",
next_state,
intent: "greeting",
missing_fields: [],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
if (intent === "checkout") {
const basketItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : [];
if (!basketItems.length) {
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
return {
plan: {
reply: "Para avanzar necesito al menos un producto. ¿Qué querés pedir?",
next_state,
intent: "checkout",
missing_fields: ["basket_items"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
if (!hasAddress(prev)) {
actions.push({ type: "ask_address", payload: {} });
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, { requested_checkout: true });
return {
plan: {
reply: "Perfecto. ¿Me pasás la dirección de entrega?",
next_state,
intent: "checkout",
missing_fields: ["address"],
order_action: "checkout",
basket_resolved: { items: basketItems },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
actions.push({ type: "create_order", payload: {} });
actions.push({ type: "send_payment_link", payload: {} });
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, { requested_checkout: true });
return {
plan: {
reply: "Genial, ya genero el link de pago y te lo paso.",
next_state,
intent: "checkout",
missing_fields: [],
order_action: "checkout",
basket_resolved: { items: basketItems },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
if (needsCatalog && !productQuery) {
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
return {
plan: {
reply: askClarificationReply(),
next_state,
intent,
missing_fields: ["product_query"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
if (needsCatalog) {
const { candidates, audit: catAudit } = await retrieveCandidates({
tenantId,
query: productQuery,
attributes: nlu?.entities?.attributes || [],
preparation: nlu?.entities?.preparation || [],
limit: 12,
});
audit.catalog = catAudit;
if (!candidates.length) {
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
return {
plan: {
reply: `No encontré "${productQuery}" en el catálogo. ¿Podés decirme el nombre exacto o un corte similar?`,
next_state,
intent,
missing_fields: ["product_query"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
const best = candidates[0];
const second = candidates[1];
const strong = candidates.length === 1 || (best?._score >= 0.9 && (!second || best._score - (second?._score || 0) >= 0.2));
if (!strong) {
const { question, pending } = buildPagedOptions({ candidates });
context_patch.pending_clarification = pending;
context_patch.pending_item = null;
actions.push({ type: "show_options", payload: { count: pending.options?.length || 0 } });
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { did_show_options: true });
return {
plan: {
reply: question,
next_state,
intent: "browse",
missing_fields: ["product_selection"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
const pendingItem = buildPendingItemFromCandidate(best);
const qty = resolveQuantity({
quantity: nlu?.entities?.quantity,
unit: nlu?.entities?.unit,
displayUnit: pendingItem.display_unit,
});
if (intent === "price_query") {
context_patch.pending_item = pendingItem;
const price = best.price != null ? `está $${best.price} ${pendingItem.display_unit === "unit" ? "por unidad" : "el kilo"}` : "no tengo el precio confirmado ahora";
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, {});
return {
plan: {
reply: `${best.name} ${price}. ${unitAskFor(pendingItem.display_unit === "unit" ? "unit" : "kg")}`,
next_state,
intent: "price_query",
missing_fields: ["quantity"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
if (intent === "add_to_cart" && qty?.quantity) {
const item = {
product_id: pendingItem.product_id,
variation_id: pendingItem.variation_id,
quantity: qty.quantity,
unit: qty.unit,
label: pendingItem.name,
};
const prevItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : [];
context_patch.order_basket = { items: [...prevItems, item] };
actions.push({ type: "add_to_cart", payload: item });
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true });
const display = qty.display_unit === "kg"
? `${qty.display_quantity}kg`
: qty.display_unit === "unit"
? `${qty.display_quantity}u`
: `${qty.display_quantity}g`;
return {
plan: {
reply: `Perfecto, anoto ${display} de ${pendingItem.name}. ¿Algo más?`,
next_state,
intent: "add_to_cart",
missing_fields: [],
order_action: "none",
basket_resolved: { items: [item] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
context_patch.pending_item = pendingItem;
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, {});
return {
plan: {
reply: unitAskFor(pendingItem.display_unit === "unit" ? "unit" : "kg"),
next_state,
intent: "add_to_cart",
missing_fields: ["quantity"],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
// Fallback seguro
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
return {
plan: {
reply: "Dale, ¿qué necesitás exactamente?",
next_state,
intent: "other",
missing_fields: [],
order_action: "none",
basket_resolved: { items: [] },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}

236
src/services/wooOrders.js Normal file
View File

@@ -0,0 +1,236 @@
import { getDecryptedTenantEcommerceConfig, getWooProductCacheById } from "../db/repo.js";
import { debug as dbg } from "./debug.js";
// --- Simple in-memory lock to serialize work per key ---
const locks = new Map();
async function withLock(key, fn) {
const prev = locks.get(key) || Promise.resolve();
let release;
const next = new Promise((r) => (release = r));
locks.set(key, prev.then(() => next));
await prev;
try {
return await fn();
} finally {
release();
if (locks.get(key) === next) locks.delete(key);
}
}
async function fetchWoo({ url, method = "GET", body = null, timeout = 20000, headers = {} }) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error("timeout")), timeout);
const t0 = Date.now();
try {
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
...headers,
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
if (dbg.wooHttp) console.log("[wooOrders] http", method, res.status, Date.now() - t0, "ms");
const text = await res.text();
let parsed;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
if (!res.ok) {
const err = new Error(`Woo HTTP ${res.status}`);
err.status = res.status;
err.body = parsed;
err.url = url;
err.method = method;
throw err;
}
return parsed;
} catch (e) {
const err = new Error(`Woo request failed after ${Date.now() - t0}ms: ${e.message}`);
err.cause = e;
err.status = e?.status || null;
err.body = e?.body || null;
err.url = url;
err.method = method;
throw err;
} finally {
clearTimeout(timer);
}
}
async function getWooClient({ tenantId }) {
const encryptionKey = process.env.APP_ENCRYPTION_KEY;
if (!encryptionKey) throw new Error("APP_ENCRYPTION_KEY is required to decrypt Woo credentials");
const cfg = await getDecryptedTenantEcommerceConfig({
tenant_id: tenantId,
provider: "woo",
encryption_key: encryptionKey,
});
if (!cfg) throw new Error("Woo config not found for tenant");
const consumerKey =
cfg.consumer_key ||
process.env.WOO_CONSUMER_KEY ||
(() => {
throw new Error("consumer_key not set");
})();
const consumerSecret =
cfg.consumer_secret ||
process.env.WOO_CONSUMER_SECRET ||
(() => {
throw new Error("consumer_secret not set");
})();
const base = cfg.base_url.replace(/\/+$/, "");
const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64");
return {
base,
authHeader: { Authorization: `Basic ${auth}` },
timeout: Math.max(cfg.timeout_ms ?? 20000, 20000),
};
}
function parsePrice(p) {
if (p == null) return null;
const n = Number(String(p).replace(",", "."));
return Number.isFinite(n) ? n : null;
}
async function getWooProductPrice({ tenantId, productId }) {
if (!productId) return null;
const cached = await getWooProductCacheById({ tenant_id: tenantId, woo_product_id: productId });
if (cached?.price != null) return Number(cached.price);
const client = await getWooClient({ tenantId });
const url = `${client.base}/products/${encodeURIComponent(productId)}`;
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
return parsePrice(data?.price ?? data?.regular_price ?? data?.sale_price);
}
function normalizeBasketItems(basket) {
const items = Array.isArray(basket?.items) ? basket.items : [];
return items.filter((it) => it && it.product_id && it.quantity && it.unit);
}
function toMoney(value) {
const n = Number(value);
if (!Number.isFinite(n)) return null;
return (Math.round(n * 100) / 100).toFixed(2);
}
async function buildLineItems({ tenantId, basket }) {
const items = normalizeBasketItems(basket);
const lineItems = [];
for (const it of items) {
const productId = Number(it.product_id);
const unit = String(it.unit);
const qty = Number(it.quantity);
if (!productId || !Number.isFinite(qty) || qty <= 0) continue;
const pricePerKg = await getWooProductPrice({ tenantId, productId });
if (unit === "unit") {
const total = pricePerKg != null ? toMoney(pricePerKg * qty) : null;
lineItems.push({
product_id: productId,
variation_id: it.variation_id ?? null,
quantity: Math.round(qty),
...(total ? { subtotal: total, total } : {}),
meta_data: [
{ key: "unit", value: "unit" },
],
});
continue;
}
// Carne por gramos (enteros): quantity=1 y total calculado en base a ARS/kg
const grams = Math.round(qty);
const kilos = grams / 1000;
const total = pricePerKg != null ? toMoney(pricePerKg * kilos) : null;
lineItems.push({
product_id: productId,
variation_id: it.variation_id ?? null,
quantity: 1,
...(total ? { subtotal: total, total } : {}),
meta_data: [
{ key: "unit", value: "g" },
{ key: "weight_g", value: grams },
{ key: "unit_price_per_kg", value: pricePerKg },
],
});
}
return lineItems;
}
function mapAddress(address) {
if (!address || typeof address !== "object") return null;
return {
first_name: address.first_name || "",
last_name: address.last_name || "",
address_1: address.address_1 || address.text || "",
address_2: address.address_2 || "",
city: address.city || "",
state: address.state || "",
postcode: address.postcode || "",
country: address.country || "AR",
phone: address.phone || "",
email: address.email || "",
};
}
export async function createOrder({ tenantId, wooCustomerId, basket, address, run_id }) {
const lockKey = `${tenantId}:${wooCustomerId || "anon"}`;
return withLock(lockKey, async () => {
const client = await getWooClient({ tenantId });
const lineItems = await buildLineItems({ tenantId, basket });
if (!lineItems.length) throw new Error("order_empty_basket");
const addr = mapAddress(address);
const payload = {
status: "pending",
customer_id: wooCustomerId || undefined,
line_items: lineItems,
...(addr ? { billing: addr, shipping: addr } : {}),
meta_data: [
{ key: "source", value: "whatsapp" },
...(run_id ? [{ key: "run_id", value: run_id }] : []),
],
};
const url = `${client.base}/orders`;
const data = await fetchWoo({ url, method: "POST", body: payload, timeout: client.timeout, headers: client.authHeader });
return { id: data?.id || null, raw: data, line_items: lineItems };
});
}
export async function updateOrder({ tenantId, wooOrderId, basket, address, run_id }) {
if (!wooOrderId) throw new Error("missing_woo_order_id");
const lockKey = `${tenantId}:order:${wooOrderId}`;
return withLock(lockKey, async () => {
const client = await getWooClient({ tenantId });
const lineItems = await buildLineItems({ tenantId, basket });
if (!lineItems.length) throw new Error("order_empty_basket");
const addr = mapAddress(address);
const payload = {
line_items: lineItems,
...(addr ? { billing: addr, shipping: addr } : {}),
meta_data: [
{ key: "source", value: "whatsapp" },
...(run_id ? [{ key: "run_id", value: run_id }] : []),
],
};
const url = `${client.base}/orders/${encodeURIComponent(wooOrderId)}`;
const data = await fetchWoo({ url, method: "PUT", body: payload, timeout: client.timeout, headers: client.authHeader });
return { id: data?.id || wooOrderId, raw: data, line_items: lineItems };
});
}
export async function updateOrderStatus({ tenantId, wooOrderId, status }) {
if (!wooOrderId) throw new Error("missing_woo_order_id");
const lockKey = `${tenantId}:order:${wooOrderId}:status`;
return withLock(lockKey, async () => {
const client = await getWooClient({ tenantId });
const payload = { status };
const url = `${client.base}/orders/${encodeURIComponent(wooOrderId)}`;
const data = await fetchWoo({ url, method: "PUT", body: payload, timeout: client.timeout, headers: client.authHeader });
return { id: data?.id || wooOrderId, raw: data };
});
}