separated in modules
This commit is contained in:
4
TODO.md
4
TODO.md
@@ -1,8 +1,8 @@
|
||||
# TODOs
|
||||
|
||||
- Integrar WooCommerce real en `src/services/woo.js` (reemplazar stub `createWooCustomer` con llamadas a la API y manejo de errores; usar creds/config desde env).
|
||||
- Integrar WooCommerce real en `src/modules/2-identity/services/woo.js` (reemplazar stub `createWooCustomer` con llamadas a la API y manejo de errores; usar creds/config desde env).
|
||||
- Pipeline: cuando Woo devuelva el cliente real, mantener/actualizar el mapping en `wa_identity_map` vía `upsertWooCustomerMap`.
|
||||
- Conectar con OpenAI en `src/services/pipeline.js` usando `llmInput` y validar el output con esquema (Zod) antes de guardar el run. (Hecho) — env: `OPENAI_API_KEY`, opcional `OPENAI_MODEL`.
|
||||
- Conectar con OpenAI en `src/modules/2-identity/services/pipeline.js` usando `llmInput` y validar el output con esquema (Zod) antes de guardar el run. (Hecho) — env: `OPENAI_API_KEY`, opcional `OPENAI_MODEL`.
|
||||
- (Opcional) Endpoint interno para forzar/upsert de mapping Woo ↔ wa_chat_id, reutilizando repo/woo service.
|
||||
- Revisar manejo de multi-tenant en simulador/UI (instance/tenant_key) y asegurar consistencia en `resolveTenantId`/webhooks.
|
||||
- Enterprise: mover credenciales de Woo (u otras tiendas) a secret manager (Vault/AWS SM/etc.), solo referenciarlas desde DB por clave/ID; auditar acceso a secretos y mapping; soportar rotación de keys.
|
||||
|
||||
54
db/migrations/20260114132000_woo_products_snapshot.sql
Normal file
54
db/migrations/20260114132000_woo_products_snapshot.sql
Normal file
@@ -0,0 +1,54 @@
|
||||
-- migrate:up
|
||||
create table if not exists woo_snapshot_runs (
|
||||
id bigserial primary key,
|
||||
tenant_id uuid not null references tenants(id) on delete cascade,
|
||||
source text not null, -- csv | api | webhook
|
||||
total_items integer not null default 0,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists woo_products_snapshot (
|
||||
tenant_id uuid not null references tenants(id) on delete cascade,
|
||||
woo_id integer not null,
|
||||
type text not null, -- simple | variable | variation
|
||||
parent_id integer null,
|
||||
name text not null,
|
||||
slug text null,
|
||||
status text null,
|
||||
catalog_visibility text null,
|
||||
price_regular numeric(12,2) null,
|
||||
price_sale numeric(12,2) null,
|
||||
price_current numeric(12,2) null,
|
||||
stock_status text null,
|
||||
stock_qty integer null,
|
||||
backorders text null,
|
||||
categories jsonb not null default '[]'::jsonb,
|
||||
tags jsonb not null default '[]'::jsonb,
|
||||
attributes_normalized jsonb not null default '{}'::jsonb,
|
||||
date_modified timestamptz null,
|
||||
run_id bigint null references woo_snapshot_runs(id) on delete set null,
|
||||
raw jsonb not null default '{}'::jsonb,
|
||||
updated_at timestamptz not null default now(),
|
||||
primary key (tenant_id, woo_id)
|
||||
);
|
||||
|
||||
create index if not exists woo_snapshot_tenant_type_idx
|
||||
on woo_products_snapshot (tenant_id, type);
|
||||
|
||||
create index if not exists woo_snapshot_tenant_parent_idx
|
||||
on woo_products_snapshot (tenant_id, parent_id);
|
||||
|
||||
create index if not exists woo_snapshot_tenant_status_idx
|
||||
on woo_products_snapshot (tenant_id, status);
|
||||
|
||||
create or replace view sellable_items as
|
||||
select *
|
||||
from woo_products_snapshot
|
||||
where lower(type) in ('simple', 'variation')
|
||||
and coalesce(lower(status), 'publish') = 'publish'
|
||||
and coalesce(lower(catalog_visibility), 'visible') not in ('hidden');
|
||||
|
||||
-- migrate:down
|
||||
drop view if exists sellable_items;
|
||||
drop table if exists woo_products_snapshot;
|
||||
drop table if exists woo_snapshot_runs;
|
||||
17
db/migrations/20260115123000_drop_woo_products_cache.sql
Normal file
17
db/migrations/20260115123000_drop_woo_products_cache.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- migrate:up
|
||||
drop table if exists woo_products_cache;
|
||||
|
||||
-- migrate:down
|
||||
create table if not exists woo_products_cache (
|
||||
tenant_id uuid not null references tenants(id) on delete cascade,
|
||||
woo_product_id integer not null,
|
||||
name text not null,
|
||||
sku text,
|
||||
price numeric(12,2),
|
||||
currency text,
|
||||
refreshed_at timestamptz not null default now(),
|
||||
payload jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
primary key (tenant_id, woo_product_id)
|
||||
);
|
||||
2
index.js
2
index.js
@@ -1,5 +1,5 @@
|
||||
import "dotenv/config";
|
||||
import { ensureTenant } from "./src/db/repo.js";
|
||||
import { ensureTenant } from "./src/modules/2-identity/db/repo.js";
|
||||
import { createApp } from "./src/app.js";
|
||||
|
||||
async function configureUndiciDispatcher() {
|
||||
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parse": "^6.1.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.19.2",
|
||||
"openai": "^6.15.0",
|
||||
@@ -357,6 +358,12 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/csv-parse": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.1.0.tgz",
|
||||
"integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dbmate": {
|
||||
"version": "2.28.0",
|
||||
"resolved": "https://registry.npmjs.org/dbmate/-/dbmate-2.28.0.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parse": "^6.1.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.19.2",
|
||||
"openai": "^6.15.0",
|
||||
|
||||
222
scripts/import-woo-snapshot.mjs
Normal file
222
scripts/import-woo-snapshot.mjs
Normal file
@@ -0,0 +1,222 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { pool } from "../src/modules/2-identity/db/pool.js";
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const out = { file: null, tenantKey: null, replace: true };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === "--file") out.file = args[++i];
|
||||
else if (a === "--tenant-key") out.tenantKey = args[++i];
|
||||
else if (a === "--no-replace") out.replace = false;
|
||||
}
|
||||
if (!out.file) {
|
||||
throw new Error("Usage: node scripts/import-woo-snapshot.mjs --file <path> [--tenant-key <key>] [--no-replace]");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseNumber(val) {
|
||||
if (val == null) return null;
|
||||
const s = String(val).replace(/\./g, "").replace(",", ".").trim();
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function parseBool(val) {
|
||||
if (val == null) return null;
|
||||
const s = String(val).trim().toLowerCase();
|
||||
if (s === "1" || s === "si" || s === "sí" || s === "yes" || s === "true") return true;
|
||||
if (s === "0" || s === "no" || s === "false") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function splitList(val) {
|
||||
if (!val) return [];
|
||||
return String(val)
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function extractAttributes(row) {
|
||||
const attrs = {};
|
||||
for (const key of Object.keys(row)) {
|
||||
const m = /^Nombre del atributo (\d+)$/i.exec(key);
|
||||
if (!m) continue;
|
||||
const idx = m[1];
|
||||
const name = String(row[key] || "").trim();
|
||||
if (!name) continue;
|
||||
const valuesKey = `Valor(es) del atributo ${idx}`;
|
||||
const rawValues = row[valuesKey];
|
||||
const values = String(rawValues || "")
|
||||
.split("|")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
attrs[name.toLowerCase()] = values.length ? values : [String(rawValues || "").trim()].filter(Boolean);
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function normalizeRow(row) {
|
||||
const wooId = Number(row["ID"] || row["Id"] || row["id"] || null);
|
||||
const type = String(row["Tipo"] || "").trim().toLowerCase();
|
||||
const parentId = Number(row["Superior"] || null) || null;
|
||||
const name = String(row["Nombre"] || "").trim();
|
||||
const slug = String(row["Slug"] || row["slug"] || "").trim() || null;
|
||||
const published = parseBool(row["Publicado"]);
|
||||
const status = published === true ? "publish" : published === false ? "draft" : null;
|
||||
const visibility = String(row["Visibilidad en el catálogo"] || "").trim() || null;
|
||||
const priceRegular = parseNumber(row["Precio normal"]);
|
||||
const priceSale = parseNumber(row["Precio rebajado"]);
|
||||
const priceCurrent = priceSale != null ? priceSale : priceRegular;
|
||||
const hasStock = parseBool(row["¿Existencias?"]);
|
||||
const stockQty = parseNumber(row["Inventario"]);
|
||||
const backorders = String(row["¿Permitir reservas de productos agotados?"] || "").trim().toLowerCase() || null;
|
||||
const weightKg = parseNumber(row["Peso (kg)"]);
|
||||
const categories = splitList(row["Categorías"]);
|
||||
const tags = splitList(row["Etiquetas"]);
|
||||
const attributes = extractAttributes(row);
|
||||
if (weightKg != null) attributes["peso_kg"] = [String(weightKg)];
|
||||
|
||||
let stockStatus = null;
|
||||
if (hasStock === true) {
|
||||
if (stockQty == null || stockQty > 0) stockStatus = "instock";
|
||||
else stockStatus = "outofstock";
|
||||
} else if (hasStock === false) {
|
||||
stockStatus = "outofstock";
|
||||
}
|
||||
|
||||
return {
|
||||
woo_id: wooId,
|
||||
type,
|
||||
parent_id: parentId,
|
||||
name,
|
||||
slug,
|
||||
status,
|
||||
catalog_visibility: visibility,
|
||||
price_regular: priceRegular,
|
||||
price_sale: priceSale,
|
||||
price_current: priceCurrent,
|
||||
stock_status: stockStatus,
|
||||
stock_qty: stockQty == null ? null : Math.round(stockQty),
|
||||
backorders,
|
||||
categories,
|
||||
tags,
|
||||
attributes_normalized: attributes,
|
||||
};
|
||||
}
|
||||
|
||||
async function getTenants(tenantKey) {
|
||||
if (tenantKey) {
|
||||
const { rows } = await pool.query(`select id, key from tenants where key=$1`, [tenantKey]);
|
||||
return rows;
|
||||
}
|
||||
const { rows } = await pool.query(`select id, key from tenants`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function insertRun({ tenantId, total, source }) {
|
||||
const { rows } = await pool.query(
|
||||
`insert into woo_snapshot_runs (tenant_id, source, total_items) values ($1, $2, $3) returning id`,
|
||||
[tenantId, source, total]
|
||||
);
|
||||
return rows[0]?.id || null;
|
||||
}
|
||||
|
||||
async function upsertSnapshotItem({ tenantId, runId, item, raw }) {
|
||||
const q = `
|
||||
insert into woo_products_snapshot
|
||||
(tenant_id, woo_id, type, parent_id, name, slug, status, catalog_visibility,
|
||||
price_regular, price_sale, price_current, stock_status, stock_qty, backorders,
|
||||
categories, tags, attributes_normalized, date_modified, run_id, raw, updated_at)
|
||||
values
|
||||
($1,$2,$3,$4,$5,$6,$7,$8,
|
||||
$9,$10,$11,$12,$13,$14,
|
||||
$15::jsonb,$16::jsonb,$17::jsonb,$18,$19,$20::jsonb,now())
|
||||
on conflict (tenant_id, woo_id)
|
||||
do update set
|
||||
type = excluded.type,
|
||||
parent_id = excluded.parent_id,
|
||||
name = excluded.name,
|
||||
slug = excluded.slug,
|
||||
status = excluded.status,
|
||||
catalog_visibility = excluded.catalog_visibility,
|
||||
price_regular = excluded.price_regular,
|
||||
price_sale = excluded.price_sale,
|
||||
price_current = excluded.price_current,
|
||||
stock_status = excluded.stock_status,
|
||||
stock_qty = excluded.stock_qty,
|
||||
backorders = excluded.backorders,
|
||||
categories = excluded.categories,
|
||||
tags = excluded.tags,
|
||||
attributes_normalized = excluded.attributes_normalized,
|
||||
date_modified = excluded.date_modified,
|
||||
run_id = excluded.run_id,
|
||||
raw = excluded.raw,
|
||||
updated_at = now()
|
||||
`;
|
||||
await pool.query(q, [
|
||||
tenantId,
|
||||
item.woo_id,
|
||||
item.type,
|
||||
item.parent_id,
|
||||
item.name,
|
||||
item.slug,
|
||||
item.status,
|
||||
item.catalog_visibility,
|
||||
item.price_regular,
|
||||
item.price_sale,
|
||||
item.price_current,
|
||||
item.stock_status,
|
||||
item.stock_qty,
|
||||
item.backorders,
|
||||
JSON.stringify(item.categories ?? []),
|
||||
JSON.stringify(item.tags ?? []),
|
||||
JSON.stringify(item.attributes_normalized ?? {}),
|
||||
null,
|
||||
runId,
|
||||
JSON.stringify(raw ?? {}),
|
||||
]);
|
||||
}
|
||||
|
||||
async function deleteMissing({ tenantId, runId }) {
|
||||
await pool.query(
|
||||
`delete from woo_products_snapshot where tenant_id=$1 and coalesce(run_id,0) <> $2`,
|
||||
[tenantId, runId]
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { file, tenantKey, replace } = parseArgs();
|
||||
const abs = path.resolve(file);
|
||||
const content = fs.readFileSync(abs);
|
||||
const records = parse(content, { columns: true, skip_empty_lines: true });
|
||||
const normalized = records.map((r) => ({ item: normalizeRow(r), raw: r })).filter((r) => r.item.woo_id && r.item.name);
|
||||
|
||||
const tenants = await getTenants(tenantKey);
|
||||
if (!tenants.length) {
|
||||
throw new Error("No tenants found for import");
|
||||
}
|
||||
|
||||
for (const t of tenants) {
|
||||
const runId = await insertRun({ tenantId: t.id, total: normalized.length, source: "csv" });
|
||||
for (const row of normalized) {
|
||||
await upsertSnapshotItem({ tenantId: t.id, runId, item: row.item, raw: row.raw });
|
||||
}
|
||||
if (replace && runId) {
|
||||
await deleteMissing({ tenantId: t.id, runId });
|
||||
}
|
||||
console.log(`[import] tenant=${t.key} items=${normalized.length} run_id=${runId}`);
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
10
src/app.js
10
src/app.js
@@ -3,15 +3,16 @@ import cors from "cors";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
import { createSimulatorRouter } from "./routes/simulator.js";
|
||||
import { createEvolutionRouter } from "./routes/evolution.js";
|
||||
import { createMercadoPagoRouter } from "./routes/mercadoPago.js";
|
||||
import { createSimulatorRouter } from "./modules/1-intake/routes/simulator.js";
|
||||
import { createEvolutionRouter } from "./modules/1-intake/routes/evolution.js";
|
||||
import { createMercadoPagoRouter } from "./modules/6-mercadopago/routes/mercadoPago.js";
|
||||
import { createWooWebhooksRouter } from "./modules/2-identity/routes/wooWebhooks.js";
|
||||
|
||||
export function createApp({ tenantId }) {
|
||||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
// Serve /public as static (UI + webcomponents)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -23,6 +24,7 @@ export function createApp({ tenantId }) {
|
||||
app.use(createSimulatorRouter({ tenantId }));
|
||||
app.use(createEvolutionRouter());
|
||||
app.use("/payments/meli", createMercadoPagoRouter());
|
||||
app.use(createWooWebhooksRouter());
|
||||
|
||||
// Home (UI)
|
||||
app.get("/", (req, res) => {
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
getIdentityMapByChat,
|
||||
getLastInboundMessage,
|
||||
listUsers,
|
||||
} from "../db/repo.js";
|
||||
import { deleteWooCustomer } from "../services/woo.js";
|
||||
import { processMessage } from "../services/pipeline.js";
|
||||
} from "../modules/2-identity/db/repo.js";
|
||||
import { deleteWooCustomer } from "../modules/2-identity/services/woo.js";
|
||||
import { processMessage } from "../modules/2-identity/services/pipeline.js";
|
||||
|
||||
export async function handleDeleteConversation({ tenantId, chat_id }) {
|
||||
if (!chat_id) return { ok: false, error: "chat_id_required" };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getConversationState } from "../db/repo.js";
|
||||
import { getConversationState } from "../modules/2-identity/db/repo.js";
|
||||
|
||||
export async function handleGetConversationState({ tenantId, chat_id }) {
|
||||
if (!chat_id) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { listConversations } from "../db/repo.js";
|
||||
import { listConversations } from "../modules/2-identity/db/repo.js";
|
||||
|
||||
export async function handleListConversations({ tenantId, query }) {
|
||||
const { q = "", status = "", state = "", limit = "50" } = query || {};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { listMessages } from "../db/repo.js";
|
||||
import { listMessages } from "../modules/2-identity/db/repo.js";
|
||||
|
||||
export async function handleListMessages({ tenantId, chat_id, limit = "200" }) {
|
||||
if (!chat_id) return [];
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { searchProducts } from "../services/wooProducts.js";
|
||||
import { searchSnapshotItems } from "../modules/shared/wooSnapshot.js";
|
||||
|
||||
export async function handleSearchProducts({ tenantId, q = "", limit = "10", forceWoo = "0" }) {
|
||||
const { items, source } = await searchProducts({
|
||||
const { items, source } = await searchSnapshotItems({
|
||||
tenantId,
|
||||
q,
|
||||
limit: parseInt(limit, 10) || 10,
|
||||
forceWoo: String(forceWoo) === "1" || String(forceWoo).toLowerCase() === "true",
|
||||
});
|
||||
return { items, source };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { listRuns, getRunById } from "../db/repo.js";
|
||||
import { listRuns, getRunById } from "../modules/2-identity/db/repo.js";
|
||||
|
||||
export async function handleListRuns({ tenantId, chat_id = null, limit = "50" }) {
|
||||
return listRuns({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import crypto from "crypto";
|
||||
import { parseEvolutionWebhook } from "../services/evolutionParser.js";
|
||||
import { resolveTenantId, processMessage } from "../services/pipeline.js";
|
||||
import { debug as dbg } from "../services/debug.js";
|
||||
import { resolveTenantId, processMessage } from "../../2-identity/services/pipeline.js";
|
||||
import { debug as dbg } from "../../shared/debug.js";
|
||||
|
||||
export async function handleEvolutionWebhook(body) {
|
||||
const t0 = Date.now();
|
||||
@@ -1,6 +1,6 @@
|
||||
import crypto from "crypto";
|
||||
import { resolveTenantId } from "../services/pipeline.js";
|
||||
import { processMessage } from "../services/pipeline.js";
|
||||
import { resolveTenantId } from "../../2-identity/services/pipeline.js";
|
||||
import { processMessage } from "../../2-identity/services/pipeline.js";
|
||||
|
||||
export async function handleSimSend(body) {
|
||||
const { chat_id, from_phone, text } = body || {};
|
||||
@@ -1,13 +1,13 @@
|
||||
import express from "express";
|
||||
|
||||
import { addSseClient, removeSseClient } from "../services/sse.js";
|
||||
import { makeGetConversations } from "../controllers/conversations.js";
|
||||
import { makeListRuns, makeGetRunById } from "../controllers/runs.js";
|
||||
import { addSseClient, removeSseClient } from "../../shared/sse.js";
|
||||
import { makeGetConversations } from "../../../controllers/conversations.js";
|
||||
import { makeListRuns, makeGetRunById } from "../../../controllers/runs.js";
|
||||
import { makeSimSend } from "../controllers/sim.js";
|
||||
import { makeGetConversationState } from "../controllers/conversationState.js";
|
||||
import { makeListMessages } from "../controllers/messages.js";
|
||||
import { makeSearchProducts } from "../controllers/products.js";
|
||||
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../controllers/admin.js";
|
||||
import { makeGetConversationState } from "../../../controllers/conversationState.js";
|
||||
import { makeListMessages } from "../../../controllers/messages.js";
|
||||
import { makeSearchProducts } from "../../../controllers/products.js";
|
||||
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../../controllers/admin.js";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
@@ -55,3 +55,4 @@ export function parseEvolutionWebhook(reqBody) {
|
||||
raw: body, // para log/debug si querés
|
||||
};
|
||||
}
|
||||
|
||||
59
src/modules/2-identity/controllers/wooWebhooks.js
Normal file
59
src/modules/2-identity/controllers/wooWebhooks.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { refreshProductByWooId } from "../../shared/wooSnapshot.js";
|
||||
import { getTenantByKey } from "../db/repo.js";
|
||||
|
||||
function unauthorized(res) {
|
||||
res.setHeader("WWW-Authenticate", 'Basic realm="woo-webhook"');
|
||||
return res.status(401).json({ ok: false, error: "unauthorized" });
|
||||
}
|
||||
|
||||
function checkBasicAuth(req) {
|
||||
const user = process.env.WOO_WEBHOOK_USER || "";
|
||||
const pass = process.env.WOO_WEBHOOK_PASS || "";
|
||||
const auth = req.headers?.authorization || "";
|
||||
if (!user || !pass) return { ok: false, reason: "missing_env" };
|
||||
if (!auth.startsWith("Basic ")) return { ok: false, reason: "missing_basic" };
|
||||
const decoded = Buffer.from(auth.slice(6), "base64").toString("utf8");
|
||||
const [u, p] = decoded.split(":");
|
||||
if (u === user && p === pass) return { ok: true };
|
||||
return { ok: false, reason: "invalid_creds" };
|
||||
}
|
||||
|
||||
function parseWooId(payload) {
|
||||
const id = payload?.id || payload?.data?.id || null;
|
||||
const parentId = payload?.parent_id || payload?.data?.parent_id || null;
|
||||
const resource = payload?.resource || payload?.topic || null;
|
||||
return { id: id ? Number(id) : null, parentId: parentId ? Number(parentId) : null, resource };
|
||||
}
|
||||
|
||||
export function makeWooProductWebhook() {
|
||||
return async function handleWooProductWebhook(req, res) {
|
||||
const auth = checkBasicAuth(req);
|
||||
if (!auth.ok) return unauthorized(res);
|
||||
|
||||
const { id, parentId, resource } = parseWooId(req.body || {});
|
||||
if (!id) return res.status(400).json({ ok: false, error: "missing_id" });
|
||||
|
||||
// Determinar tenant por query ?tenant_key=...
|
||||
const tenantKey = req.query?.tenant_key || process.env.TENANT_KEY || null;
|
||||
if (!tenantKey) return res.status(400).json({ ok: false, error: "missing_tenant_key" });
|
||||
const tenant = await getTenantByKey(String(tenantKey).toLowerCase());
|
||||
if (!tenant?.id) return res.status(404).json({ ok: false, error: "tenant_not_found" });
|
||||
|
||||
const parentForVariation =
|
||||
resource && String(resource).includes("variation") ? parentId || null : null;
|
||||
|
||||
const updated = await refreshProductByWooId({
|
||||
tenantId: tenant.id,
|
||||
wooId: id,
|
||||
parentId: parentForVariation,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
ok: true,
|
||||
woo_id: updated?.woo_id || id,
|
||||
type: updated?.type || null,
|
||||
parent_id: updated?.parent_id || null,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ export async function getRecentMessagesForLLM({
|
||||
`;
|
||||
const { rows } = await pool.query(q, [tenant_id, wa_chat_id, lim]);
|
||||
|
||||
return rows.reverse().map(r => ({
|
||||
return rows.reverse().map((r) => ({
|
||||
role: r.direction === "in" ? "user" : "assistant",
|
||||
content: String(r.text).trim().slice(0, maxCharsPerMessage),
|
||||
}));
|
||||
@@ -529,98 +529,6 @@ export async function getDecryptedTenantEcommerceConfig({
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function upsertWooProductCache({
|
||||
tenant_id,
|
||||
woo_product_id,
|
||||
name,
|
||||
sku = null,
|
||||
price = null,
|
||||
currency = null,
|
||||
payload = {},
|
||||
refreshed_at = null,
|
||||
}) {
|
||||
const q = `
|
||||
insert into woo_products_cache
|
||||
(tenant_id, woo_product_id, name, sku, price, currency, refreshed_at, payload, created_at, updated_at)
|
||||
values
|
||||
($1, $2, $3, $4, $5, $6, coalesce($7::timestamptz, now()), $8::jsonb, now(), now())
|
||||
on conflict (tenant_id, woo_product_id)
|
||||
do update set
|
||||
name = excluded.name,
|
||||
sku = excluded.sku,
|
||||
price = excluded.price,
|
||||
currency = excluded.currency,
|
||||
refreshed_at = excluded.refreshed_at,
|
||||
payload = excluded.payload,
|
||||
updated_at = now()
|
||||
returning tenant_id, woo_product_id, name, sku, price, currency, refreshed_at, payload, updated_at
|
||||
`;
|
||||
|
||||
const { rows } = await pool.query(q, [
|
||||
tenant_id,
|
||||
woo_product_id,
|
||||
name,
|
||||
sku,
|
||||
price,
|
||||
currency,
|
||||
refreshed_at,
|
||||
JSON.stringify(payload ?? {}),
|
||||
]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function searchWooProductCache({ 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 [];
|
||||
|
||||
// Búsqueda simple: name o sku (ilike). Más adelante: trigram/FTS si hace falta.
|
||||
const like = `%${query}%`;
|
||||
const sql = `
|
||||
select tenant_id, woo_product_id, name, sku, price, currency, refreshed_at, payload, updated_at
|
||||
from woo_products_cache
|
||||
where tenant_id=$1
|
||||
and (name ilike $2 or coalesce(sku,'') ilike $2)
|
||||
order by refreshed_at desc
|
||||
limit $3
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenant_id, like, lim]);
|
||||
return rows.map((r) => ({
|
||||
tenant_id: r.tenant_id,
|
||||
woo_product_id: r.woo_product_id,
|
||||
name: r.name,
|
||||
sku: r.sku,
|
||||
price: r.price,
|
||||
currency: r.currency,
|
||||
refreshed_at: r.refreshed_at,
|
||||
payload: r.payload,
|
||||
updated_at: r.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getWooProductCacheById({ tenant_id, woo_product_id }) {
|
||||
const sql = `
|
||||
select tenant_id, woo_product_id, name, sku, price, currency, refreshed_at, payload, updated_at
|
||||
from woo_products_cache
|
||||
where tenant_id=$1 and woo_product_id=$2
|
||||
limit 1
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenant_id, woo_product_id]);
|
||||
const r = rows[0];
|
||||
if (!r) return null;
|
||||
return {
|
||||
tenant_id: r.tenant_id,
|
||||
woo_product_id: r.woo_product_id,
|
||||
name: r.name,
|
||||
sku: r.sku,
|
||||
price: r.price,
|
||||
currency: r.currency,
|
||||
refreshed_at: r.refreshed_at,
|
||||
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();
|
||||
@@ -737,3 +645,4 @@ export async function getMpPaymentById({ tenant_id, payment_id }) {
|
||||
const { rows } = await pool.query(sql, [tenant_id, payment_id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
9
src/modules/2-identity/routes/wooWebhooks.js
Normal file
9
src/modules/2-identity/routes/wooWebhooks.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import express from "express";
|
||||
import { makeWooProductWebhook } from "../controllers/wooWebhooks.js";
|
||||
|
||||
export function createWooWebhooksRouter() {
|
||||
const router = express.Router();
|
||||
router.post("/webhook/woo/products", makeWooProductWebhook());
|
||||
return router;
|
||||
}
|
||||
|
||||
437
src/modules/2-identity/services/pipeline.js
Normal file
437
src/modules/2-identity/services/pipeline.js
Normal file
@@ -0,0 +1,437 @@
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
getConversationState,
|
||||
insertMessage,
|
||||
insertRun,
|
||||
touchConversationState,
|
||||
upsertConversationState,
|
||||
getRecentMessagesForLLM,
|
||||
getExternalCustomerIdByChat,
|
||||
upsertExternalCustomerMap,
|
||||
updateRunLatency,
|
||||
getTenantByKey,
|
||||
getTenantIdByChannel,
|
||||
} from "../db/repo.js";
|
||||
import { sseSend } from "../../shared/sse.js";
|
||||
import { createWooCustomer, getWooCustomerById } from "./woo.js";
|
||||
import { debug as dbg } from "../../shared/debug.js";
|
||||
import { runTurnV3 } from "../../3-turn-engine/turnEngineV3.js";
|
||||
import { safeNextState } from "../../3-turn-engine/fsm.js";
|
||||
import { createOrder, updateOrder } from "../../4-woo-orders/wooOrders.js";
|
||||
import { createPreference } from "../../6-mercadopago/mercadoPago.js";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function newId(prefix = "run") {
|
||||
return `${prefix}_${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
function makePerf() {
|
||||
const started_at = Date.now();
|
||||
const perf = { t0: started_at, marks: {} };
|
||||
const mark = (name) => {
|
||||
perf.marks[name] = Date.now();
|
||||
};
|
||||
const msBetween = (a, b) => {
|
||||
const ta = a === "t0" ? perf.t0 : perf.marks[a];
|
||||
const tb = b === "t0" ? perf.t0 : perf.marks[b];
|
||||
if (!ta || !tb) return null;
|
||||
return tb - ta;
|
||||
};
|
||||
return { started_at, perf, mark, msBetween };
|
||||
}
|
||||
|
||||
function logStage(enabled, stage, payload) {
|
||||
if (!enabled) return;
|
||||
console.log(`[pipeline] ${stage}`, payload);
|
||||
}
|
||||
|
||||
function collapseAssistantMessages(messages) {
|
||||
const out = [];
|
||||
for (const m of messages || []) {
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.role === "assistant" && m.role === "assistant") continue;
|
||||
out.push(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
export async function processMessage({
|
||||
tenantId,
|
||||
chat_id,
|
||||
from,
|
||||
text,
|
||||
provider,
|
||||
message_id,
|
||||
displayName = null,
|
||||
meta = null,
|
||||
}) {
|
||||
const { started_at, mark, msBetween } = makePerf();
|
||||
|
||||
await touchConversationState({ tenant_id: tenantId, wa_chat_id: chat_id });
|
||||
|
||||
mark("start");
|
||||
const stageDebug = dbg.perf;
|
||||
const prev = await getConversationState(tenantId, chat_id);
|
||||
mark("after_getConversationState");
|
||||
const isStale =
|
||||
prev?.state_updated_at &&
|
||||
Date.now() - new Date(prev.state_updated_at).getTime() > 24 * 60 * 60 * 1000;
|
||||
const prev_state = isStale ? "IDLE" : prev?.state || "IDLE";
|
||||
let externalCustomerId = await getExternalCustomerIdByChat({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: chat_id,
|
||||
provider: "woo",
|
||||
});
|
||||
mark("after_getExternalCustomerIdByChat");
|
||||
|
||||
await insertMessage({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: chat_id,
|
||||
provider,
|
||||
message_id,
|
||||
direction: "in",
|
||||
text,
|
||||
payload: { raw: { from, text, meta } },
|
||||
run_id: null,
|
||||
});
|
||||
mark("after_insertMessage_in");
|
||||
|
||||
mark("before_getRecentMessagesForLLM_for_plan");
|
||||
const history = await getRecentMessagesForLLM({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: chat_id,
|
||||
limit: 20,
|
||||
});
|
||||
const conversation_history = collapseAssistantMessages(history);
|
||||
mark("after_getRecentMessagesForLLM_for_plan");
|
||||
|
||||
logStage(stageDebug, "history", { has_history: Array.isArray(conversation_history), state: prev_state });
|
||||
|
||||
let reducedContext = prev?.context && typeof prev?.context === "object" ? { ...prev.context } : {};
|
||||
let decision;
|
||||
let plan;
|
||||
let llmMeta;
|
||||
let tools = [];
|
||||
|
||||
mark("before_turn_v3");
|
||||
const out = await runTurnV3({
|
||||
tenantId,
|
||||
chat_id,
|
||||
text,
|
||||
prev_state,
|
||||
prev_context: reducedContext,
|
||||
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");
|
||||
|
||||
const runStatus = llmMeta?.error ? "warn" : "ok";
|
||||
const isSimulated = provider === "sim" || meta?.source === "sim";
|
||||
|
||||
const invariants = {
|
||||
ok: true,
|
||||
checks: [
|
||||
{ name: "required_keys_present", ok: true },
|
||||
{ name: "no_checkout_without_payment_link", ok: true },
|
||||
{ name: "no_order_action_without_items", ok: true },
|
||||
],
|
||||
};
|
||||
mark("before_insertRun");
|
||||
|
||||
// --- Ejecutar acciones determinísticas ---
|
||||
let actionPatch = {};
|
||||
if (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;
|
||||
};
|
||||
|
||||
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,
|
||||
message_id: `${provider}:${message_id}`,
|
||||
prev_state,
|
||||
user_text: text,
|
||||
llm_output: { ...plan, _llm: llmMeta },
|
||||
tools,
|
||||
invariants,
|
||||
final_reply: plan.reply,
|
||||
status: runStatus,
|
||||
latency_ms: null,
|
||||
});
|
||||
mark("after_insertRun");
|
||||
|
||||
const outMessageId = newId("out");
|
||||
await insertMessage({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: chat_id,
|
||||
provider,
|
||||
message_id: outMessageId,
|
||||
direction: "out",
|
||||
text: plan.reply,
|
||||
payload: { reply: plan.reply, railguard: { simulated: isSimulated, source: meta?.source || null } },
|
||||
run_id,
|
||||
});
|
||||
mark("after_insertMessage_out");
|
||||
|
||||
if (llmMeta?.error) {
|
||||
const errMsgId = newId("err");
|
||||
await insertMessage({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: chat_id,
|
||||
provider: "system",
|
||||
message_id: errMsgId,
|
||||
direction: "out",
|
||||
text: `[ERROR] openai: ${llmMeta.error}`,
|
||||
payload: { error: { source: "openai", ...llmMeta }, railguard: { simulated: isSimulated, source: meta?.source || null } },
|
||||
run_id,
|
||||
});
|
||||
}
|
||||
|
||||
let wooCustomerError = null;
|
||||
if (tools.some((t) => t.type === "ensure_woo_customer" && !t.ok)) {
|
||||
wooCustomerError = tools.find((t) => t.type === "ensure_woo_customer" && !t.ok)?.error || null;
|
||||
}
|
||||
|
||||
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,
|
||||
railguard: { simulated: isSimulated, source: meta?.source || null },
|
||||
woo_customer_error: wooCustomerError,
|
||||
};
|
||||
|
||||
const nextState = safeNextState(prev_state, context, { requested_checkout: plan.intent === "checkout" }).next_state;
|
||||
plan.next_state = nextState;
|
||||
|
||||
const stateRow = await upsertConversationState({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: chat_id,
|
||||
state: nextState,
|
||||
last_intent: plan.intent,
|
||||
last_order_id: null,
|
||||
context,
|
||||
});
|
||||
mark("after_upsertConversationState");
|
||||
|
||||
sseSend("conversation.upsert", {
|
||||
chat_id: stateRow.wa_chat_id,
|
||||
from: stateRow.wa_chat_id.replace(/^sim:/, ""),
|
||||
state: stateRow.state,
|
||||
intent: stateRow.last_intent || "other",
|
||||
status: runStatus,
|
||||
last_activity: stateRow.updated_at,
|
||||
last_run_id: run_id,
|
||||
});
|
||||
|
||||
const end_to_end_ms = Date.now() - started_at;
|
||||
if (run_id) {
|
||||
await updateRunLatency({ tenant_id: tenantId, run_id, latency_ms: end_to_end_ms });
|
||||
}
|
||||
|
||||
sseSend("run.created", {
|
||||
run_id,
|
||||
ts: nowIso(),
|
||||
chat_id,
|
||||
from,
|
||||
status: runStatus,
|
||||
prev_state,
|
||||
input: { text },
|
||||
llm_output: { ...plan, _llm: llmMeta },
|
||||
tools,
|
||||
invariants,
|
||||
final_reply: plan.reply,
|
||||
order_id: actionPatch.woo_order_id || null,
|
||||
payment_link: actionPatch.payment_link || null,
|
||||
latency_ms: end_to_end_ms,
|
||||
});
|
||||
|
||||
console.log("[perf] processMessage", {
|
||||
tenantId,
|
||||
chat_id,
|
||||
provider,
|
||||
message_id,
|
||||
run_id,
|
||||
end_to_end_ms,
|
||||
ms: {
|
||||
db_state_ms: msBetween("start", "after_getConversationState"),
|
||||
db_identity_ms: msBetween("after_getConversationState", "after_getExternalCustomerIdByChat"),
|
||||
insert_in_ms: msBetween("after_getExternalCustomerIdByChat", "after_insertMessage_in"),
|
||||
history_for_plan_ms: msBetween("after_insertMessage_in", "after_getRecentMessagesForLLM_for_plan"),
|
||||
insert_run_ms: msBetween("before_insertRun", "after_insertRun"),
|
||||
insert_out_ms: msBetween("after_insertRun", "after_insertMessage_out"),
|
||||
upsert_state_ms: msBetween("after_insertMessage_out", "after_upsertConversationState"),
|
||||
},
|
||||
});
|
||||
|
||||
return { run_id, reply: plan.reply };
|
||||
}
|
||||
|
||||
function parseTenantFromChatId(chat_id) {
|
||||
const m = /^([a-z0-9_-]+):/.exec(chat_id);
|
||||
return m?.[1]?.toLowerCase() || null;
|
||||
}
|
||||
|
||||
export async function resolveTenantId({ chat_id, to_phone = null, tenant_key = null }) {
|
||||
const explicit = (tenant_key || parseTenantFromChatId(chat_id) || "").toLowerCase();
|
||||
|
||||
if (explicit) {
|
||||
const t = await getTenantByKey(explicit);
|
||||
if (t) return t.id;
|
||||
throw new Error(`tenant_not_found: ${explicit}`);
|
||||
}
|
||||
|
||||
if (to_phone) {
|
||||
const id = await getTenantIdByChannel({ channel_type: "whatsapp", channel_key: to_phone });
|
||||
if (id) return id;
|
||||
}
|
||||
|
||||
const fallbackKey = (process.env.TENANT_KEY || "piaf").toLowerCase();
|
||||
const t = await getTenantByKey(fallbackKey);
|
||||
if (t) return t.id;
|
||||
throw new Error(`tenant_not_found: ${fallbackKey}`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import crypto from "crypto";
|
||||
import { getDecryptedTenantEcommerceConfig } from "../db/repo.js";
|
||||
import { debug } from "./debug.js";
|
||||
import { debug } from "../../shared/debug.js";
|
||||
|
||||
// --- Simple in-memory lock to serialize work per key (e.g. wa_chat_id) ---
|
||||
const locks = new Map();
|
||||
@@ -42,12 +42,8 @@ function isRetryableNetworkError(err) {
|
||||
const e2 = getDeep(err, ["cause", "cause"]);
|
||||
|
||||
const candidates = [e0, e1, e2].filter(Boolean);
|
||||
const codes = new Set(
|
||||
candidates.map((e) => e.code).filter(Boolean)
|
||||
);
|
||||
const names = new Set(
|
||||
candidates.map((e) => e.name).filter(Boolean)
|
||||
);
|
||||
const codes = new Set(candidates.map((e) => e.code).filter(Boolean));
|
||||
const names = new Set(candidates.map((e) => e.name).filter(Boolean));
|
||||
const messages = candidates.map((e) => String(e.message || "")).join(" | ").toLowerCase();
|
||||
|
||||
const aborted =
|
||||
@@ -1,12 +1,12 @@
|
||||
import crypto from "crypto";
|
||||
import OpenAI from "openai";
|
||||
import { debug as dbg } from "./debug.js";
|
||||
import { searchProducts } from "./wooProducts.js";
|
||||
import { debug as dbg } from "../shared/debug.js";
|
||||
import { searchSnapshotItems } from "../shared/wooSnapshot.js";
|
||||
import {
|
||||
searchProductAliases,
|
||||
getProductEmbedding,
|
||||
upsertProductEmbedding,
|
||||
} from "../db/repo.js";
|
||||
} from "../2-identity/db/repo.js";
|
||||
|
||||
function getOpenAiKey() {
|
||||
return process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY || null;
|
||||
@@ -148,13 +148,12 @@ export async function retrieveCandidates({
|
||||
}
|
||||
audit.sources.aliases = aliases.length;
|
||||
|
||||
const { items: wooItems, source: wooSource } = await searchProducts({
|
||||
const { items: wooItems, source: wooSource } = await searchSnapshotItems({
|
||||
tenantId,
|
||||
q,
|
||||
limit: lim,
|
||||
forceWoo: true,
|
||||
});
|
||||
audit.sources.woo = { source: wooSource, count: wooItems?.length || 0 };
|
||||
audit.sources.snapshot = { source: wooSource, count: wooItems?.length || 0 };
|
||||
|
||||
let candidates = (wooItems || []).map((c) => {
|
||||
const lit = literalScore(q, c);
|
||||
@@ -208,3 +207,4 @@ export async function retrieveCandidates({
|
||||
|
||||
return { candidates: finalList, audit };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import OpenAI from "openai";
|
||||
import { z } from "zod";
|
||||
import Ajv from "ajv";
|
||||
import { debug as dbg } from "./debug.js";
|
||||
import { debug as dbg } from "../shared/debug.js";
|
||||
|
||||
let _client = null;
|
||||
let _clientKey = null;
|
||||
@@ -23,67 +22,6 @@ function getClient() {
|
||||
return _client;
|
||||
}
|
||||
|
||||
const NextStateSchema = z.enum([
|
||||
"IDLE",
|
||||
"BROWSING",
|
||||
"BUILDING_ORDER",
|
||||
"WAITING_ADDRESS",
|
||||
"WAITING_PAYMENT",
|
||||
"COMPLETED",
|
||||
]);
|
||||
|
||||
const IntentSchema = z.enum([
|
||||
"ask_recommendation",
|
||||
"ask_price",
|
||||
"browse_products",
|
||||
"create_order",
|
||||
"add_item",
|
||||
"remove_item",
|
||||
"checkout",
|
||||
"provide_address",
|
||||
"confirm_payment",
|
||||
"track_order",
|
||||
"other",
|
||||
]);
|
||||
|
||||
const OrderActionSchema = z.enum(["none", "create", "update", "cancel", "checkout"]);
|
||||
|
||||
const BasketItemSchema = z.object({
|
||||
product_id: z.number().int().nonnegative(),
|
||||
variation_id: z.number().int().nonnegative().nullable(),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.enum(["kg", "g", "unit"]),
|
||||
label: z.string().min(1),
|
||||
});
|
||||
|
||||
const PlanSchema = z
|
||||
.object({
|
||||
reply: z.string().min(1).max(350).catch(z.string().min(1)), // respetar guideline, sin romper si excede
|
||||
next_state: NextStateSchema,
|
||||
intent: IntentSchema,
|
||||
missing_fields: z.array(z.string()).default([]),
|
||||
order_action: OrderActionSchema.default("none"),
|
||||
basket_resolved: z
|
||||
.object({
|
||||
items: z.array(BasketItemSchema).default([]),
|
||||
})
|
||||
.default({ items: [] }),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ExtractItemSchema = z.object({
|
||||
label: z.string().min(1),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.enum(["kg", "g", "unit"]),
|
||||
});
|
||||
|
||||
const ExtractSchema = z
|
||||
.object({
|
||||
intent: IntentSchema,
|
||||
items: z.array(ExtractItemSchema).default([]),
|
||||
})
|
||||
.strict();
|
||||
|
||||
function extractJsonObject(text) {
|
||||
const s = String(text || "");
|
||||
const i = s.indexOf("{");
|
||||
@@ -262,138 +200,4 @@ export async function llmNluV3({ input, model } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un "plan" de conversación (salida estructurada) usando OpenAI.
|
||||
*
|
||||
* - `promptSystem`: instrucciones del bot
|
||||
* - `input`: { last_user_message, conversation_history, current_conversation_state, context }
|
||||
*/
|
||||
export async function llmPlan({ promptSystem, input, model } = {}) {
|
||||
const system =
|
||||
`${promptSystem}\n\n` +
|
||||
"Respondé SOLO con un JSON válido (sin markdown). Respetá estrictamente el formato requerido.";
|
||||
const { parsed, raw_text, model: chosenModel, usage } = await jsonCompletion({
|
||||
system,
|
||||
user: JSON.stringify(input ?? {}),
|
||||
model,
|
||||
});
|
||||
|
||||
const plan = PlanSchema.parse(parsed);
|
||||
return {
|
||||
plan,
|
||||
raw_text,
|
||||
model: chosenModel,
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Paso 1: extracción de intención + items mencionados (sin resolver IDs).
|
||||
* Devuelve SOLO: intent + items[{label, quantity, unit}]
|
||||
*/
|
||||
export async function llmExtract({ input, model } = {}) {
|
||||
const system =
|
||||
"Extraé intención e items del mensaje del usuario.\n" +
|
||||
"Respondé SOLO JSON válido (sin markdown) con keys EXACTAS:\n" +
|
||||
`intent (one of: ${IntentSchema.options.join("|")}), items (array of {label, quantity, unit(kg|g|unit)}).\n` +
|
||||
"Si no hay items claros, devolvé items: [].";
|
||||
|
||||
const { parsed, raw_text, model: chosenModel, usage } = await jsonCompletion({
|
||||
system,
|
||||
user: JSON.stringify(input ?? {}),
|
||||
model,
|
||||
});
|
||||
|
||||
const extracted = ExtractSchema.parse(parsed);
|
||||
return { extracted, raw_text, model: chosenModel, usage };
|
||||
}
|
||||
|
||||
// --- NLU v2 (LLM-first) ---
|
||||
|
||||
const NluIntentV2Schema = z.enum([
|
||||
"price_query",
|
||||
"browse",
|
||||
"add_to_cart",
|
||||
"remove_from_cart",
|
||||
"checkout",
|
||||
"delivery_question",
|
||||
"store_hours",
|
||||
"greeting",
|
||||
"other",
|
||||
]);
|
||||
|
||||
const NluSelectionSchema = z
|
||||
.object({
|
||||
type: z.enum(["index", "text", "sku"]),
|
||||
value: z.string().min(1),
|
||||
})
|
||||
.nullable()
|
||||
.default(null);
|
||||
|
||||
const NluEntitiesSchema = z
|
||||
.object({
|
||||
product_query: z.string().nullable().default(null),
|
||||
quantity: z.number().nullable().default(null),
|
||||
unit: z.enum(["kg", "g", "unidad", "docena"]).nullable().default(null),
|
||||
selection: NluSelectionSchema,
|
||||
attributes: z.array(z.string()).default([]),
|
||||
preparation: z.array(z.string()).default([]),
|
||||
budget: z.number().nullable().default(null),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const NluNeedsSchema = z
|
||||
.object({
|
||||
catalog_lookup: z.boolean().default(false),
|
||||
knowledge_lookup: z.boolean().default(false),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const NluClarificationSchema = z
|
||||
.object({
|
||||
reason: z.enum([
|
||||
"ambiguous_product",
|
||||
"missing_quantity",
|
||||
"missing_variant",
|
||||
"missing_delivery_zone",
|
||||
"none",
|
||||
]),
|
||||
question: z.string().nullable().default(null),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const NluV2Schema = z
|
||||
.object({
|
||||
intent: NluIntentV2Schema,
|
||||
confidence: z.number().min(0).max(1).default(0.5),
|
||||
language: z.string().default("es-AR"),
|
||||
entities: NluEntitiesSchema,
|
||||
dialogue_act: z.enum(["answer", "ask_clarification", "confirm", "propose_options"]).default("answer"),
|
||||
needs: NluNeedsSchema,
|
||||
clarification: NluClarificationSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function llmNlu({ input, model } = {}) {
|
||||
const system =
|
||||
"Sos un servicio NLU para un asistente de carnicería en Argentina (es-AR).\n" +
|
||||
"Tu tarea es EXTRAER intención, entidades y acto conversacional del mensaje del usuario.\n" +
|
||||
"Respondé SOLO JSON válido (sin markdown) y con keys EXACTAS según el contrato.\n" +
|
||||
"\n" +
|
||||
"Reglas críticas:\n" +
|
||||
"- Si el contexto incluye last_shown_options y el usuario responde con un número o 'el segundo/la cuarta', eso es selection {type:'index'}.\n" +
|
||||
"- Si el usuario pone '2kg' o '500g' o '3 unidades' eso es quantity+unit.\n" +
|
||||
"- Si el usuario pone solo un número y hay opciones mostradas, interpretalo como selection (no como cantidad).\n" +
|
||||
"- Si el contexto indica pending_item (ya hay producto elegido) y NO hay opciones mostradas, y el usuario pone solo un número, interpretalo como quantity (con unit null o la que indique el usuario).\n" +
|
||||
"- No inventes productos ni SKUs. product_query es lo que el usuario pidió (ej 'asado', 'tapa de asado wagyu').\n" +
|
||||
"- needs.catalog_lookup debe ser true para intents: price_query, browse, add_to_cart (salvo que sea pura selección numérica sobre opciones ya mostradas).\n";
|
||||
|
||||
const { parsed, raw_text, model: chosenModel, usage } = await jsonCompletion({
|
||||
system,
|
||||
user: JSON.stringify(input ?? {}),
|
||||
model,
|
||||
});
|
||||
|
||||
const nlu = NluV2Schema.parse(parsed);
|
||||
return { nlu, raw_text, model: chosenModel, usage };
|
||||
}
|
||||
// Legacy llmPlan/llmExtract y NLU v2 removidos.
|
||||
@@ -582,3 +582,4 @@ export async function runTurnV3({
|
||||
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getDecryptedTenantEcommerceConfig, getWooProductCacheById } from "../db/repo.js";
|
||||
import { debug as dbg } from "./debug.js";
|
||||
import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js";
|
||||
import { debug as dbg } from "../shared/debug.js";
|
||||
import { getSnapshotPriceByWooId } from "../shared/wooSnapshot.js";
|
||||
|
||||
// --- Simple in-memory lock to serialize work per key ---
|
||||
const locks = new Map();
|
||||
@@ -100,8 +101,8 @@ function parsePrice(p) {
|
||||
|
||||
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 snap = await getSnapshotPriceByWooId({ tenantId, wooId: productId });
|
||||
if (snap != null) return Number(snap);
|
||||
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 });
|
||||
@@ -234,3 +235,4 @@ export async function updateOrderStatus({ tenantId, wooOrderId, status }) {
|
||||
return { id: data?.id || wooOrderId, raw: data };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fetchPayment, reconcilePayment, verifyWebhookSignature } from "../services/mercadoPago.js";
|
||||
import { fetchPayment, reconcilePayment, verifyWebhookSignature } from "../mercadoPago.js";
|
||||
|
||||
export function makeMercadoPagoWebhook() {
|
||||
return async function handleMercadoPagoWebhook(req, res) {
|
||||
@@ -39,3 +39,4 @@ export function makeMercadoPagoReturn() {
|
||||
res.status(200).send(`OK - ${status}`);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import crypto from "crypto";
|
||||
import { upsertMpPayment } from "../db/repo.js";
|
||||
import { updateOrderStatus } from "./wooOrders.js";
|
||||
import { upsertMpPayment } from "../2-identity/db/repo.js";
|
||||
import { updateOrderStatus } from "../4-woo-orders/wooOrders.js";
|
||||
|
||||
function getAccessToken() {
|
||||
return process.env.MP_ACCESS_TOKEN || null;
|
||||
@@ -175,3 +175,4 @@ export async function reconcilePayment({ tenantId, payment }) {
|
||||
|
||||
return { payment: saved, woo_order_id: wooOrderId, tenant_id: resolvedTenantId };
|
||||
}
|
||||
|
||||
@@ -7,3 +7,4 @@ export function createMercadoPagoRouter() {
|
||||
router.get("/return", makeMercadoPagoReturn());
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ function envIsOff(v) {
|
||||
*
|
||||
* - DEBUG_PERF: performance/latencias
|
||||
* - DEBUG_WOO_HTTP: requests/responses a Woo (status, timings, tamaño)
|
||||
* - DEBUG_WOO_PRODUCTS: caching/queries de productos Woo
|
||||
* - DEBUG_LLM: requests/responses a OpenAI
|
||||
* - DEBUG_EVOLUTION: hook evolution + parse
|
||||
* - DEBUG_DB: queries/latencias DB (si se instrumenta)
|
||||
@@ -23,9 +22,6 @@ export const debug = {
|
||||
perf: envIsOn(process.env.DEBUG_PERF),
|
||||
|
||||
wooHttp: envIsOn(process.env.DEBUG_WOO_HTTP),
|
||||
|
||||
wooProducts: envIsOn(process.env.DEBUG_WOO_PRODUCTS),
|
||||
|
||||
llm: envIsOn(process.env.DEBUG_LLM),
|
||||
|
||||
evolution: envIsOn(process.env.DEBUG_EVOLUTION),
|
||||
@@ -39,4 +35,3 @@ export function debugOn(flagName) {
|
||||
return Boolean(debug?.[flagName]);
|
||||
}
|
||||
|
||||
|
||||
253
src/modules/shared/wooSnapshot.js
Normal file
253
src/modules/shared/wooSnapshot.js
Normal file
@@ -0,0 +1,253 @@
|
||||
import { pool } from "../2-identity/db/pool.js";
|
||||
import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js";
|
||||
import { debug as dbg } from "./debug.js";
|
||||
|
||||
async function fetchWoo({ url, method = "GET", body = null, timeout = 20000, headers = {} }) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(new Error("timeout")), timeout);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
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;
|
||||
} 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;
|
||||
}
|
||||
|
||||
function normalizeAttributes(attrs) {
|
||||
const out = {};
|
||||
if (!Array.isArray(attrs)) return out;
|
||||
for (const a of attrs) {
|
||||
const name = String(a?.name || "").trim().toLowerCase();
|
||||
if (!name) continue;
|
||||
const options = Array.isArray(a?.options) ? a.options.map((v) => String(v).trim()).filter(Boolean) : [];
|
||||
const value = a?.option ? [String(a.option).trim()] : options;
|
||||
if (value.length) out[name] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeWooProduct(p) {
|
||||
return {
|
||||
woo_id: p?.id,
|
||||
type: p?.type || "simple",
|
||||
parent_id: p?.parent_id || null,
|
||||
name: p?.name || "",
|
||||
slug: p?.slug || null,
|
||||
status: p?.status || null,
|
||||
catalog_visibility: p?.catalog_visibility || null,
|
||||
price_regular: parsePrice(p?.regular_price),
|
||||
price_sale: parsePrice(p?.sale_price),
|
||||
price_current: parsePrice(p?.price),
|
||||
stock_status: p?.stock_status || null,
|
||||
stock_qty: p?.stock_quantity != null ? Number(p.stock_quantity) : null,
|
||||
backorders: p?.backorders || null,
|
||||
categories: Array.isArray(p?.categories) ? p.categories.map((c) => c?.name || c?.slug).filter(Boolean) : [],
|
||||
tags: Array.isArray(p?.tags) ? p.tags.map((c) => c?.name || c?.slug).filter(Boolean) : [],
|
||||
attributes_normalized: normalizeAttributes(p?.attributes || []),
|
||||
date_modified: p?.date_modified || null,
|
||||
raw: p,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotRowToItem(row) {
|
||||
const categories = Array.isArray(row?.categories) ? row.categories : [];
|
||||
const attributes = row?.attributes_normalized && typeof row.attributes_normalized === "object" ? row.attributes_normalized : {};
|
||||
return {
|
||||
woo_product_id: row?.woo_id,
|
||||
name: row?.name || "",
|
||||
sku: row?.slug || null,
|
||||
price: row?.price_current != null ? Number(row.price_current) : null,
|
||||
currency: null,
|
||||
type: row?.type || null,
|
||||
categories: categories.map((c) => ({ id: null, name: String(c), slug: String(c) })),
|
||||
attributes: Object.entries(attributes).map(([name, options]) => ({
|
||||
name,
|
||||
options: Array.isArray(options) ? options : [String(options)],
|
||||
})),
|
||||
raw_price: {
|
||||
price: row?.price_current ?? null,
|
||||
regular_price: row?.price_regular ?? null,
|
||||
sale_price: row?.price_sale ?? null,
|
||||
price_html: null,
|
||||
},
|
||||
source: "snapshot",
|
||||
};
|
||||
}
|
||||
|
||||
export async function insertSnapshotRun({ tenantId, source, total }) {
|
||||
const { rows } = await pool.query(
|
||||
`insert into woo_snapshot_runs (tenant_id, source, total_items) values ($1, $2, $3) returning id`,
|
||||
[tenantId, source, total || 0]
|
||||
);
|
||||
return rows[0]?.id || null;
|
||||
}
|
||||
|
||||
export async function searchSnapshotItems({ tenantId, q = "", limit = 12 }) {
|
||||
const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 12));
|
||||
const query = String(q || "").trim();
|
||||
if (!query) return { items: [], source: "snapshot" };
|
||||
const like = `%${query}%`;
|
||||
const sql = `
|
||||
select *
|
||||
from sellable_items
|
||||
where tenant_id=$1
|
||||
and (name ilike $2 or coalesce(slug,'') ilike $2)
|
||||
order by updated_at desc
|
||||
limit $3
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenantId, like, lim]);
|
||||
return { items: rows.map(snapshotRowToItem), source: "snapshot" };
|
||||
}
|
||||
|
||||
export async function getSnapshotPriceByWooId({ tenantId, wooId }) {
|
||||
if (!wooId) return null;
|
||||
const sql = `
|
||||
select price_current
|
||||
from woo_products_snapshot
|
||||
where tenant_id=$1 and woo_id=$2
|
||||
limit 1
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenantId, wooId]);
|
||||
const price = rows[0]?.price_current;
|
||||
return price == null ? null : Number(price);
|
||||
}
|
||||
|
||||
export async function upsertSnapshotItems({ tenantId, items, runId = null }) {
|
||||
const rows = Array.isArray(items) ? items : [];
|
||||
for (const item of rows) {
|
||||
const q = `
|
||||
insert into woo_products_snapshot
|
||||
(tenant_id, woo_id, type, parent_id, name, slug, status, catalog_visibility,
|
||||
price_regular, price_sale, price_current, stock_status, stock_qty, backorders,
|
||||
categories, tags, attributes_normalized, date_modified, run_id, raw, updated_at)
|
||||
values
|
||||
($1,$2,$3,$4,$5,$6,$7,$8,
|
||||
$9,$10,$11,$12,$13,$14,
|
||||
$15::jsonb,$16::jsonb,$17::jsonb,$18,$19,$20::jsonb,now())
|
||||
on conflict (tenant_id, woo_id)
|
||||
do update set
|
||||
type = excluded.type,
|
||||
parent_id = excluded.parent_id,
|
||||
name = excluded.name,
|
||||
slug = excluded.slug,
|
||||
status = excluded.status,
|
||||
catalog_visibility = excluded.catalog_visibility,
|
||||
price_regular = excluded.price_regular,
|
||||
price_sale = excluded.price_sale,
|
||||
price_current = excluded.price_current,
|
||||
stock_status = excluded.stock_status,
|
||||
stock_qty = excluded.stock_qty,
|
||||
backorders = excluded.backorders,
|
||||
categories = excluded.categories,
|
||||
tags = excluded.tags,
|
||||
attributes_normalized = excluded.attributes_normalized,
|
||||
date_modified = excluded.date_modified,
|
||||
run_id = excluded.run_id,
|
||||
raw = excluded.raw,
|
||||
updated_at = now()
|
||||
`;
|
||||
await pool.query(q, [
|
||||
tenantId,
|
||||
item.woo_id,
|
||||
item.type,
|
||||
item.parent_id,
|
||||
item.name,
|
||||
item.slug,
|
||||
item.status,
|
||||
item.catalog_visibility,
|
||||
item.price_regular,
|
||||
item.price_sale,
|
||||
item.price_current,
|
||||
item.stock_status,
|
||||
item.stock_qty,
|
||||
item.backorders,
|
||||
JSON.stringify(item.categories || []),
|
||||
JSON.stringify(item.tags || []),
|
||||
JSON.stringify(item.attributes_normalized || {}),
|
||||
item.date_modified,
|
||||
runId,
|
||||
JSON.stringify(item.raw || {}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMissingItems({ tenantId, runId }) {
|
||||
if (!runId) return;
|
||||
await pool.query(
|
||||
`delete from woo_products_snapshot where tenant_id=$1 and coalesce(run_id,0) <> $2`,
|
||||
[tenantId, runId]
|
||||
);
|
||||
}
|
||||
|
||||
export async function refreshProductByWooId({ tenantId, wooId, parentId = null }) {
|
||||
const client = await getWooClient({ tenantId });
|
||||
let url = `${client.base}/products/${encodeURIComponent(wooId)}`;
|
||||
if (parentId) {
|
||||
url = `${client.base}/products/${encodeURIComponent(parentId)}/variations/${encodeURIComponent(wooId)}`;
|
||||
}
|
||||
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
|
||||
if (dbg.wooHttp) console.log("[wooSnapshot] refresh", { wooId, parentId, type: data?.type });
|
||||
const normalized = normalizeWooProduct(data);
|
||||
await upsertSnapshotItems({ tenantId, items: [normalized], runId: null });
|
||||
return normalized;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,415 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { llmNlu } from "./openai.js";
|
||||
import { searchProducts } from "./wooProducts.js";
|
||||
|
||||
// --- Types / Contracts (runtime-validated where it matters) ---
|
||||
|
||||
const TurnActionSchema = z.object({
|
||||
type: z.enum(["show_options", "quote_price", "add_to_cart", "ask_clarification"]),
|
||||
payload: z.record(z.any()).default({}),
|
||||
});
|
||||
|
||||
function normalizeUnit(unit) {
|
||||
const u = unit == null ? null : String(unit).toLowerCase();
|
||||
if (!u) return null;
|
||||
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";
|
||||
if (u === "docena" || u === "docenas") return "docena";
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickProductQuery({ nlu, prevContext }) {
|
||||
const q = nlu?.entities?.product_query || null;
|
||||
if (q && String(q).trim()) return String(q).trim();
|
||||
const last = prevContext?.slots?.product_label || prevContext?.pending_item?.name || null;
|
||||
return last && String(last).trim() ? String(last).trim() : null;
|
||||
}
|
||||
|
||||
function mapIntentToLegacy(intent) {
|
||||
switch (intent) {
|
||||
case "price_query":
|
||||
return "ask_price";
|
||||
case "browse":
|
||||
return "browse_products";
|
||||
case "add_to_cart":
|
||||
return "add_item";
|
||||
case "remove_from_cart":
|
||||
return "remove_item";
|
||||
case "checkout":
|
||||
return "checkout";
|
||||
case "greeting":
|
||||
return "other";
|
||||
default:
|
||||
return "other";
|
||||
}
|
||||
}
|
||||
|
||||
function formatARS(n) {
|
||||
const num = Number(n);
|
||||
if (!Number.isFinite(num)) return String(n);
|
||||
// simple ARS formatting (miles con punto)
|
||||
return Math.round(num).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
|
||||
}
|
||||
|
||||
function inferUnitHintByName(name) {
|
||||
const n = String(name || "").toLowerCase();
|
||||
// Embutidos / parrilleros: puede ser kg o unidad
|
||||
if (/\b(chorizo|chorizos|morcilla|morcillas|salchicha|salchichas|parrillera|parrilleras)\b/.test(n)) {
|
||||
return { defaultUnit: "unit", ask: "¿Los querés por kg o por unidad?" };
|
||||
}
|
||||
// Bebidas: unidad
|
||||
if (/\b(vino|vinos|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/.test(n)) {
|
||||
return { defaultUnit: "unit", ask: "¿Cuántas unidades querés?" };
|
||||
}
|
||||
// Carnes: kg (no preguntar “por kilo”, preguntar cantidad)
|
||||
return { defaultUnit: "kg", ask: "¿Cuántos kilos querés?" };
|
||||
}
|
||||
|
||||
function unitAskFor(unit) {
|
||||
if (unit === "g") return "¿Cuántos gramos querés?";
|
||||
if (unit === "unit") return "¿Cuántas unidades querés?";
|
||||
return "¿Cuántos kilos querés?";
|
||||
}
|
||||
|
||||
function formatQty({ quantity, unit }) {
|
||||
const q = Number(quantity);
|
||||
if (!Number.isFinite(q) || q <= 0) return String(quantity);
|
||||
if (unit === "g") return `${q}g`;
|
||||
if (unit === "unit") return `${q}u`;
|
||||
return `${q}kg`;
|
||||
}
|
||||
|
||||
function buildOptionsList(candidates, { baseIdx = 1, pageSize = 9 } = {}) {
|
||||
const slice = candidates.slice(0, Math.max(1, Math.min(20, pageSize)));
|
||||
const options = slice.map((c, i) => ({
|
||||
idx: baseIdx + i,
|
||||
woo_product_id: c.woo_product_id,
|
||||
name: c.name,
|
||||
price: c.price ?? null,
|
||||
}));
|
||||
const list = options.map((o) => `- ${o.idx}) ${o.name}`).join("\n");
|
||||
return { options, text: `¿Cuál de estos querés?\n${list}\n\nRespondé con el número.` };
|
||||
}
|
||||
|
||||
function parseIndexSelection(text) {
|
||||
const t = String(text || "").toLowerCase();
|
||||
const m = /\b(\d{1,2})\b/.exec(t);
|
||||
if (!m) return null;
|
||||
const n = parseInt(m[1], 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn Engine v2: “LLM-first NLU, deterministic core”
|
||||
*
|
||||
* Devuelve un objeto compatible con el pipeline actual:
|
||||
* { plan, llmMeta, decision }
|
||||
* - plan: { reply, next_state, intent, missing_fields, order_action, basket_resolved }
|
||||
* - decision.context_patch: para que pipeline lo persista
|
||||
*/
|
||||
export async function runTurnV2({
|
||||
tenantId,
|
||||
chat_id,
|
||||
text,
|
||||
prev_state,
|
||||
prev_context,
|
||||
conversation_history,
|
||||
} = {}) {
|
||||
const prev = prev_context && typeof prev_context === "object" ? prev_context : {};
|
||||
|
||||
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: Array.isArray(conversation_history)
|
||||
? conversation_history
|
||||
.slice(-6)
|
||||
.map((m) => `${m.role === "user" ? "U" : "A"}:${String(m.content || "").slice(0, 120)}`)
|
||||
.join(" | ")
|
||||
: "",
|
||||
pending_context: {
|
||||
pending_clarification: Boolean(prev?.pending_clarification?.candidates?.length),
|
||||
pending_item: prev?.pending_item?.name || null,
|
||||
},
|
||||
last_shown_options,
|
||||
locale: "es-AR",
|
||||
customer_profile: prev?.customer_profile || null,
|
||||
feature_flags: { turn_engine: "v2" },
|
||||
};
|
||||
|
||||
const { nlu, raw_text, model, usage } = await llmNlu({ input: nluInput });
|
||||
const llmMeta = { model, usage, raw_text, kind: "nlu_v2" };
|
||||
|
||||
const actions = [];
|
||||
const context_patch = {};
|
||||
|
||||
// --- AWAITING_QUANTITY / pending_item ---
|
||||
// Si ya hay un producto elegido, el siguiente turno suele ser cantidad/unidad.
|
||||
if (prev?.pending_item?.product_id && !prev?.pending_clarification?.candidates?.length) {
|
||||
const q0 = nlu?.entities?.quantity;
|
||||
const u0 = normalizeUnit(nlu?.entities?.unit);
|
||||
// Permitir "por kg"/"por unidad" sin número: actualizamos default_unit y preguntamos cantidad.
|
||||
if (!q0 && u0) {
|
||||
context_patch.pending_item = { ...(prev.pending_item || {}), default_unit: u0 === "g" ? "g" : u0 === "unit" ? "unit" : "kg" };
|
||||
const plan = {
|
||||
reply: unitAskFor(context_patch.pending_item.default_unit),
|
||||
next_state: "BUILDING_ORDER",
|
||||
intent: "add_item",
|
||||
missing_fields: ["quantity"],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [] },
|
||||
};
|
||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "pending_item_set_unit" } };
|
||||
return { plan, llmMeta, decision };
|
||||
}
|
||||
|
||||
// Número solo (ej "4") -> quantity, con unit por default_unit del pending_item.
|
||||
if (q0 && Number(q0) > 0) {
|
||||
const defaultUnit = prev.pending_item.default_unit || "kg";
|
||||
const unit = u0 === "g" ? "g" : u0 === "unit" || u0 === "docena" ? "unit" : defaultUnit === "g" ? "g" : defaultUnit === "unit" ? "unit" : "kg";
|
||||
const qty = u0 === "docena" ? Number(q0) * 12 : Number(q0);
|
||||
const it = {
|
||||
product_id: Number(prev.pending_item.product_id),
|
||||
variation_id: prev.pending_item.variation_id ?? null,
|
||||
quantity: qty,
|
||||
unit,
|
||||
label: prev.pending_item.name || "ese producto",
|
||||
};
|
||||
context_patch.pending_item = null;
|
||||
context_patch.order_basket = { items: [...(prev?.order_basket?.items || []), it] };
|
||||
actions.push({ type: "add_to_cart", payload: { product_id: it.product_id, quantity: it.quantity, unit: it.unit } });
|
||||
const plan = {
|
||||
reply: `Perfecto, anoto ${formatQty({ quantity: it.quantity, unit: it.unit })} de ${it.label}. ¿Algo más?`,
|
||||
next_state: "BUILDING_ORDER",
|
||||
intent: "add_item",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [it] },
|
||||
};
|
||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "pending_item_qty" } };
|
||||
return { plan, llmMeta, decision };
|
||||
}
|
||||
|
||||
// Si no entendimos cantidad, re-preguntamos con la unidad correcta.
|
||||
const fallbackUnit = prev.pending_item.default_unit || inferUnitHintByName(prev.pending_item.name || "").defaultUnit;
|
||||
const plan = {
|
||||
reply: unitAskFor(fallbackUnit === "unit" ? "unit" : fallbackUnit === "g" ? "g" : "kg"),
|
||||
next_state: "BUILDING_ORDER",
|
||||
intent: "add_item",
|
||||
missing_fields: ["quantity"],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [] },
|
||||
};
|
||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "pending_item_reask" } };
|
||||
return { plan, llmMeta, decision };
|
||||
}
|
||||
|
||||
// --- Deterministic overrides from state/pending ---
|
||||
// Si hay pending_clarification, una respuesta numérica debe interpretarse como selección.
|
||||
const selectionIdx =
|
||||
(nlu?.entities?.selection?.type === "index" ? parseInt(String(nlu.entities.selection.value), 10) : null) ??
|
||||
(prev?.pending_clarification?.candidates?.length ? parseIndexSelection(text) : null);
|
||||
|
||||
// Si hay selectionIdx y options vigentes, elegimos producto.
|
||||
let chosen = null;
|
||||
if (selectionIdx && Array.isArray(prev?.pending_clarification?.options)) {
|
||||
const opt = prev.pending_clarification.options.find((o) => o.idx === selectionIdx && o.type === "product");
|
||||
if (opt) {
|
||||
chosen = prev.pending_clarification.candidates?.find((c) => c.woo_product_id === opt.woo_product_id) || null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Retrieval (catálogo) ---
|
||||
const productQuery = pickProductQuery({ nlu, prevContext: prev });
|
||||
let candidates = [];
|
||||
if (nlu?.needs?.catalog_lookup || ["price_query", "browse", "add_to_cart"].includes(nlu.intent)) {
|
||||
if (productQuery) {
|
||||
const { items } = await searchProducts({ tenantId, q: productQuery, limit: 12 });
|
||||
candidates = Array.isArray(items) ? items : [];
|
||||
}
|
||||
}
|
||||
|
||||
// Si no venía de pending_clarification pero NLU pidió browse, y tenemos candidatos, mostramos lista.
|
||||
if (!chosen && (nlu.intent === "browse" || (nlu.intent === "price_query" && candidates.length > 1))) {
|
||||
const { options, text: listText } = buildOptionsList(candidates, { baseIdx: 1, pageSize: 9 });
|
||||
actions.push({ type: "show_options", payload: { count: options.length } });
|
||||
context_patch.pending_clarification = {
|
||||
candidates: candidates.map((c) => ({
|
||||
woo_product_id: c.woo_product_id,
|
||||
name: c.name,
|
||||
price: c.price ?? null,
|
||||
categories: c.categories || [],
|
||||
attributes: c.attributes || [],
|
||||
})),
|
||||
options: options.map((o) => ({ idx: o.idx, type: "product", woo_product_id: o.woo_product_id, name: o.name })),
|
||||
candidate_offset: 0,
|
||||
page_size: 9,
|
||||
base_idx: 1,
|
||||
has_more: false,
|
||||
next_candidate_offset: options.length,
|
||||
next_base_idx: options.length + 1,
|
||||
};
|
||||
// clave: al entrar en selección por lista, anulamos pending_item viejo
|
||||
context_patch.pending_item = null;
|
||||
const plan = {
|
||||
reply: listText,
|
||||
next_state: "BROWSING",
|
||||
intent: "browse_products",
|
||||
missing_fields: ["product_selection"],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [] },
|
||||
};
|
||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, productQuery, candidates_count: candidates.length } };
|
||||
return { plan, llmMeta, decision };
|
||||
}
|
||||
|
||||
// Si hubo elección por índice (chosen), pasamos a AWAITING_QUANTITY (representado como BUILDING_ORDER).
|
||||
if (chosen) {
|
||||
actions.push({ type: "ask_clarification", payload: { reason: "missing_quantity" } });
|
||||
const unitHint = inferUnitHintByName(chosen.name);
|
||||
context_patch.pending_clarification = null;
|
||||
context_patch.pending_item = {
|
||||
product_id: Number(chosen.woo_product_id),
|
||||
variation_id: null,
|
||||
name: chosen.name,
|
||||
price: chosen.price ?? null,
|
||||
categories: chosen.categories || [],
|
||||
attributes: chosen.attributes || [],
|
||||
default_unit: unitHint.defaultUnit === "unit" ? "unit" : "kg",
|
||||
};
|
||||
|
||||
// si el mismo turno ya trajo cantidad, agregamos al carrito directo
|
||||
const q = nlu?.entities?.quantity;
|
||||
const u = normalizeUnit(nlu?.entities?.unit);
|
||||
if (q && Number(q) > 0) {
|
||||
const unit = u === "g" ? "g" : u === "unit" || u === "docena" ? "unit" : "kg";
|
||||
const qty = u === "docena" ? Number(q) * 12 : Number(q);
|
||||
const it = {
|
||||
product_id: Number(chosen.woo_product_id),
|
||||
variation_id: null,
|
||||
quantity: qty,
|
||||
unit,
|
||||
label: chosen.name,
|
||||
};
|
||||
context_patch.pending_item = null;
|
||||
context_patch.order_basket = { items: [...(prev?.order_basket?.items || []), it] };
|
||||
actions.push({ type: "add_to_cart", payload: { product_id: it.product_id, quantity: it.quantity, unit: it.unit } });
|
||||
const plan = {
|
||||
reply: `Perfecto, anoto ${unit === "kg" ? `${qty}kg` : `${qty}u`} de ${chosen.name}. ¿Algo más?`,
|
||||
next_state: "BUILDING_ORDER",
|
||||
intent: "add_item",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [it] },
|
||||
};
|
||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, chosen: { id: chosen.woo_product_id, name: chosen.name } } };
|
||||
return { plan, llmMeta, decision };
|
||||
}
|
||||
|
||||
const plan = {
|
||||
reply: unitHint.ask,
|
||||
next_state: "BUILDING_ORDER",
|
||||
intent: "add_item",
|
||||
missing_fields: ["quantity"],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [] },
|
||||
};
|
||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, chosen: { id: chosen.woo_product_id, name: chosen.name } } };
|
||||
return { plan, llmMeta, decision };
|
||||
}
|
||||
|
||||
// price_query: si hay 1 candidato claro => cotizar
|
||||
if (nlu.intent === "price_query" && candidates.length === 1) {
|
||||
const c = candidates[0];
|
||||
actions.push({ type: "quote_price", payload: { product_id: c.woo_product_id } });
|
||||
const unitHint = inferUnitHintByName(c.name);
|
||||
const price = c.price;
|
||||
const per = unitHint.defaultUnit === "unit" ? "por unidad" : "el kilo";
|
||||
const reply =
|
||||
price != null
|
||||
? `${c.name} está $${formatARS(price)} ${per}. ${unitHint.ask}`
|
||||
: `Dale, lo reviso y te confirmo el precio en un momento. ${unitHint.ask}`;
|
||||
// en price, dejamos pending_item para que el siguiente turno sea cantidad
|
||||
context_patch.pending_item = {
|
||||
product_id: Number(c.woo_product_id),
|
||||
variation_id: null,
|
||||
name: c.name,
|
||||
price: c.price ?? null,
|
||||
categories: c.categories || [],
|
||||
attributes: c.attributes || [],
|
||||
default_unit: unitHint.defaultUnit === "unit" ? "unit" : "kg",
|
||||
};
|
||||
context_patch.pending_clarification = null;
|
||||
const plan = {
|
||||
reply,
|
||||
next_state: "BROWSING",
|
||||
intent: "ask_price",
|
||||
missing_fields: ["quantity"],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [] },
|
||||
};
|
||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, candidates_count: candidates.length } };
|
||||
return { plan, llmMeta, decision };
|
||||
}
|
||||
|
||||
// add_to_cart sin candidates: preguntamos producto
|
||||
if (nlu.intent === "add_to_cart" && !productQuery) {
|
||||
actions.push({ type: "ask_clarification", payload: { reason: "missing_product" } });
|
||||
const plan = {
|
||||
reply: "Dale. ¿Qué producto querés agregar?",
|
||||
next_state: prev_state || "IDLE",
|
||||
intent: "add_item",
|
||||
missing_fields: ["product_query"],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [] },
|
||||
};
|
||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu } };
|
||||
return { plan, llmMeta, decision };
|
||||
}
|
||||
|
||||
// add_to_cart con candidates y cantidad: si hay 1 candidato claro, agregamos directo.
|
||||
if (nlu.intent === "add_to_cart" && candidates.length === 1 && nlu?.entities?.quantity && Number(nlu.entities.quantity) > 0) {
|
||||
const c = candidates[0];
|
||||
const u = normalizeUnit(nlu?.entities?.unit);
|
||||
const unitHint = inferUnitHintByName(c.name);
|
||||
const unit = u === "g" ? "g" : u === "unit" || u === "docena" ? "unit" : unitHint.defaultUnit === "unit" ? "unit" : "kg";
|
||||
const qty = u === "docena" ? Number(nlu.entities.quantity) * 12 : Number(nlu.entities.quantity);
|
||||
const it = { product_id: Number(c.woo_product_id), variation_id: null, quantity: qty, unit, label: c.name };
|
||||
context_patch.order_basket = { items: [...(prev?.order_basket?.items || []), it] };
|
||||
actions.push({ type: "add_to_cart", payload: { product_id: it.product_id, quantity: it.quantity, unit: it.unit } });
|
||||
const plan = {
|
||||
reply: `Perfecto, anoto ${formatQty({ quantity: it.quantity, unit: it.unit })} de ${c.name}. ¿Algo más?`,
|
||||
next_state: "BUILDING_ORDER",
|
||||
intent: "add_item",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [it] },
|
||||
};
|
||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "add_to_cart_direct" } };
|
||||
return { plan, llmMeta, decision };
|
||||
}
|
||||
|
||||
// Fallback: respuesta segura y corta, sin inventar.
|
||||
const legacyIntent = mapIntentToLegacy(nlu.intent);
|
||||
const plan = {
|
||||
reply: nlu?.clarification?.question || "Dale. ¿Qué necesitás exactamente?",
|
||||
next_state: prev_state || "IDLE",
|
||||
intent: legacyIntent,
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [] },
|
||||
};
|
||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, productQuery, candidates_count: candidates.length } };
|
||||
// validate actions shape (best-effort, no throw)
|
||||
try {
|
||||
decision.actions = Array.isArray(actions) ? actions.map((a) => TurnActionSchema.parse(a)) : [];
|
||||
} catch {
|
||||
decision.actions = [];
|
||||
}
|
||||
return { plan, llmMeta, decision };
|
||||
}
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
import { getDecryptedTenantEcommerceConfig, getWooProductCacheById, searchWooProductCache, upsertWooProductCache } from "../db/repo.js";
|
||||
import { debug as dbg } from "./debug.js";
|
||||
|
||||
async function fetchWoo({ url, method = "GET", body = null, timeout = 8000, 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,
|
||||
});
|
||||
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;
|
||||
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 ?? 8000, 2000),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePrice(p) {
|
||||
if (p == null) return null;
|
||||
const n = Number(String(p).replace(",", "."));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function isStale(refreshed_at, maxAgeMs) {
|
||||
if (!refreshed_at) return true;
|
||||
const t = new Date(refreshed_at).getTime();
|
||||
if (!Number.isFinite(t)) return true;
|
||||
return Date.now() - t > maxAgeMs;
|
||||
}
|
||||
|
||||
function normalizeWooProduct(p) {
|
||||
return {
|
||||
woo_product_id: p?.id,
|
||||
name: p?.name || "",
|
||||
sku: p?.sku || null,
|
||||
price: parsePrice(p?.price ?? p?.regular_price ?? p?.sale_price),
|
||||
currency: null,
|
||||
type: p?.type || null, // simple | variable | grouped | external
|
||||
categories: Array.isArray(p?.categories)
|
||||
? p.categories.map((c) => ({
|
||||
id: c?.id ?? null,
|
||||
name: c?.name ?? null,
|
||||
slug: c?.slug ?? null,
|
||||
}))
|
||||
: [],
|
||||
attributes: Array.isArray(p?.attributes)
|
||||
? p.attributes.map((a) => ({
|
||||
name: a?.name || null,
|
||||
options: Array.isArray(a?.options) ? a.options.slice(0, 20) : [],
|
||||
}))
|
||||
: [],
|
||||
raw_price: {
|
||||
price: p?.price ?? null,
|
||||
regular_price: p?.regular_price ?? null,
|
||||
sale_price: p?.sale_price ?? null,
|
||||
price_html: p?.price_html ?? null,
|
||||
},
|
||||
payload: p,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchProducts({
|
||||
tenantId,
|
||||
q,
|
||||
limit = 10,
|
||||
maxAgeMs = 24 * 60 * 60 * 1000,
|
||||
forceWoo = false,
|
||||
}) {
|
||||
const debug = dbg.wooProducts;
|
||||
const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 10));
|
||||
const query = String(q || "").trim();
|
||||
if (!query) return { items: [], source: "none" };
|
||||
|
||||
// 1) Cache en Postgres
|
||||
const cached = await searchWooProductCache({ tenant_id: tenantId, q: query, limit: lim });
|
||||
|
||||
// 2) Si no hay suficiente (o force), buscamos en Woo y cacheamos
|
||||
// Nota: si el cache tiene 3 items pero pedimos 12, igual necesitamos ir a Woo para no “recortar” catálogo.
|
||||
const needWooSearch = forceWoo || cached.length < lim;
|
||||
const client = await getWooClient({ tenantId });
|
||||
|
||||
let wooItems = [];
|
||||
if (needWooSearch) {
|
||||
const url = `${client.base}/products?search=${encodeURIComponent(query)}&per_page=${lim}`;
|
||||
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
|
||||
wooItems = Array.isArray(data) ? data : [];
|
||||
|
||||
if (debug) {
|
||||
console.log("[wooProducts] search", {
|
||||
tenantId,
|
||||
query,
|
||||
count: wooItems.length,
|
||||
sample: wooItems.slice(0, 5).map((p) => ({
|
||||
id: p?.id,
|
||||
name: p?.name,
|
||||
sku: p?.sku,
|
||||
price: p?.price,
|
||||
regular_price: p?.regular_price,
|
||||
sale_price: p?.sale_price,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
for (const p of wooItems) {
|
||||
const n = normalizeWooProduct(p);
|
||||
if (!n.woo_product_id || !n.name) continue;
|
||||
await upsertWooProductCache({
|
||||
tenant_id: tenantId,
|
||||
woo_product_id: n.woo_product_id,
|
||||
name: n.name,
|
||||
sku: n.sku,
|
||||
price: n.price,
|
||||
currency: n.currency,
|
||||
payload: n.payload,
|
||||
refreshed_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Si tenemos cache pero está stale, refrescamos precio contra Woo (por ID) y actualizamos si cambió.
|
||||
// Nota: lo hacemos solo para los items que vamos a devolver (lim), para no demorar demasiado.
|
||||
const toReturn = cached.slice(0, lim);
|
||||
for (const c of toReturn) {
|
||||
if (!isStale(c.refreshed_at, maxAgeMs)) continue;
|
||||
try {
|
||||
const url = `${client.base}/products/${encodeURIComponent(c.woo_product_id)}`;
|
||||
const p = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
|
||||
if (debug) {
|
||||
console.log("[wooProducts] refresh", {
|
||||
tenantId,
|
||||
woo_product_id: c.woo_product_id,
|
||||
name: p?.name,
|
||||
sku: p?.sku,
|
||||
price: p?.price,
|
||||
regular_price: p?.regular_price,
|
||||
sale_price: p?.sale_price,
|
||||
});
|
||||
}
|
||||
const n = normalizeWooProduct(p);
|
||||
if (!n.woo_product_id || !n.name) continue;
|
||||
// Si cambió el precio (o faltaba), actualizamos.
|
||||
const prevPrice = c.price == null ? null : Number(c.price);
|
||||
const nextPrice = n.price;
|
||||
const changed = prevPrice !== nextPrice;
|
||||
|
||||
await upsertWooProductCache({
|
||||
tenant_id: tenantId,
|
||||
woo_product_id: n.woo_product_id,
|
||||
name: n.name,
|
||||
sku: n.sku,
|
||||
price: n.price,
|
||||
currency: n.currency,
|
||||
payload: n.payload,
|
||||
refreshed_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
// mantener coherencia de respuesta: refrescar item desde DB
|
||||
const refreshed = await getWooProductCacheById({ tenant_id: tenantId, woo_product_id: n.woo_product_id });
|
||||
if (refreshed) Object.assign(c, refreshed);
|
||||
}
|
||||
} catch {
|
||||
// si Woo falla, devolvemos cache (mejor que nada)
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Respuesta “unificada” (preferimos cache, pero si hicimos Woo search devolvemos esos)
|
||||
const finalItems = needWooSearch
|
||||
? wooItems
|
||||
.map(normalizeWooProduct)
|
||||
.filter((p) => p.woo_product_id && p.name)
|
||||
.slice(0, lim)
|
||||
.map((p) => ({
|
||||
woo_product_id: p.woo_product_id,
|
||||
name: p.name,
|
||||
sku: p.sku,
|
||||
price: p.price,
|
||||
currency: p.currency,
|
||||
type: p.type,
|
||||
categories: p.categories,
|
||||
attributes: p.attributes,
|
||||
raw_price: p.raw_price,
|
||||
source: "woo",
|
||||
}))
|
||||
: toReturn.map((c) => ({
|
||||
woo_product_id: c.woo_product_id,
|
||||
name: c.name,
|
||||
sku: c.sku,
|
||||
price: c.price == null ? null : Number(c.price),
|
||||
currency: c.currency,
|
||||
refreshed_at: c.refreshed_at,
|
||||
type: c?.payload?.type || null,
|
||||
categories: Array.isArray(c?.payload?.categories)
|
||||
? c.payload.categories.map((cat) => ({
|
||||
id: cat?.id ?? null,
|
||||
name: cat?.name ?? null,
|
||||
slug: cat?.slug ?? null,
|
||||
}))
|
||||
: [],
|
||||
attributes: Array.isArray(c?.payload?.attributes)
|
||||
? c.payload.attributes.map((a) => ({
|
||||
name: a?.name || null,
|
||||
options: Array.isArray(a?.options) ? a.options.slice(0, 20) : [],
|
||||
}))
|
||||
: [],
|
||||
raw_price: {
|
||||
price: c?.payload?.price ?? null,
|
||||
regular_price: c?.payload?.regular_price ?? null,
|
||||
sale_price: c?.payload?.sale_price ?? null,
|
||||
price_html: c?.payload?.price_html ?? null,
|
||||
},
|
||||
source: isStale(c.refreshed_at, maxAgeMs) ? "cache_stale" : "cache",
|
||||
}));
|
||||
|
||||
return { items: finalItems, source: needWooSearch ? "woo" : "cache" };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user