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;
|
||||
Reference in New Issue
Block a user