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,
};
}