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:
103
db/migrations/20260501130000_seed_piaf_settings_and_replies.sql
Normal file
103
db/migrations/20260501130000_seed_piaf_settings_and_replies.sql
Normal file
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -164,12 +164,6 @@ class HomeDashboard extends HTMLElement {
|
||||
<canvas id="shipping-donut"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">Efectivo vs Tarjeta</div>
|
||||
<div class="chart-container short">
|
||||
<canvas id="payment-donut"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card full-width">
|
||||
<div class="chart-title">Top Productos por Facturación</div>
|
||||
@@ -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() {
|
||||
|
||||
@@ -431,7 +431,6 @@ class OrdersCrud extends HTMLElement {
|
||||
<th>Tipo</th>
|
||||
<th>Estado</th>
|
||||
<th>Envío</th>
|
||||
<th>Pago</th>
|
||||
<th>Cliente</th>
|
||||
<th>Total</th>
|
||||
<th>Fecha</th>
|
||||
@@ -453,12 +452,6 @@ class OrdersCrud extends HTMLElement {
|
||||
</td>
|
||||
<td><span class="status-badge" style="background:${statusColor(order.status)}">${statusLabel(order.status)}</span></td>
|
||||
<td><span class="badge" style="background:${order.is_delivery ? '#3b82f6' : '#8b5cf6'};color:#fff">${order.is_delivery ? 'DEL' : 'RET'}</span></td>
|
||||
<td>
|
||||
<div class="badges">
|
||||
<span class="badge" style="background:${order.is_cash ? '#f59e0b' : '#1f6feb'};color:${order.is_cash ? '#000' : '#fff'}">${order.is_cash ? '$' : 'MP'}</span>
|
||||
<span class="badge" style="background:${order.is_paid ? '#22c55e' : '#ef4444'};color:#fff">${order.is_paid ? '✓' : '✗'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="customer-name" title="${customerName}">${customerName}</td>
|
||||
<td class="total">$${Number(order.total || 0).toLocaleString("es-AR")}</td>
|
||||
<td>${formatDate(order.date_created)}</td>
|
||||
@@ -579,28 +572,6 @@ class OrdersCrud extends HTMLElement {
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="detail-title">Pago</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Método</span>
|
||||
<span class="detail-value">
|
||||
<span class="badge" style="background:${order.is_cash ? '#f59e0b' : '#1f6feb'};color:${order.is_cash ? '#000' : '#fff'};padding:3px 8px;border-radius:4px;font-size:10px;">
|
||||
${order.is_cash ? 'EFECTIVO' : 'LINK'}
|
||||
</span>
|
||||
${order.payment_method_title ? `<span style="margin-left:8px;color:var(--muted);font-size:11px;">${order.payment_method_title}</span>` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Estado</span>
|
||||
<span class="detail-value">
|
||||
<span class="badge" style="background:${order.is_paid ? '#22c55e' : '#ef4444'};color:#fff;padding:3px 8px;border-radius:4px;font-size:10px;">
|
||||
${order.is_paid ? 'PAGADO' : 'PENDIENTE'}
|
||||
</span>
|
||||
${order.date_paid ? `<span style="margin-left:8px;color:var(--muted);font-size:11px;">${formatDate(order.date_paid)}</span>` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="detail-title">Cliente</div>
|
||||
<div class="detail-row">
|
||||
|
||||
@@ -7,7 +7,6 @@ const PROMPT_LABELS = {
|
||||
greeting: "Saludos",
|
||||
orders: "Pedidos",
|
||||
shipping: "Envio/Retiro",
|
||||
payment: "Pago",
|
||||
browse: "Consultas de catalogo",
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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