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:
Lucas Tettamanti
2026-05-01 21:18:29 -03:00
parent 6b7889ef4e
commit 7b6c62b23d
21 changed files with 454 additions and 114 deletions

View 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;