diff --git a/db/migrations/20260114130000_product_aliases.sql b/db/migrations/20260114130000_product_aliases.sql new file mode 100644 index 0000000..4a6d892 --- /dev/null +++ b/db/migrations/20260114130000_product_aliases.sql @@ -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; diff --git a/db/migrations/20260114130100_product_embeddings_cache.sql b/db/migrations/20260114130100_product_embeddings_cache.sql new file mode 100644 index 0000000..2d1f6f4 --- /dev/null +++ b/db/migrations/20260114130100_product_embeddings_cache.sql @@ -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; diff --git a/db/migrations/20260114130200_mp_payments.sql b/db/migrations/20260114130200_mp_payments.sql new file mode 100644 index 0000000..2d139c8 --- /dev/null +++ b/db/migrations/20260114130200_mp_payments.sql @@ -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; diff --git a/db/migrations/20260114131000_product_aliases_seed_ar.sql b/db/migrations/20260114131000_product_aliases_seed_ar.sql new file mode 100644 index 0000000..9cea8eb --- /dev/null +++ b/db/migrations/20260114131000_product_aliases_seed_ar.sql @@ -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'; diff --git a/src/app.js b/src/app.js index b44b9e4..5e4538e 100644 --- a/src/app.js +++ b/src/app.js @@ -5,6 +5,7 @@ import { fileURLToPath } from "url"; import { createSimulatorRouter } from "./routes/simulator.js"; import { createEvolutionRouter } from "./routes/evolution.js"; +import { createMercadoPagoRouter } from "./routes/mercadoPago.js"; export function createApp({ tenantId }) { const app = express(); @@ -21,6 +22,7 @@ export function createApp({ tenantId }) { // --- Integraciones / UI --- app.use(createSimulatorRouter({ tenantId })); app.use(createEvolutionRouter()); + app.use("/payments/meli", createMercadoPagoRouter()); // Home (UI) app.get("/", (req, res) => { diff --git a/src/controllers/mercadoPago.js b/src/controllers/mercadoPago.js new file mode 100644 index 0000000..40e19b9 --- /dev/null +++ b/src/controllers/mercadoPago.js @@ -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}`); + }; +} diff --git a/src/db/repo.js b/src/db/repo.js index 5503e3c..68f0dac 100644 --- a/src/db/repo.js +++ b/src/db/repo.js @@ -619,4 +619,121 @@ export async function getWooProductCacheById({ tenant_id, woo_product_id }) { payload: r.payload, 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; } \ No newline at end of file diff --git a/src/routes/mercadoPago.js b/src/routes/mercadoPago.js new file mode 100644 index 0000000..b73e7d6 --- /dev/null +++ b/src/routes/mercadoPago.js @@ -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; +} diff --git a/src/services/catalogRetrieval.js b/src/services/catalogRetrieval.js new file mode 100644 index 0000000..ca6b1b5 --- /dev/null +++ b/src/services/catalogRetrieval.js @@ -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 }; +} diff --git a/src/services/mercadoPago.js b/src/services/mercadoPago.js new file mode 100644 index 0000000..704181c --- /dev/null +++ b/src/services/mercadoPago.js @@ -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 }; +} diff --git a/src/services/pipeline.js b/src/services/pipeline.js index c918e0a..238cb4b 100644 --- a/src/services/pipeline.js +++ b/src/services/pipeline.js @@ -16,6 +16,10 @@ import { llmExtract, llmPlan } from "./openai.js"; import { searchProducts } from "./wooProducts.js"; import { debug as dbg } from "./debug.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() { @@ -384,6 +388,61 @@ function isGreeting(text) { 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) { // 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 : {}; @@ -1357,8 +1416,10 @@ export async function processMessage({ mark("after_insertMessage_in"); mark("before_classifyIntent"); - const useTurnV2 = String(process.env.TURN_ENGINE || "").toLowerCase() === "v2"; - const classification = useTurnV2 ? { kind: "other", intent_hint: "other" } : classifyIntent(text, prev?.context); + const turnEngine = String(process.env.TURN_ENGINE || "").toLowerCase(); + const useTurnV2 = turnEngine === "v2"; + const useTurnV3 = turnEngine === "v3"; + const classification = useTurnV2 || useTurnV3 ? { kind: "other", intent_hint: "other" } : classifyIntent(text, prev?.context); mark("after_classifyIntent"); logStage(stageDebug, "classifyIntent", classification); @@ -1378,8 +1439,24 @@ export async function processMessage({ let plan; let llmMeta; 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"); const out = await runTurnV2({ tenantId, @@ -1469,6 +1546,94 @@ export async function processMessage({ }; 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({ tenant_id: tenantId, wa_chat_id: chat_id, @@ -1476,7 +1641,7 @@ export async function processMessage({ prev_state, user_text: text, llm_output: { ...plan, _llm: llmMeta }, - tools: [], + tools, invariants, final_reply: plan.reply, status: runStatus, @@ -1588,6 +1753,7 @@ export async function processMessage({ const context = { ...(reducedContext || {}), ...(decision?.context_patch || {}), + ...(actionPatch || {}), missing_fields: plan.missing_fields || [], basket_resolved: (reducedContext?.order_basket?.items?.length ? reducedContext.order_basket : plan.basket_resolved) || { items: [] }, external_customer_id: externalCustomerId ?? prev?.context?.external_customer_id ?? null, @@ -1595,10 +1761,15 @@ export async function processMessage({ 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({ tenant_id: tenantId, wa_chat_id: chat_id, - state: plan.next_state, + state: nextState, last_intent: plan.intent, last_order_id: null, context, diff --git a/src/services/turnEngineV3.js b/src/services/turnEngineV3.js new file mode 100644 index 0000000..3b6cbb1 --- /dev/null +++ b/src/services/turnEngineV3.js @@ -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 } }, + }; +} diff --git a/src/services/wooOrders.js b/src/services/wooOrders.js new file mode 100644 index 0000000..849c981 --- /dev/null +++ b/src/services/wooOrders.js @@ -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 }; + }); +}