Mejoras: idempotency webhook, métricas rewriter, recommend en XState, seeds
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
152
src/modules/3-turn-engine/machine/e2e.test.js
Normal file
152
src/modules/3-turn-engine/machine/e2e.test.js
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user