From 7b6c62b23d4558db11e31fbe54023701f9cc29ba Mon Sep 17 00:00:00 2001 From: Lucas Tettamanti Date: Fri, 1 May 2026 21:18:29 -0300 Subject: [PATCH] =?UTF-8?q?Mejoras:=20idempotency=20webhook,=20m=C3=A9tric?= =?UTF-8?q?as=20rewriter,=20recommend=20en=20XState,=20seeds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7 frentes en una pasada: 1. Idempotency en pipeline.processMessage: si el message_id ya existía (Evolution suele reentregar webhooks), skip todo el turn y devuelve { duplicate: true }. Antes el ON CONFLICT DO NOTHING evitaba el insert pero igual procesaba NLU + side effects. 2. Limpieza de payment residual de admin/UI: - ordersRepo.getMonthlyStats / getTotals: out las queries cash/card - home-dashboard: out el donut "Efectivo vs Tarjeta" - orders-crud: out columna "Pago" + sección de detalle de pago - conversation-inspector: out 💵💳✅ del resumen - takeovers: out payment_link en run, out payment del summary - public/main.js: out la invariant no_checkout_without_payment_link - prompts-crud: out la entry "payment" del PROMPT_LABELS - wooOrders.parseOrder: out lectura de payment_method/is_paid (estado del pago lo gestiona el comercio offline, fuera del bot) - ordersRepo: out is_cash/is_paid del row mapping 3. Seed migration 20260501130000_seed_piaf_settings_and_replies: - schedule realista para piaf (delivery/pickup días+horas) - delivery_zones con barrios CABA reales (Palermo, Belgrano, etc) - 41 reply_templates con 17 keys + variantes (todo lo de DEFAULTS) Permite editar respuestas sin redeploy y desbloquea {{store_hours_today}}, {{delivery_zones_summary}} reales en los templates. 4. Address validation en shipping: nuevo checkAddressInZone() en storeContext.js. Cuando el usuario da dirección, se valida contra zonas configuradas. Si está fuera, renderiza shipping.address_out_of_zone con sugerencia de zonas alternativas. Sin zonas configuradas → accept-by-default. 5. Métricas rewriter: getRewriterMetrics() expuesto, contadores ok/fallback/timeouts + avg_ms + fallback_rate. Endpoint nuevo /api/metrics/rewriter. 6. Shadow XState → audit_log: el shadow mode pasa de console.log a insertAuditLog con entity_type='xstate_shadow'. Permite review post-mortem. 7. Recommend portado a XState: nuevo recommendActor (fromPromise) wrappea handleRecommend; sub-state cart.recommending invoca el actor; ingestRecommendResult absorbe { plan, decision } en context. RECOMMEND event funciona desde idle y desde cart con USE_XSTATE=1. 8. tenantId opcional en renderReply/loadReplyVariants — defaultea a getTenantId() del módulo shared/tenant.js. Backward-compat: callers pueden seguir pasando tenantId o omitirlo. E2E tests nuevos en machine/e2e.test.js: golden flow pickup, golden flow delivery con address-in-zone, snapshot rehydrate full flow, universal cart-on-add desde shipping. 192/192 tests pasando (188 previos + 4 E2E). Co-Authored-By: Claude Sonnet 4.6 --- ...1130000_seed_piaf_settings_and_replies.sql | 103 ++++++++++++ public/components/conversation-inspector.js | 6 +- public/components/home-dashboard.js | 22 --- public/components/orders-crud.js | 29 ---- public/components/prompts-crud.js | 1 - public/main.js | 2 - src/modules/0-ui/handlers/stats.js | 1 - src/modules/0-ui/handlers/takeovers.js | 8 +- src/modules/1-intake/routes/simulator.js | 2 + src/modules/2-identity/services/pipeline.js | 11 +- src/modules/3-turn-engine/machine/actions.js | 18 +++ src/modules/3-turn-engine/machine/actors.js | 18 +++ src/modules/3-turn-engine/machine/e2e.test.js | 152 ++++++++++++++++++ src/modules/3-turn-engine/machine/index.js | 22 +++ src/modules/3-turn-engine/replyRewriter.js | 25 +++ src/modules/3-turn-engine/replyTemplates.js | 18 ++- .../3-turn-engine/stateHandlers/shipping.js | 24 ++- src/modules/3-turn-engine/storeContext.js | 44 +++++ src/modules/3-turn-engine/turnEngineV3.js | 20 ++- src/modules/4-woo-orders/ordersRepo.js | 25 +-- src/modules/4-woo-orders/wooOrders.js | 17 -- 21 files changed, 454 insertions(+), 114 deletions(-) create mode 100644 db/migrations/20260501130000_seed_piaf_settings_and_replies.sql create mode 100644 src/modules/3-turn-engine/machine/e2e.test.js diff --git a/db/migrations/20260501130000_seed_piaf_settings_and_replies.sql b/db/migrations/20260501130000_seed_piaf_settings_and_replies.sql new file mode 100644 index 0000000..1a36c97 --- /dev/null +++ b/db/migrations/20260501130000_seed_piaf_settings_and_replies.sql @@ -0,0 +1,103 @@ +-- migrate:up +-- Seed mono-tenant: settings de tienda (horarios + zonas de delivery) + +-- reply_templates con todas las variantes hoy hardcodeadas en DEFAULTS. +-- Esto desbloquea editar respuestas vía UI/SQL sin redeploy. + +-- 1) tenant_settings: schedule + delivery_zones para piaf +UPDATE tenant_settings +SET + store_name = COALESCE(NULLIF(store_name, 'Mi Negocio'), 'Piaf'), + bot_name = COALESCE(NULLIF(bot_name, 'Bot'), 'Piaf'), + delivery_enabled = true, + pickup_enabled = true, + schedule = '{ + "delivery": { + "mon": {"enabled": true, "start": "09:00", "end": "13:00"}, + "tue": {"enabled": true, "start": "09:00", "end": "13:00"}, + "wed": {"enabled": true, "start": "09:00", "end": "13:00"}, + "thu": {"enabled": true, "start": "09:00", "end": "13:00"}, + "fri": {"enabled": true, "start": "09:00", "end": "13:00"}, + "sat": {"enabled": true, "start": "09:00", "end": "13:00"}, + "sun": {"enabled": false} + }, + "pickup": { + "mon": {"enabled": true, "start": "09:00", "end": "20:00"}, + "tue": {"enabled": true, "start": "09:00", "end": "20:00"}, + "wed": {"enabled": true, "start": "09:00", "end": "20:00"}, + "thu": {"enabled": true, "start": "09:00", "end": "20:00"}, + "fri": {"enabled": true, "start": "09:00", "end": "20:00"}, + "sat": {"enabled": true, "start": "09:00", "end": "13:00"}, + "sun": {"enabled": false} + } + }'::jsonb, + delivery_zones = '{ + "caba": { + "barrios": ["Palermo", "Belgrano", "Recoleta", "Villa Crespo", "Almagro", "Caballito", "Núñez", "Colegiales", "Chacarita", "Las Cañitas"] + } + }'::jsonb +WHERE tenant_id = 'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid; + +-- 2) reply_templates: seed con DEFAULTS de replyTemplates.js para piaf +INSERT INTO reply_templates (tenant_id, template_key, variant, content, weight) +SELECT + 'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid, + v.template_key, + v.variant, + v.content, + v.weight +FROM (VALUES + -- IDLE + ('idle.greeting', 1, '¡Hola! ¿En qué te puedo ayudar?', 1), + ('idle.greeting', 2, '¡Hola! Estoy para ayudarte con tu pedido. ¿Qué andás buscando?', 1), + ('idle.greeting', 3, 'Buenas. ¿Querés que te muestre algo en particular o hacemos un pedido?', 1), + ('idle.help_prompt', 1, 'Decime qué necesitás. Podés pedirme productos, precios, o armar el pedido directo.', 1), + ('idle.help_prompt', 2, '¿Qué te tiro? Podés pedir algo, preguntar precios o consultar disponibilidad.', 1), + -- CART + ('cart.ask_more', 1, '¿Algo más?', 1), + ('cart.ask_more', 2, '¿Querés agregar algo más al pedido?', 1), + ('cart.ask_more', 3, '¿Sumamos algo más o cerramos así?', 1), + ('cart.empty_prompt', 1, 'Tu carrito está vacío. ¿Qué querés agregar?', 1), + ('cart.empty_prompt', 2, 'Todavía no hay nada en el carrito. ¿Por dónde empezamos?', 1), + ('cart.not_found', 1, 'No encontré "{{query}}". ¿Podés decirlo de otra forma?', 1), + ('cart.not_found', 2, 'Mmm, no tengo "{{query}}" exacto. ¿Probamos con otra cosa?', 1), + ('cart.not_found', 3, 'No me aparece "{{query}}". Si querés, dame otro nombre o detalle más.', 1), + ('cart.didnt_understand', 1, 'Perdón, no te entendí.', 1), + ('cart.didnt_understand', 2, 'No me quedó claro, ¿me lo decís de otra forma?', 1), + ('cart.didnt_understand', 3, 'No te seguí, ¿podés repetir?', 1), + ('cart.skip_acknowledged', 1, 'Ok, lo dejamos.', 1), + ('cart.skip_acknowledged', 2, 'Listo, no lo agregamos.', 1), + ('cart.confirm_to_shipping', 1, 'Buenísimo. ¿Es para delivery o lo pasás a buscar?', 1), + ('cart.confirm_to_shipping', 2, 'Perfecto. ¿Te lo enviamos o lo retirás?', 1), + ('cart.pending_before_close', 1, 'Antes de cerrar, ¿qué hacemos con lo que quedó pendiente?', 1), + ('cart.pending_before_close', 2, 'Tenemos algo pendiente para resolver antes de cerrar el pedido.', 1), + ('cart.added_confirm', 1, 'Anoté {{summary}}. ¿Algo más?', 1), + ('cart.added_confirm', 2, 'Listo, {{summary}} agregado. ¿Sumamos algo más?', 1), + ('cart.added_confirm', 3, 'Sumé {{summary}}. ¿Querés agregar algo más?', 1), + ('cart.added_confirm', 4, 'Va {{summary}}. ¿Algo más?', 1), + ('cart.ask_what_product', 1, '¿Qué producto querés?', 1), + ('cart.ask_what_product', 2, 'Decime el producto y lo busco.', 1), + ('cart.price_no_query', 1, '¿De qué producto querés saber el precio?', 1), + ('cart.price_no_query', 2, 'Decime el producto y te paso el precio.', 1), + ('cart.price_results_header', 1, 'Estos son los precios:', 1), + ('cart.price_results_header', 2, 'Precios disponibles:', 1), + -- SHIPPING (incluye {{delivery_zones_summary}} y {{delivery_hours}} cuando hay datos) + ('shipping.ask_method', 1, '¿Lo enviamos a domicilio o lo pasás a buscar?', 1), + ('shipping.ask_method', 2, '¿Es para delivery o pickup?', 1), + ('shipping.ask_address', 1, 'Pasame la dirección de entrega.', 1), + ('shipping.ask_address', 2, 'Decime dónde lo entregamos (calle y altura). Hacemos delivery en {{delivery_zones_summary}}.', 1), + ('shipping.address_recorded', 1, 'Anotado: {{address}}.', 1), + ('shipping.address_recorded', 2, 'Listo, dirección guardada: {{address}}.', 1), + -- ORDER CLOSE + ('order.confirmed', 1, '¡Listo! Anotamos tu pedido. Te coordinamos por acá la entrega.', 1), + ('order.confirmed', 2, 'Perfecto, ya quedó registrado. Te confirmamos en breve los detalles de entrega.', 1), + ('order.confirmed', 3, 'Genial, anotado. Cualquier ajuste avisame por acá.', 1) +) AS v(template_key, variant, content, weight) +ON CONFLICT (tenant_id, template_key, variant) DO NOTHING; + +-- migrate:down +DELETE FROM reply_templates +WHERE tenant_id = 'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid; +-- Settings: limpiar schedule/zones (no borrar la fila) +UPDATE tenant_settings +SET schedule = '{}'::jsonb, delivery_zones = '{}'::jsonb +WHERE tenant_id = 'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid; diff --git a/public/components/conversation-inspector.js b/public/components/conversation-inspector.js index c7b1a4d..dd58e74 100644 --- a/public/components/conversation-inspector.js +++ b/public/components/conversation-inspector.js @@ -206,13 +206,10 @@ class ConversationInspector extends HTMLElement { parts.push(`[${activePending.length} pendiente(s)]`); } - // Checkout info + // Checkout info (sólo shipping — el bot no maneja pagos) const checkoutInfo = []; if (order?.is_delivery === true) checkoutInfo.push("🚚"); if (order?.is_delivery === false) checkoutInfo.push("🏪"); - if (order?.payment_type === "cash") checkoutInfo.push("💵"); - if (order?.payment_type === "link") checkoutInfo.push("💳"); - if (order?.is_paid) checkoutInfo.push("✅"); if (checkoutInfo.length) parts.push(checkoutInfo.join("")); return parts.length ? parts.join(" ") : "—"; @@ -232,7 +229,6 @@ class ConversationInspector extends HTMLElement { "ensure_woo_customer": "woo customer", "create_order": "create order", "update_order": "update order", - "send_payment_link": "payment link", "request_human_takeover": "human takeover", "add_to_cart": "add to cart", "human_response_sent": "human response", diff --git a/public/components/home-dashboard.js b/public/components/home-dashboard.js index 5461104..e8d09de 100644 --- a/public/components/home-dashboard.js +++ b/public/components/home-dashboard.js @@ -164,12 +164,6 @@ class HomeDashboard extends HTMLElement { -
-
Efectivo vs Tarjeta
-
- -
-
Top Productos por Facturación
@@ -455,22 +449,6 @@ class HomeDashboard extends HTMLElement { }); } - // Payment donut - const paymentCtx = this.shadowRoot.getElementById("payment-donut"); - if (paymentCtx) { - if (this.charts.paymentDonut) this.charts.paymentDonut.destroy(); - this.charts.paymentDonut = new Chart(paymentCtx, { - type: "doughnut", - data: { - labels: ["Efectivo", "Tarjeta"], - datasets: [{ - data: [totals.by_payment?.cash || 0, totals.by_payment?.card || 0], - backgroundColor: ["#10B981", "#EC4899"], - }], - }, - options: this.getDonutOptions(), - }); - } } getDonutOptions() { diff --git a/public/components/orders-crud.js b/public/components/orders-crud.js index 5d898ab..89c18f7 100644 --- a/public/components/orders-crud.js +++ b/public/components/orders-crud.js @@ -431,7 +431,6 @@ class OrdersCrud extends HTMLElement { Tipo Estado Envío - Pago Cliente Total Fecha @@ -453,12 +452,6 @@ class OrdersCrud extends HTMLElement { ${statusLabel(order.status)} ${order.is_delivery ? 'DEL' : 'RET'} - -
- ${order.is_cash ? '$' : 'MP'} - ${order.is_paid ? '✓' : '✗'} -
- ${customerName} $${Number(order.total || 0).toLocaleString("es-AR")} ${formatDate(order.date_created)} @@ -579,28 +572,6 @@ class OrdersCrud extends HTMLElement { ` : ''}
-
-
Pago
-
- Método - - - ${order.is_cash ? 'EFECTIVO' : 'LINK'} - - ${order.payment_method_title ? `${order.payment_method_title}` : ''} - -
-
- Estado - - - ${order.is_paid ? 'PAGADO' : 'PENDIENTE'} - - ${order.date_paid ? `${formatDate(order.date_paid)}` : ''} - -
-
-
Cliente
diff --git a/public/components/prompts-crud.js b/public/components/prompts-crud.js index e1d5dbd..b50bf32 100644 --- a/public/components/prompts-crud.js +++ b/public/components/prompts-crud.js @@ -7,7 +7,6 @@ const PROMPT_LABELS = { greeting: "Saludos", orders: "Pedidos", shipping: "Envio/Retiro", - payment: "Pago", browse: "Consultas de catalogo", }; diff --git a/public/main.js b/public/main.js index 7ea0c6f..f9125ff 100644 --- a/public/main.js +++ b/public/main.js @@ -92,7 +92,6 @@ async function processMessage({ chat_id, from, text }) { 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 }, ], }; @@ -110,7 +109,6 @@ async function processMessage({ chat_id, from, text }) { invariants, final_reply: plan.reply, order_id: null, - payment_link: null, latency_ms: Date.now() - started_at, }; diff --git a/src/modules/0-ui/handlers/stats.js b/src/modules/0-ui/handlers/stats.js index ac8b60d..1a7bf6f 100644 --- a/src/modules/0-ui/handlers/stats.js +++ b/src/modules/0-ui/handlers/stats.js @@ -25,7 +25,6 @@ export async function handleGetOrderStats({ tenantId }) { order_counts: monthlyStats.order_counts, by_source: monthlyStats.by_source, by_shipping: monthlyStats.by_shipping, - by_payment: monthlyStats.by_payment, // Totales agregados (para donuts) totals_aggregated: totals, diff --git a/src/modules/0-ui/handlers/takeovers.js b/src/modules/0-ui/handlers/takeovers.js index 03ee7b0..da4ede1 100644 --- a/src/modules/0-ui/handlers/takeovers.js +++ b/src/modules/0-ui/handlers/takeovers.js @@ -154,7 +154,6 @@ export async function handleRespondToTakeover({ invariants: { ok: true, checks: [] }, final_reply: response, order_id: null, - payment_link: null, latency_ms: 0, }); @@ -348,13 +347,10 @@ function summarizeContext(contextSnapshot) { summary.push(`Pendiente: ${pendingItems}`); } - // Shipping/Payment + // Shipping (sin payment — el bot no maneja pagos) if (ctx.order?.is_delivery !== null) { summary.push(ctx.order.is_delivery ? "Delivery" : "Retiro"); } - if (ctx.order?.payment_type) { - summary.push(`Pago: ${ctx.order.payment_type}`); - } - + return summary.join(" | ") || "Sin contexto"; } diff --git a/src/modules/1-intake/routes/simulator.js b/src/modules/1-intake/routes/simulator.js index 799e125..b76adff 100644 --- a/src/modules/1-intake/routes/simulator.js +++ b/src/modules/1-intake/routes/simulator.js @@ -15,6 +15,7 @@ import { makeListPendingTakeovers, makeListAllTakeovers, makeGetTakeover, makeRe import { makeGetSettings, makeSaveSettings } from "../../0-ui/controllers/settings.js"; import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js"; import { makeListOrders, makeGetOrderStats, makeGetProductsWithStock, makeCreateTestOrder } from "../../0-ui/controllers/testing.js"; +import { getRewriterMetrics } from "../../3-turn-engine/replyRewriter.js"; function nowIso() { return new Date().toISOString(); @@ -50,6 +51,7 @@ export function createSimulatorRouter({ tenantId }) { * --- UI data endpoints --- */ router.post("/sim/send", makeSimSend()); + router.get("/api/metrics/rewriter", (req, res) => res.json(getRewriterMetrics())); router.get("/conversations", makeGetConversations(getTenantId)); router.get("/conversations/state", makeGetConversationState(getTenantId)); diff --git a/src/modules/2-identity/services/pipeline.js b/src/modules/2-identity/services/pipeline.js index bbfdefb..201ae0c 100644 --- a/src/modules/2-identity/services/pipeline.js +++ b/src/modules/2-identity/services/pipeline.js @@ -141,7 +141,7 @@ let externalCustomerId = await getExternalCustomerIdByChat({ }); mark("after_getExternalCustomerIdByChat"); - await insertMessage({ + const inserted = await insertMessage({ tenant_id: tenantId, wa_chat_id: chat_id, provider, @@ -153,6 +153,15 @@ let externalCustomerId = await getExternalCustomerIdByChat({ }); mark("after_insertMessage_in"); + // Idempotency: si el message_id ya estaba insertado (Evolution suele + // reentregar webhooks), evitamos volver a procesar el turn entero. + if (!inserted) { + if (dbg.perf || dbg.evolution) { + console.log("[pipeline] duplicate message ignored", { message_id, chat_id }); + } + return { run_id: null, reply: null, duplicate: true }; + } + mark("before_getRecentMessagesForLLM_for_plan"); const history = await getRecentMessagesForLLM({ tenant_id: tenantId, diff --git a/src/modules/3-turn-engine/machine/actions.js b/src/modules/3-turn-engine/machine/actions.js index 3650c84..e157ac4 100644 --- a/src/modules/3-turn-engine/machine/actions.js +++ b/src/modules/3-turn-engine/machine/actions.js @@ -256,6 +256,23 @@ export const enqueueRemoveFromCart = assign({ ], }); +/** + * Absorbe el resultado del recommendActor: reply via rawText (ya viene + * formateado por handleRecommend), merge de order y enqueue de actions. + */ +export const ingestRecommendResult = assign({ + pending_reply: ({ event }) => { + const reply = event.output?.plan?.reply; + return reply ? { rawText: reply } : null; + }, + order: ({ context, event }) => event.output?.decision?.order || context.order, + pending_actions: ({ context, event }) => { + const incoming = event.output?.decision?.actions || []; + if (!incoming.length) return context.pending_actions || []; + return [...(context.pending_actions || []), ...incoming]; + }, +}); + // ───────────────────────────────────────────────────────────── // Async reply renderers (entry actions que producen reply async) // ───────────────────────────────────────────────────────────── @@ -364,6 +381,7 @@ export const actions = { enqueueWooCreateOrder, enqueueAddToCart, enqueueRemoveFromCart, + ingestRecommendResult, replyIdleGreeting, replyIdleHelp, replyAskMore, diff --git a/src/modules/3-turn-engine/machine/actors.js b/src/modules/3-turn-engine/machine/actors.js index 4dd8748..95b2012 100644 --- a/src/modules/3-turn-engine/machine/actors.js +++ b/src/modules/3-turn-engine/machine/actors.js @@ -6,6 +6,7 @@ import { fromPromise } from "xstate"; import { retrieveCandidates } from "../catalogRetrieval.js"; import { getProductQtyRules } from "../../0-ui/db/repo.js"; +import { handleRecommend } from "../recommendations.js"; /** * Busca candidatos para una lista de queries de producto. @@ -39,7 +40,24 @@ export const getQtyRulesActor = fromPromise(async ({ input }) => { return await getProductQtyRules({ tenantId, wooProductId }); }); +/** + * Recomendación (cross-sell o planificación). Devuelve `{ plan, decision }` + * con shape compatible con el dispatcher legacy. + */ +export const recommendActor = fromPromise(async ({ input }) => { + const { tenantId, text, nlu, order } = input || {}; + return await handleRecommend({ + tenantId, + text, + nlu, + order, + prevContext: { order }, + audit: {}, + }); +}); + export const actors = { searchCatalogActor, getQtyRulesActor, + recommendActor, }; diff --git a/src/modules/3-turn-engine/machine/e2e.test.js b/src/modules/3-turn-engine/machine/e2e.test.js new file mode 100644 index 0000000..339a2da --- /dev/null +++ b/src/modules/3-turn-engine/machine/e2e.test.js @@ -0,0 +1,152 @@ +/** + * E2E test del flow completo desde NLU output hasta serialización del + * snapshot, sin tocar DB real (mocks) ni LLM real. + * + * Cubre el camino feliz: hola → add → confirm → shipping pickup → IDLE + * con la orden creada. También valida snapshot persist/restore. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createActor } from "xstate"; + +// Mock pool de DB para que reply_templates devuelva [] (cae a DEFAULTS) +vi.mock("../../shared/db/pool.js", () => ({ + pool: { query: vi.fn().mockResolvedValue({ rows: [] }) }, +})); + +vi.mock("../replyRewriter.js", () => ({ + rewriteReply: vi.fn(async ({ baseText }) => ({ text: baseText, rewritten: false, ms: 0 })), +})); + +vi.mock("../catalogRetrieval.js", () => ({ + retrieveCandidates: vi.fn(async ({ query }) => ({ + candidates: query === "chorizo" + ? [{ woo_product_id: 100, name: "Chorizo Parrillero", price: 1500, sell_unit: "kg", _score: 1.0 }] + : query === "vacio" + ? [{ woo_product_id: 200, name: "Vacío", price: 4500, sell_unit: "kg", _score: 1.0 }] + : [], + audit: {}, + })), +})); + +vi.mock("../../0-ui/db/repo.js", () => ({ + getProductQtyRules: vi.fn(async () => []), +})); + +import { machine } from "./index.js"; + +const TENANT = "eb71b9a7-9ccf-430e-9b25-951a0c589c0f"; + +function makeActor(input = {}) { + return createActor(machine, { + input: { tenantId: TENANT, chat_id: "e2e", storeConfig: {}, ...input }, + }); +} + +async function settle(actor) { + for (let i = 0; i < 100; i++) { + const snap = actor.getSnapshot(); + const children = Object.values(snap.children || {}); + const running = children.some((c) => { + try { return c.getSnapshot()?.status === "active"; } catch { return false; } + }); + if (!running) return snap; + await new Promise((r) => setTimeout(r, 5)); + } + return actor.getSnapshot(); +} + +describe("E2E — golden flow pickup", () => { + it("hola → add chorizo (qty+unit) → confirm → pickup → idle con create_order", async () => { + const a = makeActor(); + a.start(); + + // Greeting + a.send({ type: "GREETING" }); + expect(a.getSnapshot().value).toBe("idle"); + expect(a.getSnapshot().context.pending_reply).toMatchObject({ templateKey: "idle.greeting" }); + + // Add chorizo con qty/unit completos → strong-match → ready + a.send({ type: "ADD_TO_CART", items: [{ product_query: "chorizo", quantity: 2, unit: "kg" }], text: "2kg de chorizo" }); + await settle(a); + let snap = a.getSnapshot(); + expect(snap.context.order.cart).toHaveLength(1); + expect(snap.context.order.cart[0].name).toBe("Chorizo Parrillero"); + + // Confirm + a.send({ type: "CONFIRM_ORDER" }); + expect(a.getSnapshot().value).toBe("shipping"); + + // Pickup → IDLE con create_order + a.send({ type: "SELECT_SHIPPING", method: "pickup" }); + snap = a.getSnapshot(); + expect(snap.value).toBe("idle"); + expect(snap.context.order.is_delivery).toBe(false); + expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true); + expect(snap.context.pending_reply?.templateKey).toBe("order.confirmed"); + + a.stop(); + }); +}); + +describe("E2E — golden flow delivery con address en zona", () => { + it("flow delivery con dirección en Palermo se confirma", async () => { + const a = makeActor({ + storeConfig: { delivery_zones: { caba: { barrios: ["Palermo", "Belgrano"] } } }, + }); + a.start(); + a.send({ type: "ADD_TO_CART", items: [{ product_query: "vacio", quantity: 1, unit: "kg" }], text: "1kg vacio" }); + await settle(a); + a.send({ type: "CONFIRM_ORDER" }); + a.send({ type: "SELECT_SHIPPING", method: "delivery" }); + expect(a.getSnapshot().value).toBe("shipping"); + a.send({ type: "PROVIDE_ADDRESS", address: "Av Santa Fe 3000 Palermo" }); + const snap = a.getSnapshot(); + expect(snap.value).toBe("idle"); + expect(snap.context.order.shipping_address).toMatch(/Palermo/); + expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true); + a.stop(); + }); +}); + +describe("E2E — snapshot rehydrate full flow", () => { + it("persiste snapshot a mitad de flow, hidrata, completa", async () => { + const a = makeActor(); + a.start(); + a.send({ type: "ADD_TO_CART", items: [{ product_query: "vacio", quantity: 1, unit: "kg" }], text: "1kg vacio" }); + await settle(a); + a.send({ type: "CONFIRM_ORDER" }); + expect(a.getSnapshot().value).toBe("shipping"); + const persisted = a.getPersistedSnapshot(); + a.stop(); + + // Re-hidrato y completo + const b = createActor(machine, { + snapshot: persisted, + input: { tenantId: TENANT, chat_id: "e2e", storeConfig: {} }, + }); + b.start(); + expect(b.getSnapshot().value).toBe("shipping"); + b.send({ type: "SELECT_SHIPPING", method: "pickup" }); + const snap = b.getSnapshot(); + expect(snap.value).toBe("idle"); + expect(snap.context.order.is_delivery).toBe(false); + expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true); + b.stop(); + }); +}); + +describe("E2E — universal cart-on-add desde shipping", () => { + it("desde shipping, add_to_cart vuelve a cart.searching", async () => { + const a = makeActor(); + a.start(); + a.send({ type: "ADD_TO_CART", items: [{ product_query: "chorizo", quantity: 2, unit: "kg" }], text: "2kg chorizo" }); + await settle(a); + a.send({ type: "CONFIRM_ORDER" }); + expect(a.getSnapshot().value).toBe("shipping"); + a.send({ type: "ADD_TO_CART", items: [{ product_query: "vacio", quantity: 1, unit: "kg" }], text: "1kg vacio" }); + expect(a.getSnapshot().value).toEqual({ cart: "searching" }); + await settle(a); + expect(a.getSnapshot().context.order.cart.length).toBe(2); + a.stop(); + }); +}); diff --git a/src/modules/3-turn-engine/machine/index.js b/src/modules/3-turn-engine/machine/index.js index 4480c1e..730ec81 100644 --- a/src/modules/3-turn-engine/machine/index.js +++ b/src/modules/3-turn-engine/machine/index.js @@ -83,6 +83,7 @@ export const machine = setup({ target: `${ConversationStates.CART}.searching`, }, PRICE_QUERY: { actions: "setUserText", target: `${ConversationStates.CART}.pricing` }, + RECOMMEND: { actions: "setUserText", target: `${ConversationStates.CART}.recommending` }, VIEW_CART: { target: `${ConversationStates.CART}.showing` }, CONFIRM_ORDER: { actions: "replyEmptyCart" }, OTHER: { actions: "replyIdleHelp" }, @@ -114,6 +115,7 @@ export const machine = setup({ }, ], PRICE_QUERY: { actions: "setUserText", target: ".pricing" }, + RECOMMEND: { actions: "setUserText", target: ".recommending" }, GREETING: { actions: "replyIdleGreeting", target: ".idle" }, }, states: { @@ -251,6 +253,26 @@ export const machine = setup({ always: "idle", }, + recommending: { + invoke: { + src: "recommendActor", + input: ({ context, event }) => ({ + tenantId: context.tenantId, + text: event.text || context.userText, + nlu: event.nlu || null, + order: context.order, + }), + onDone: { + actions: "ingestRecommendResult", + target: "idle", + }, + onError: { + actions: "replyDidntUnderstand", + target: "idle", + }, + }, + }, + pricing: { // Para v1, pricing reusa el flow de searching y muestra resultados. // Una iteración futura podría tener un actor separado para no agregar al carrito. diff --git a/src/modules/3-turn-engine/replyRewriter.js b/src/modules/3-turn-engine/replyRewriter.js index b82bf76..64b82c5 100644 --- a/src/modules/3-turn-engine/replyRewriter.js +++ b/src/modules/3-turn-engine/replyRewriter.js @@ -79,6 +79,27 @@ const _inflightCache = new Map(); const _resultCache = new Map(); const RESULT_TTL_MS = 30_000; +// Métricas exportadas para observabilidad. Se logean cada N rewrites. +const _metrics = { ok: 0, fallback: 0, timeouts: 0, totalMs: 0 }; + +export function getRewriterMetrics() { + const total = _metrics.ok + _metrics.fallback; + return { + rewrites_ok: _metrics.ok, + rewrites_fallback: _metrics.fallback, + rewrites_timeout: _metrics.timeouts, + fallback_rate: total ? _metrics.fallback / total : 0, + avg_ms: _metrics.ok ? Math.round(_metrics.totalMs / _metrics.ok) : 0, + }; +} + +export function resetRewriterMetrics() { + _metrics.ok = 0; + _metrics.fallback = 0; + _metrics.timeouts = 0; + _metrics.totalMs = 0; +} + function cacheKey({ templateKey, baseText, lastUserMsg, lastAssistantMsg }) { return `${templateKey}|${baseText}|${lastUserMsg}|${lastAssistantMsg}`; } @@ -186,9 +207,13 @@ export async function rewriteReply({ const result = { text: safeText, rewritten: true, model }; _resultCache.set(key, { value: result, t: Date.now() }); + _metrics.ok++; + _metrics.totalMs += (Date.now() - t0); return result; } catch (err) { const msg = String(err?.message || err); + _metrics.fallback++; + if (msg.includes("timeout")) _metrics.timeouts++; if (dbg.llm) console.log("[rewriter] error fallback to base", msg); return { text: baseText, rewritten: false, error: msg }; } diff --git a/src/modules/3-turn-engine/replyTemplates.js b/src/modules/3-turn-engine/replyTemplates.js index d0e85d9..5dde675 100644 --- a/src/modules/3-turn-engine/replyTemplates.js +++ b/src/modules/3-turn-engine/replyTemplates.js @@ -13,6 +13,7 @@ import { pool } from "../shared/db/pool.js"; import { rewriteReply } from "./replyRewriter.js"; +import { getTenantId } from "../shared/tenant.js"; const cache = new Map(); const CACHE_TTL = 5 * 60 * 1000; @@ -99,6 +100,10 @@ export const DEFAULTS = { "Anotado: {{address}}.", "Listo, dirección guardada: {{address}}.", ], + "shipping.address_out_of_zone": [ + "Esa dirección queda fuera de la zona de delivery. Hacemos entregas en {{delivery_zones_summary}}. ¿Probás otra dirección o pasás a buscar?", + "No llegamos hasta ahí. Cubrimos {{delivery_zones_summary}}. ¿Querés cambiar la dirección o retiro en sucursal?", + ], // ---------------- ORDER CLOSE ---------------- "order.confirmed": [ "¡Listo! Anotamos tu pedido. Te coordinamos por acá la entrega y el pago.", @@ -135,8 +140,9 @@ async function loadFromDb({ tenantId, templateKey }) { })); } -export async function loadReplyVariants({ tenantId, templateKey, skipCache = false }) { - const cacheKey = `${tenantId}:${templateKey}`; +export async function loadReplyVariants({ tenantId, templateKey, skipCache = false } = {}) { + const tid = tenantId || getTenantId(); + const cacheKey = `${tid}:${templateKey}`; if (!skipCache) { const cached = cache.get(cacheKey); if (cached && Date.now() - cached.timestamp < CACHE_TTL) { @@ -146,7 +152,7 @@ export async function loadReplyVariants({ tenantId, templateKey, skipCache = fal let variants = []; try { - variants = await loadFromDb({ tenantId, templateKey }); + variants = await loadFromDb({ tenantId: tid, templateKey }); } catch (err) { console.error(`[replyTemplates] DB error loading ${templateKey}: ${err.message}`); } @@ -220,8 +226,10 @@ export async function renderReply({ conversation_history = null, state = null, userText = null, -}) { - const variants = await loadReplyVariants({ tenantId, templateKey }); +} = {}) { + // tenantId opcional: defaultea al cacheado al boot (mono-tenant). + const tid = tenantId || getTenantId(); + const variants = await loadReplyVariants({ tenantId: tid, templateKey }); if (variants.length === 0) { return { reply: "", template_id: `${templateKey}:0`, variant: 0 }; } diff --git a/src/modules/3-turn-engine/stateHandlers/shipping.js b/src/modules/3-turn-engine/stateHandlers/shipping.js index bc79a30..37ca675 100644 --- a/src/modules/3-turn-engine/stateHandlers/shipping.js +++ b/src/modules/3-turn-engine/stateHandlers/shipping.js @@ -10,7 +10,7 @@ import { ConversationState } from "../fsm.js"; import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js"; import { parseIndexSelection } from "./utils.js"; import { renderReply } from "../replyTemplates.js"; -import { buildStoreContextVars } from "../storeContext.js"; +import { buildStoreContextVars, checkAddressInZone } from "../storeContext.js"; const SHIPPING_OPTIONS_TAIL = "\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal"; @@ -62,6 +62,28 @@ export async function handleShippingState({ tenantId, text, nlu, order, audit, r const address = nlu?.entities?.address || (text?.length > 5 ? text.trim() : null); if (address) { + // Validar zona si hay zonas cargadas. Si no hay datos cargados, accept-by-default. + const zoneCheck = checkAddressInZone({ address, storeConfig }); + audit.address_zone_check = zoneCheck; + if (!zoneCheck.inZone) { + const r = await renderReply({ + tenantId, + templateKey: "shipping.address_out_of_zone", + vars: storeVars, + recentReplies, + ...rewriteCtx, + }); + return { + plan: { + reply: r.reply, + next_state: ConversationState.SHIPPING, + intent: "provide_address", + missing_fields: ["address"], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, + }; + } currentOrder = { ...currentOrder, shipping_address: address }; return finalizeOrder({ tenantId, currentOrder, audit, recentReplies, storeVars, rewriteCtx, addressJustRecorded: true }); } diff --git a/src/modules/3-turn-engine/storeContext.js b/src/modules/3-turn-engine/storeContext.js index 31847b0..d2219ed 100644 --- a/src/modules/3-turn-engine/storeContext.js +++ b/src/modules/3-turn-engine/storeContext.js @@ -48,6 +48,50 @@ function formatDaySlot(slot) { /** * Resumen de zonas de delivery: lista de barrios o "Consultar zonas". */ +/** + * Lista plana de barrios (lowercase) habilitados para delivery. + */ +function getDeliveryZoneNames(deliveryZones) { + if (!deliveryZones || typeof deliveryZones !== "object") return []; + const names = []; + if (Array.isArray(deliveryZones.zones)) { + for (const z of deliveryZones.zones) if (z?.name) names.push(z.name); + } else if (deliveryZones.caba?.barrios) { + if (Array.isArray(deliveryZones.caba.barrios)) names.push(...deliveryZones.caba.barrios); + } else { + for (const [k, v] of Object.entries(deliveryZones)) { + if (v === true) names.push(k); + else if (v?.name) names.push(v.name); + } + } + return names.map((n) => String(n).toLowerCase().trim()).filter(Boolean); +} + +/** + * Verifica si un texto de dirección menciona un barrio en zona. + * - Sin zonas configuradas: retorna `{ inZone: true, reason: "no_zones_configured" }` + * (no bloqueamos hasta que el comercio cargue las zonas). + * - Con zonas: matching simple por inclusion del barrio en el texto (lowercase, sin tildes). + */ +export function checkAddressInZone({ address, storeConfig }) { + const zones = getDeliveryZoneNames(storeConfig?.delivery_zones); + if (!zones.length) { + return { inZone: true, reason: "no_zones_configured", zones: [] }; + } + const norm = String(address || "") + .toLowerCase() + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .trim(); + if (!norm) return { inZone: false, reason: "empty_address", zones }; + const match = zones.find((z) => { + const zNorm = z.normalize("NFD").replace(/[̀-ͯ]/g, ""); + return norm.includes(zNorm); + }); + if (match) return { inZone: true, reason: "matched", matched: match, zones }; + return { inZone: false, reason: "not_in_zone", zones }; +} + function summarizeDeliveryZones(deliveryZones) { if (!deliveryZones || typeof deliveryZones !== "object") return ""; const names = []; diff --git a/src/modules/3-turn-engine/turnEngineV3.js b/src/modules/3-turn-engine/turnEngineV3.js index 56e0008..847e231 100644 --- a/src/modules/3-turn-engine/turnEngineV3.js +++ b/src/modules/3-turn-engine/turnEngineV3.js @@ -19,6 +19,7 @@ import { import { getStoreConfig } from "../0-ui/db/settingsRepo.js"; import { pushRecent } from "./replyTemplates.js"; import { runTurnXState } from "./machine/runner.js"; +import { insertAuditLog } from "../0-ui/db/repo.js"; // Feature flag para NLU modular const USE_MODULAR_NLU = process.env.USE_MODULAR_NLU === "true"; @@ -205,13 +206,24 @@ export async function runTurnV3({ const legacyResult = formatResult(result, prev_context, recentReplies, failedSearches); - // Shadow mode: corre XState en paralelo, devuelve legacy, loguea diffs. + // Shadow mode: corre XState en paralelo, devuelve legacy, persiste diffs + // estructurales en audit_log para revisarlos después. if (shadowXState()) { runTurnXState({ tenantId, chat_id, text, prev_state, prev_context, conversation_history }) - .then((xstateResult) => { + .then(async (xstateResult) => { const diffs = diffResults(legacyResult, xstateResult); - if (diffs.length) { - console.log("[xstate-shadow] diffs", { chat_id, diffs }); + if (!diffs.length) return; + try { + await insertAuditLog({ + tenantId, + entityType: "xstate_shadow", + entityId: chat_id, + action: "diff", + changes: { diffs, prev_state, text_preview: String(text || "").slice(0, 80) }, + actor: "system", + }); + } catch (err) { + console.error("[xstate-shadow] audit_log failed", err?.message || err); } }) .catch((err) => console.error("[xstate-shadow] error", err?.message || err)); diff --git a/src/modules/4-woo-orders/ordersRepo.js b/src/modules/4-woo-orders/ordersRepo.js index 3904767..a38b9c0 100644 --- a/src/modules/4-woo-orders/ordersRepo.js +++ b/src/modules/4-woo-orders/ordersRepo.js @@ -169,11 +169,8 @@ export async function listOrders({ tenantId, page = 1, limit = 50 }) { total: row.total, currency: row.currency, date_created: row.date_created, - date_paid: row.date_paid, source: row.source, is_delivery: row.is_delivery, - is_cash: row.is_cash, - is_paid: ['processing', 'completed', 'on-hold'].includes(row.status), is_test: false, // Podemos agregar este campo a la BD si es necesario shipping: { first_name: firstName, @@ -210,26 +207,24 @@ export async function listOrders({ tenantId, page = 1, limit = 50 }) { */ export async function getMonthlyStats({ tenantId }) { const sql = ` - SELECT + SELECT TO_CHAR(date_created, 'YYYY-MM') as month, COUNT(*) as order_count, SUM(total) as total_revenue, SUM(CASE WHEN source = 'whatsapp' THEN total ELSE 0 END) as whatsapp_revenue, SUM(CASE WHEN source = 'web' THEN total ELSE 0 END) as web_revenue, SUM(CASE WHEN is_delivery THEN total ELSE 0 END) as delivery_revenue, - SUM(CASE WHEN NOT is_delivery THEN total ELSE 0 END) as pickup_revenue, - SUM(CASE WHEN is_cash THEN total ELSE 0 END) as cash_revenue, - SUM(CASE WHEN NOT is_cash THEN total ELSE 0 END) as card_revenue + SUM(CASE WHEN NOT is_delivery THEN total ELSE 0 END) as pickup_revenue FROM woo_orders_cache WHERE tenant_id = $1 GROUP BY TO_CHAR(date_created, 'YYYY-MM') ORDER BY month ASC `; const { rows } = await pool.query(sql, [tenantId]); - + const months = rows.map(r => r.month); const totals = rows.map(r => parseFloat(r.total_revenue) || 0); - + return { months, totals, @@ -242,10 +237,6 @@ export async function getMonthlyStats({ tenantId }) { delivery: rows.map(r => parseFloat(r.delivery_revenue) || 0), pickup: rows.map(r => parseFloat(r.pickup_revenue) || 0), }, - by_payment: { - cash: rows.map(r => parseFloat(r.cash_revenue) || 0), - card: rows.map(r => parseFloat(r.card_revenue) || 0), - }, }; } @@ -379,8 +370,6 @@ export async function getTotals({ tenantId }) { SUM(CASE WHEN source = 'web' THEN total ELSE 0 END) as web_total, SUM(CASE WHEN is_delivery THEN total ELSE 0 END) as delivery_total, SUM(CASE WHEN NOT is_delivery THEN total ELSE 0 END) as pickup_total, - SUM(CASE WHEN is_cash THEN total ELSE 0 END) as cash_total, - SUM(CASE WHEN NOT is_cash THEN total ELSE 0 END) as card_total, COUNT(*) as total_orders, SUM(total) as total_revenue FROM woo_orders_cache @@ -388,7 +377,7 @@ export async function getTotals({ tenantId }) { `; const { rows } = await pool.query(sql, [tenantId]); const r = rows[0] || {}; - + return { by_source: { whatsapp: parseFloat(r.whatsapp_total) || 0, @@ -398,10 +387,6 @@ export async function getTotals({ tenantId }) { delivery: parseFloat(r.delivery_total) || 0, pickup: parseFloat(r.pickup_total) || 0, }, - by_payment: { - cash: parseFloat(r.cash_total) || 0, - card: parseFloat(r.card_total) || 0, - }, total_orders: parseInt(r.total_orders) || 0, total_revenue: parseFloat(r.total_revenue) || 0, }; diff --git a/src/modules/4-woo-orders/wooOrders.js b/src/modules/4-woo-orders/wooOrders.js index 11038e4..ca28c74 100644 --- a/src/modules/4-woo-orders/wooOrders.js +++ b/src/modules/4-woo-orders/wooOrders.js @@ -340,25 +340,12 @@ function normalizeWooOrder(order) { !wooShippingMethod.toLowerCase().includes("local"); } - // Método de pago - const metaPaymentMethod = order.meta_data?.find(m => m.key === "payment_method_wa")?.value || null; - const paymentMethod = order.payment_method || null; - const paymentMethodTitle = order.payment_method_title || null; - const isCash = metaPaymentMethod === "cash" || - paymentMethod === "cod" || - paymentMethodTitle?.toLowerCase().includes("efectivo") || - paymentMethodTitle?.toLowerCase().includes("cash"); - - const isPaid = ["processing", "completed", "on-hold"].includes(order.status); - const datePaid = order.date_paid || null; - return { id: order.id, status: order.status, total: order.total, currency: order.currency, date_created: order.date_created, - date_paid: datePaid, billing: { first_name: order.billing?.first_name || "", last_name: order.billing?.last_name || "", @@ -395,10 +382,6 @@ function normalizeWooOrder(order) { is_test: isTest, shipping_method: shippingMethod, is_delivery: isDelivery, - payment_method: paymentMethod, - payment_method_title: paymentMethodTitle, - is_cash: isCash, - is_paid: isPaid, raw: order, }; }