Compare commits

..

23 Commits

Author SHA1 Message Date
Lucas Tettamanti
cbbb88c052 Config: layout más amplio + toolbar sticky con Guardar
Antes: container 800px centrado, 3 panels stacked vertical, save al final.
Ahora:
- Container 1600px max, padding 24px.
- Toolbar sticky arriba con título "Configuración" + Restaurar + Guardar
  (reemplaza la sección final).
- Settings grid de 2 columnas (340px / 1fr) que colapsa a 1 col en <960px:
  - Izq: panel "Información del Negocio" (campos apilados, más densos) +
    panel "Retiro en Tienda" (toggle + grid).
  - Der: panel "Zonas de Entrega" full-width — el mapa ocupa la mayor parte
    de la pantalla (height:calc(100vh-220px), min 520px).
- zones-layout dentro del panel: 300px lista/form / 1fr mapa flex.
- Sin pérdida funcional: mismos campos, mismas validaciones, mismas tools.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:35:31 -03:00
Lucas Tettamanti
448b3d7c44 last_delivery: reusar dirección/zona del último pedido + retitular header
Persistencia: cuando confirm_order encola create_order y la orden tiene
shipping/zona/window, runTurn snapshot a context.last_delivery
{is_delivery, shipping_address, matched_zone, pending_location, delivery_window}.
pipeline preserva ese campo cuando resetea por stale (>24h), igual que
external_customer_id.

Agente lo ve via working_memory.last_delivery en cada turno.

Nueva tool reuse_last_delivery() que copia shipping_address + matched_zone
(+ pending_location) al order actual. Pickup-only sólo setea is_delivery=false.

systemPrompt: instrucciones para que el bot proactivamente ofrezca "te lo
mandamos al mismo lugar que la última vez (dirección, zona, $)" cuando
last_delivery existe y todavía no se eligió método de envío. Cliente puede
aceptar (reuse_last_delivery) o pedir otra dirección/retiro. delivery_window
NO se asume — siempre se vuelve a preguntar día/hora.

Smoke E2E: cliente recurrente con conversación stale 25h+
- 1ra orden: 1kg vacío → location → mar 12h → confirma.
- DB: context.last_delivery con zona Centro Test + dirección + ventana.
- 2da orden: "hola, 500g bondiola" → bot: "¿al mismo lugar (Av. Corrientes
  1234, Centro, $1.500)?" → "sí" → "¿qué día? La última fue mar 12h, puede
  ser otro" → "jueves 11hs" → orden cerrada sin re-pedir pin.

Header: "Bot Ops Console" → "Piaf Console" (index.html + ops-shell).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:33:58 -03:00
Lucas Tettamanti
3c70eb5ff7 Sacar pestaña Test del admin
- Borrado public/components/test-panel.js + su nav button + su rango de
  router + sus rutas /test/products-with-stock y /test/order.
- Borrados handleGetProductsWithStock + handleCreateTestOrder y los
  api wrappers getProductsWithStock + createTestOrder. Ya no se usan en
  ningún flujo: pruebas de bot se hacen vía simulator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:29:20 -03:00
Lucas Tettamanti
c93955fa55 Limpiar legacy delivery_* + arreglar carga del mapa en shadow DOM
Backend cleanup (todo el delivery vive ahora en delivery_zones.zones[]):
- Migration drop columns delivery_enabled / delivery_days / delivery_hours_start /
  delivery_hours_end / delivery_min_order y limpieza de schedule.delivery JSONB.
- settingsRepo: SELECT/INSERT/UPDATE sólo con campos vigentes, formatScheduleHours
  trabaja sobre pickup.
- handlers/settings: defaults sin legacy, validateSchedule sólo para pickup,
  validateDeliveryZones nuevo (estructura GeoJSON + días).
- seed_piaf_settings_and_replies + tenant_settings migrations alineadas: schedule
  sólo tiene pickup, delivery_zones queda en {} para reconfigurar via UI.

Frontend cleanup:
- settings-crud: borrado el panel "Delivery (Envío a domicilio)" + minOrder,
  toggle deliveryEnabled, y el grid schedule.delivery. collectScheduleFromInputs
  ahora sólo procesa pickup. save() ya no envía delivery_enabled/min_order.

Fix mapa (no cargaba):
- zone-map-editor: los <link> a leaflet.css/leaflet-geoman.css se inyectaban en
  document.head, que NO cruza el shadow DOM de settings-crud, por lo que las
  reglas de Leaflet no aplicaban al div del mapa. Ahora los <link> se anclan
  como hijos del propio web component; al estar en light DOM dentro del shadow
  root del padre, sí aplican.
- Espera explícita a que el stylesheet cargue antes de instanciar L.map.
- ResizeObserver + invalidateSize() para cuando el contenedor cambia tamaño
  (router muestra/oculta panel, tabs, etc).

Smoke E2E sin regresión: 1kg vacío + envío → location en Centro → "martes 12hs"
→ orden confirmada con $28.000 (producto + envío). 157/157 tests verde.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:37:47 -03:00
Lucas Tettamanti
aed79078de Zonas de delivery por polígono + horarios + location share por WhatsApp
Schema delivery_zones JSONB pasa a { zones: [{ id, name, polygon (GeoJSON),
delivery_cost, delivery_days, delivery_hours, min_order_amount, enabled }] }.
Tirado el modelo legacy de 48 barrios CABA hardcoded.

Backend:
- lib/geo.js: pointInPolygon + findZoneForPoint (ray casting, sin deps) + 9 tests.
- storeContext.checkAddressInZone ahora valida con lat/lng (necesita ubicación
  del cliente; no geocodifica texto). buildZonesForLLM expone zonas resumidas
  para el agente. summarizeDeliveryZones genera prosa con costo+días+horas.
- settingsRepo expone delivery_zones (bug pre-existente: nunca se devolvía).
- pipeline: inboundLocation ⇒ persistir order.pending_location; orderModel
  acepta pending_location, matched_zone, delivery_window.

Intake:
- evolutionParser detecta locationMessage/liveLocationMessage (Baileys).
- evolution + sim handlers propagan inboundLocation al pipeline.

Agent (DeepSeek tool-calling):
- workingMemory inyecta store.delivery.zones[], store.pickup.schedule,
  order.pending_location/matched_zone/delivery_window.
- setAddress: matchea zona con la ubicación pendiente; sin location devuelve
  need_location y el LLM le pide el pin al cliente.
- setShipping: para delivery, indica requires_location si faltan coords.
- confirmOrder: valida día+hora contra zone.delivery_days/hours o pickup.schedule.
- nueva tool set_delivery_window(day, time?) para registrar el slot pedido.
- systemPrompt agrega instrucciones de envío/zonas + flujo location share.

Frontend:
- zone-map-editor: web component (light DOM) que carga Leaflet 1.9 +
  leaflet-geoman lazy desde CDN y permite dibujar/editar polígonos sobre OSM.
  API zones get/set, eventos change/select, paleta tomada de --chart-*.
- settings-crud: borrada lista CABA_BARRIOS, nueva UI con mapa al lado y
  formulario por zona seleccionada (nombre, costo, días, horario start/end,
  mínimo, habilitada). Save serializa al schema nuevo.

Smoke E2E manual:
- "1kg vacío + envío" → bot pide pin → location en Centro → matched_zone
  $1.500, lun-sab 10-20h → "martes 12hs" → confirma orden con total + envío.
- Location en Palermo Test → mar/jue 11-19h respetado.
- Location fuera de zonas → "no llegamos a esa zona" + lista de zonas válidas.
- Domingo en Centro → rechazado con días disponibles.

157/157 tests verde.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:31:25 -03:00
Lucas Tettamanti
0bf26f8eb5 Restyling light pastel: tema blanco/azul/verde + Inter self-hosted
- theme.css reescrito: paleta light (sky/emerald accents, slate neutrals),
  tokens de spacing/typography/radii/shadows, @font-face Inter +
  JetBrains Mono variable woff2 self-hosted.
- ops-shell: header blanco con nav active=accent-soft + status pill pastel.
- run-timeline: bubbles emerald-100 (user) / blue-100 (bot) sobre blanco.
- home-dashboard: helpers cssVar + withAlpha, 6 charts coordinados a
  paleta pastel (--chart-blue/green/purple/orange/pink/gray).
- 8 CRUDs (users, products, orders, conversations, aliases,
  recommendations, quantities, takeovers, settings, debug) migrados
  de hex hardcoded oscuros a var(--*).
- modal.js + toast.js refactor a vars con fallbacks; modal blanco
  con shadow-lg y soft icon backgrounds.
- test-panel: aliases :host apuntan a globals en vez de override dark.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:56:48 -03:00
Lucas Tettamanti
675a449ce8 D9 cleanup: borrar NLU/handlers/machine/replyTemplates legacy + activar agente + prompt caching
Después de validar el agente E2E con DeepSeek, el legacy queda muerto.
51 archivos cambiados (la mayoría borrados), el motor único es ahora el
agente tool-calling.

Borrados (~3500 LOC):
- src/modules/3-turn-engine/nlu/ (router + 4 specialists + promptLoader +
  schemas + humanFallback + 6 default prompts) — reemplazado por systemPrompt.js
- src/modules/3-turn-engine/stateHandlers/ (cart.js, cartHelpers.js, idle.js,
  shipping.js, utils.js, index.js) — reemplazado por tools del agente
- src/modules/3-turn-engine/stateHandlers.js (re-export shim)
- src/modules/3-turn-engine/openai.js (NLU clásico v3 + jsonCompletion +
  llmRecommendWriter + llmPlanningRecommend) — el agente crea su propio
  cliente OpenAI con tools nativos
- src/modules/3-turn-engine/replyRewriter.js (rewriting LLM) — el agente
  escribe say directo, no necesita reescribir
- src/modules/3-turn-engine/replyTemplates.js + test (rotación de variantes)
  — el agente varía naturalmente con tool_choice=required + temperature
- src/modules/3-turn-engine/recommendations.js (cross-sell + planning) —
  el agente decide cuándo recomendar via tool calls
- src/modules/3-turn-engine/machine/ (XState v5 completo + 19 tests) —
  reemplazado por la FSM podada en fsm.js + agent/runTurn.js
- src/modules/3-turn-engine/turnEngineV3.helpers.js, .units.js,
  .pendingSelection.js (helpers del legacy)
- src/modules/0-ui/controllers/prompts.js, handlers/prompts.js,
  db/promptsRepo.js — admin de prompts NLU (ya no hay prompts editables)
- public/components/prompts-crud.js + nav entry en ops-shell

turnEngineV3.js se reduce a un thin wrapper que exporta runTurnV3 (alias
de runTurnAgent) + safeNextState (re-export de fsm.js). Mantiene la firma
pública para no tocar pipeline.js.

Activado:
- AGENT_MAX_TOOL_CALLS=10 y AGENT_TURN_TIMEOUT_MS=25000 son los únicos
  flags. Borradas: USE_MODULAR_NLU, USE_XSTATE, XSTATE_SHADOW,
  XSTATE_SETTLE_MS, REPLY_REWRITER, REPLY_REWRITER_TIMEOUT_MS, TURN_ENGINE,
  AGENT_TURN_ENGINE, AGENT_TURN_ENGINE_SHADOW (el agente es default).

Prompt caching DeepSeek:
- systemPrompt.js: era función con storeName interpolado → ahora export
  const SYSTEM_PROMPT (100% estático). storeName se pasa por user message
  via working_memory.store.name. Cualquier cambio al system invalida cache,
  por eso es estático estricto.
- runTurn.js: captura usage.prompt_cache_hit_tokens (DeepSeek) o
  prompt_tokens_details.cached_tokens (OpenAI compat) y suma a métricas.
- /api/metrics/agent ahora reporta prompt_tokens_total,
  completion_tokens_total, prompt_cache_hit_tokens, cache_hit_ratio.
- Smoke test 3 turnos: cache_hit_ratio = 0.72 (17664 cached / 24546 total
  prompt tokens). Saving directo en costo: ~$0.02/M cached vs $0.27/M no
  cached en DeepSeek.

Tests: 148/148 (perdimos 90 tests del legacy XState/replyTemplates que
ya no aplican). Sim flow E2E confirmado: hola → agent responde, multi-turn
con cache caliente.

Si más adelante hace falta volver al legacy: git revert este commit
(c c9c69cf8 es el último estado verde con doble motor).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 13:14:59 -03:00
Lucas Tettamanti
03621f16f4 Redesign: agente tool-calling con DeepSeek (D2-D10 del plan)
Reemplaza el NLU rígido (intent+entities) por un agente LLM con tool-calling
que decide y muta estado en cada turno. Opt-in vía AGENT_TURN_ENGINE=1.
DeepSeek V4 (deepseek-chat) configurado como modelo (OpenAI-compatible).

Arquitectura nueva en src/modules/3-turn-engine/agent/:

- workingMemory.js: arma el JSON contextual que recibe el LLM cada turno
  (cart, pending, last_shown_options, store, customer_profile, history,
  preparsed quantity).
- systemPrompt.js: prompt estático ~70 líneas. Define rol + reglas duras +
  cómo procesar mensajes + cómo escribir el say. Sin enumeración de intents.
- runTurn.js: loop de tool-calling con tool_choice="required". Cap 10 tool
  calls / 20s timeout. Métricas in-memory.
- customerProfile.js: lookup de frequent_items en woo_orders_cache por
  teléfono (chat_id → phone), top 5 últimos 6 meses. Cache 10 min.
- tools/schemas.js: 11 tools (search_catalog, add_to_cart, set_quantity,
  select_candidate, remove_from_cart, set_shipping, set_address,
  confirm_order, pause, escalate_to_human, say).
- tools/executor.js: validación Ajv + dispatch + observación al LLM.
  woo_id se valida contra snapshot — si no existe el agente vuelve a
  search_catalog (anti-halucinación).
- tools/searchCatalog.js: wrappea retrieveCandidates + fallback por
  categoría usando jsonb_array_elements_text del snapshot. Persiste
  last_shown_options automáticamente.
- tools/{addToCart, setQuantity, selectCandidate, removeFromCart,
  setShipping, setAddress, confirmOrder, pause, escalateToHuman}.js:
  side effects atómicos sobre el order.
- quantityParser.js (D1): determinístico, parsea fracciones, frases
  compuestas (media docena, cuarto kilo), numéricos. 46 tests.

FSM extendida (fsm.js): nuevo estado PAUSED (TTL 7d, cart preservado,
"después te digo" → pause tool).

pipeline.js: TTL stale ahora 24h general, 7d si PAUSED, infinito si
AWAITING_HUMAN.

turnEngineV3.js: nuevas flags AGENT_TURN_ENGINE y AGENT_TURN_ENGINE_SHADOW.
Branch a runTurnAgent cuando full o corre en paralelo escribiendo diffs
estructurales en audit_log (entity_type='agent_shadow') para validar
paridad antes de flippar.

Endpoint nuevo: GET /api/metrics/agent → turns, avg_tool_calls, fallback
rate, escalations, pauses, orders_confirmed.

Smoke test E2E con DeepSeek real:
- "hola" → say (2.3s, 1 tool)
- "2kg de vacio" → search → add_to_cart → say (8.8s, 3 tools)
- "media docena de chorizos" → search → say con clarificación (10.3s, 4 tools)
- "listo" → say (3.3s, 1 tool)
- "retiro" → set_shipping → confirm → say (5.1s, 3 tools)
Cart final correcto: 2kg de Vacío. Estado: CART → SHIPPING.

Tests: 238/238 pasando.

D9 (cleanup legacy ~1200 LOC NLU/handlers/replyRewriter) DEFERRED:
se hace después de paridad shadow validada con tráfico real. Hoy
agente coexiste con legacy; default sigue siendo el motor V3.

Plan completo en ~/.claude/plans/ok-creo-que-tiene-humming-sutton.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 12:52:47 -03:00
Lucas Tettamanti
9c69cf8911 D1 redesign: quantityParser determinista (es-AR) + 46 tests
Primer paso del rediseño tool-calling agent. Setup:
- DeepSeek V4 confirmado vía OPENAI_BASE_URL en .env (no commiteado).
- Smoke test exitoso: tools+tool_choice nativos andan con deepseek-chat.

Nuevo: src/modules/3-turn-engine/agent/quantityParser.js
- Parser determinista que pre-procesa la query del usuario para extraer
  cantidad+unidad ANTES del LLM. Resultado va al agente como side-channel
  (working_memory.preparsed); el agente puede sobreescribirlo.
- Cubre AR-es: fracciones (1/4 kg, 3/4), frases compuestas (media docena,
  cuarto kilo, tres cuartos, medio kilo, par, docena), numéricos pegados
  (2kg, 0.5kg, 500g, 2,5 kilos), numéricos solos, palabras + unidad.
- Confidence escalonado: fraction 0.95, phrase/numeric+unit 0.9,
  word+unit 0.85, numeric solo 0.7.

Tests: 46/46 pasan, incluyen casos de WhatsApp real, casos negativos,
edge cases (división por cero, string vacío, decimales con coma).

Total suite: 238/238 (192 previos + 46 quantity).

Próximos pasos del plan: D1 workingMemory.js + runTurn skeleton, D2 tools
cart, D3 tools shipping/checkout, D4 customerProfile, D5 catalog fallback,
D6 system prompt + tuning, D7 persistencia, D8 shadow validation,
D9 cleanup legacy, D10 hardening. Plan completo en
~/.claude/plans/ok-creo-que-tiene-humming-sutton.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 12:31:59 -03:00
Lucas Tettamanti
6376739f48 Frontend: scroll fix, SSE reconnect, toast global, stale states, theming
5 bloques en una pasada:

A. Scroll fixes (síntoma reportado por el usuario):
   - run-timeline: nuevo flag _userScrolledUp con detección por rAF en
     bindScroll. Auto-scroll al final SOLO si el usuario está abajo
     (umbral 150px) o si él mismo disparó un optimistic bubble. Cuando
     el usuario manda mensaje, se resetea el flag y se scrollea.
   - conversation-inspector: mismo patrón. ui:chatScroll respeta el
     flag userScrolledUp del emisor para no perseguir al usuario que
     lee arriba.
   - .bubble: + min-width:0, overflow-wrap:anywhere para URLs/JSON
     largos sin espacios.
   - <pre>: + overflow-x:auto, max-width:100%.
   - chat-simulator: textarea con resize:vertical, min/max heights
     viewport-friendly. inputs-col con overflow-y:auto.

B. Stale state options:
   - conversation-list y conversations-crud: dropdowns ahora muestran
     IDLE / CART / SHIPPING / AWAITING_HUMAN. Quitados BROWSING,
     BUILDING_ORDER, WAITING_ADDRESS, WAITING_PAYMENT, COMPLETED.
   - main.js: simulated plan.next_state pasa a CART.

C. SSE resilience (lib/sse.js):
   - connect() con backoff exponencial (1s → 30s).
   - safeParse() helper: cada evento envuelve JSON.parse en try-catch
     para que un payload malformado no rompa otros listeners.
   - reset retryDelay al primer "hello" exitoso.
   - ops-shell: indicador con dot (verde "En vivo" / naranja pulsante
     "Reconectando…") en lugar de texto plano.

D. Toast service global (public/lib/toast.js):
   - API simple: toast({ kind, text, ms }). Apila, animación slide-in,
     auto-dismiss 4s. Click para cerrar.
   - safeFetch en api.js: wrapper que dispara toast en network error y
     non-OK. Migrados simEvolution + retryLast.
   - chat-simulator usa toast en lugar de status text efímero.

E. Theming con CSS vars:
   - public/styles/theme.css con paleta completa (panels, borders,
     text, accents, bubbles, charts, radii, shadows). Linkeado desde
     index.html.
   - Migrados a var(--*) los 5 componentes más visibles:
     run-timeline, chat-simulator, conversation-inspector,
     conversation-list, home-dashboard. Custom properties heredan a
     través del shadow DOM, así que los demás componentes pueden
     migrar gradualmente sin cambios estructurales.
   - home-dashboard ya tenía vars locales: ahora apuntan a las globales.

Backend: 192/192 tests pasando. Sin cambios de API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 03:25:03 -03:00
Lucas Tettamanti
7b6c62b23d 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>
2026-05-01 21:18:29 -03:00
Lucas Tettamanti
6b7889ef4e Mono-tenant: resolver id una vez al boot, eliminar lookups por turno
El sistema nunca fue realmente multi-tenant en la práctica. El esquema
DB conserva las columnas tenant_id (queda lista para escalar más adelante
sin migración), pero la app ahora resuelve el tenant una sola vez al
arranque y todas las capas leen de un único punto.

- src/modules/shared/tenant.js: nuevo módulo. setTenant() en boot,
  getTenantId() lo lee desde cualquier lado.
- index.js: ensureTenant() → setTenant({ id, key }). Sin cambios externos.
- pipeline.resolveTenantId(): pasa de hacer 1-2 queries a DB por turno
  a un return sincrónico del id cacheado. Mantiene firma async para no
  romper callers.
- intake handlers (sim.js, evolution.js): usan getTenantId() directo,
  sin parsing de tenant_key del chat_id ni lookup por canal.
- wooWebhooks: ya no requiere ?tenant_key=... en la query string.
  El webhook va al único tenant configurado.
- repo.js: eliminados getTenantByKey() y getTenantIdByChannel() (no más
  callers).

Plumbing del parámetro tenantId en signatures de handlers/repos/machine
queda intacto — bajar eso es ruido de alto riesgo y no aporta hoy.
188 tests pasando.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 21:00:22 -03:00
Lucas Tettamanti
17cea4aa9e Eliminar payment + waiting (legacy): el bot toma pedidos, no cobra
El bot conversacional no maneja pagos. Su trabajo: pedidos, datos de
entrega, dejar la orden anotada en Woo (status=pending). El cobro lo
gestiona el comercio offline. Todo lo de payment_type / is_paid /
PAYMENT / WAITING_WEBHOOKS era legacy de un flow viejo que se baja.

Nuevo flow: IDLE → CART → SHIPPING → IDLE (con orden creada).
Cuando el usuario completa shipping (pickup elegido OR delivery+address),
shipping.js emite create_order y el bot cierra con order.confirmed.

- fsm.js: 4 estados (IDLE/CART/SHIPPING/AWAITING_HUMAN). hasPaymentInfo
  e isPaid eliminados. deriveNextState gira SHIPPING→IDLE en vez de
  →PAYMENT→WAITING. ALLOWED transitions actualizadas.
- orderModel.js: createEmptyOrder() sin payment_type/is_paid.
  migrateOldContext deja de leer payment_method / mp.payment_status.
- stateHandlers: payment.js y waiting.js eliminados. shipping.js gana
  finalizeOrder() que emite create_order action y vuelve a IDLE.
- replyTemplates: payment.* y waiting.* fuera. order.confirmed nuevo,
  con 3 variantes y rewriter habilitado.
- NLU openai.js + nlu/schemas.js: select_payment fuera del enum, payment_method
  fuera de entities. Prompt sin la regla de SELECCIONAR PAGO.
- nlu/router.js + nlu/index.js: dominio "payment" eliminado.
  shouldSkipRouter ya no chequea PAYMENT.
- nlu/specialists/payment.js: eliminado.
- promptsRepo.js + promptLoader.js: PROMPT_KEYS sin "payment".
- turnEngineV3.js: switch ya no dispatcha a PAYMENT/WAITING. normalizeState
  mapea estados legacy (PAYMENT/WAITING_WEBHOOKS/COMPLETED) a IDLE.
  context_patch ya no emite payment_method.
- wooOrders.createOrder: paymentMethod param eliminado. Order queda en
  status=pending sin payment_method (cobro offline).
- pipeline.js: paymentMethod fuera del create_order glue. Invariant
  "no_checkout_without_payment_link" eliminado. signal payment_selected
  reemplazado por shipping_completed.
- XState machine: top-level PAYMENT y WAITING eliminados. SELECT_PAYMENT
  event fuera. SHIPPING ahora cierra con enqueueWooCreateOrder +
  replyOrderConfirmed → IDLE. Guards hasPayment/isPaid borrados.
- Tests fsm.test.js / orderModel.test.js / machine/index.test.js
  actualizados al nuevo contrato. 188 tests pasando.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 20:53:19 -03:00
Lucas Tettamanti
04ac33430f Tier 2: XState statechart como motor de turno (opt-in)
Reemplaza el dispatcher en turnEngineV3.js por un statechart formal en
XState v5. La machine es pura: produce un effect log (pending_actions) +
un descriptor de reply (pending_reply) que el runner traduce afuera.

API externa intacta: runTurnV3 sigue retornando { plan, decision } con
shape compatible con pipeline.js. Snapshot persiste en
context.xstate_snapshot dentro del JSONB existente.

- machine/index.js: statechart top-level (idle/cart/shipping/payment/
  waiting/awaiting_human) + cart sub-statechart con todo el flujo
  multi-turno (searching/resolving/askingClarification/askingQuantity/
  computingFromPersonas/added/showing/pricing/researching).
- guards.js: portados de fsm.js (hasCart, wantsToAddProduct, etc).
- actions.js: assigns para mutations + reply descriptors (pending_reply
  con templateKey/vars/rawText). Las async no entran en la machine.
- actors.js: fromPromise wrappers de retrieveCandidates y getProductQtyRules.
- runner.js: boot con prev_context.xstate_snapshot o migrateOldContext.
  NLU → nluToEvent → send → settle (espera invokes) → realizeReply
  (renderReply real con rewriter) → getPersistedSnapshot → format.
- nluToEvent.js: adapter NLU intent → evento XState (1:1).

Feature flags: USE_XSTATE=1 reemplaza el path; XSTATE_SHADOW=1 corre
ambos en paralelo, devuelve legacy y loguea diffs estructurales para
validar antes de flippar prod.

16 unit tests para la machine cubren: arranque, regla universal cart-on-add,
flow de cart con strong/multi match, checkout completo (shipping/pickup/
payment/cash) y rehidratación de snapshot. 224 tests totales pasando.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 20:38:26 -03:00
Lucas Tettamanti
f784ddd62d Tier 1: chat quality — fuzzy aliases, reply templates, dedup, rewriter
Foco: matar repetición y adaptar respuestas. Los handlers tenían ~30 strings
hardcodeadas (3-7 lugares cada una). Aliases hacían substring exacto.

- pg_trgm + GIN indexes en product_aliases / alias_product_mappings.
  Captura plurales, diminutivos, typos sin reglas. catalogRetrieval re-busca
  el snapshot con normalized_alias cuando el query original no rinde
  (vasio→vacio→Vacío).
- reply_templates table + replyTemplates.js. 20 keys, 2-3 variantes c/u
  con DEFAULTS hardcodeados como fallback. pickVariant excluye las usadas
  en context.recent_replies (FIFO cap 8). Wired en idle/cart/cartHelpers/
  shipping/payment/waiting.
- failed_searches counter en context. count>=3 escala via humanFallback.
  Reset en cada add_to_cart exitoso.
- storeContext.js: vars derivadas de getStoreConfig (delivery_zones, hours,
  zonas) listas para inyectar en templates cuando los datos se carguen.
- replyRewriter.js: LLM call opcional (REPLY_REWRITER=1) que adapta el
  template al hilo conversacional. 1.5s timeout, fallback al template puro.
  Sólo activo en 8 slots semánticamente importantes.
- 12 unit tests para replyTemplates (rotation, recency, FIFO, vars).
  208 tests totales pasando.

Plan completo: ~/.claude/plans/ok-creo-que-tiene-humming-sutton.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 19:29:02 -03:00
Lucas Tettamanti
525679cf8b local dev setup + OPENAI_BASE_URL support + dashboard fix
- CLAUDE.md con arquitectura y comandos del proyecto
- env.example: agregar LIMIT_CONVERSATIONS, MAX_CHARS_PER_MESSAGE, OPENAI_BASE_URL
- docker-compose.override: puerto 3001, extra_hosts para modelo local en Linux
- OpenAI clients: soporte OPENAI_BASE_URL para apuntar a modelo local compatible
- stats.js: sync de órdenes en background, dashboard no bloquea al cargar
- package-lock: dbmate movido a prod dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 18:32:22 -03:00
Lucas Tettamanti
b933db88df remove database_url 2026-02-04 18:36:28 -03:00
Lucas Tettamanti
d8a0677912 more work with Dockerfile and dbmate 2026-02-04 18:16:32 -03:00
Lucas Tettamanti
f838603877 Docker compose and coolify solved 2026-02-04 17:59:30 -03:00
Lucas Tettamanti
5e79f17d00 20260204 2026-02-04 16:06:51 -03:00
Lucas Tettamanti
2f8e267268 docker compose override for local without affecting coolify 2026-01-27 03:13:44 -03:00
Lucas Tettamanti
1e84d19db8 configs 2026-01-27 02:59:31 -03:00
Lucas Tettamanti
df9420b954 dashboard 2026-01-27 02:41:39 -03:00
134 changed files with 8740 additions and 9869 deletions

View File

@@ -0,0 +1 @@
{"sessionId":"b4e65d8e-b477-417b-9390-f3d14033ef91","pid":3138376,"procStart":"71699559","acquiredAt":1777673126273}

18
.cursor/debug.log Normal file
View File

@@ -0,0 +1,18 @@
{"location":"orders-crud.js:row.onclick","message":"Row clicked","data":{"datasetOrderId":"40923"},"timestamp":1770234345333,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"orders-crud.js:selectOrder","message":"selectOrder called","data":{"orderId":"40923","updateUrl":true},"timestamp":1770234345334,"sessionId":"debug-session","hypothesisId":"D"}
{"location":"orders-crud.js:typeCheck","message":"Type comparison","data":{"orderIdType":"number","orderId":40923,"firstOrderIdType":"string","firstOrderId":"40928","strictEqual":false,"looseEqual":false},"timestamp":1770234345334,"sessionId":"debug-session","hypothesisId":"C2"}
{"location":"orders-crud.js:findOrder","message":"Order lookup","data":{"orderId":40923,"found":true,"ordersCount":50},"timestamp":1770234345334,"sessionId":"debug-session","hypothesisId":"C"}
{"location":"orders-crud.js:parseInt","message":"Parsed orderId","data":{"orderId":40923,"isNaN":false},"timestamp":1770234345334,"sessionId":"debug-session","hypothesisId":"B"}
{"location":"orders-crud.js:renderDetail","message":"renderDetail called","data":{"hasContainer":true,"hasSelectedOrder":true,"selectedOrderId":"40923"},"timestamp":1770234345341,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"orders-crud.js:row.onclick","message":"Row clicked","data":{"datasetOrderId":"40925"},"timestamp":1770234346128,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"orders-crud.js:parseInt","message":"Parsed orderId","data":{"orderId":40925,"isNaN":false},"timestamp":1770234346129,"sessionId":"debug-session","hypothesisId":"B"}
{"location":"orders-crud.js:typeCheck","message":"Type comparison","data":{"orderIdType":"number","orderId":40925,"firstOrderIdType":"string","firstOrderId":"40928","strictEqual":false,"looseEqual":false},"timestamp":1770234346129,"sessionId":"debug-session","hypothesisId":"C2"}
{"location":"orders-crud.js:findOrder","message":"Order lookup","data":{"orderId":40925,"found":true,"ordersCount":50},"timestamp":1770234346129,"sessionId":"debug-session","hypothesisId":"C"}
{"location":"orders-crud.js:selectOrder","message":"selectOrder called","data":{"orderId":"40925","updateUrl":true},"timestamp":1770234346129,"sessionId":"debug-session","hypothesisId":"D"}
{"location":"orders-crud.js:renderDetail","message":"renderDetail called","data":{"hasContainer":true,"hasSelectedOrder":true,"selectedOrderId":"40925"},"timestamp":1770234346135,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"orders-crud.js:row.onclick","message":"Row clicked","data":{"datasetOrderId":"40921"},"timestamp":1770234346848,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"orders-crud.js:typeCheck","message":"Type comparison","data":{"orderIdType":"number","orderId":40921,"firstOrderIdType":"string","firstOrderId":"40928","strictEqual":false,"looseEqual":false},"timestamp":1770234346849,"sessionId":"debug-session","hypothesisId":"C2"}
{"location":"orders-crud.js:findOrder","message":"Order lookup","data":{"orderId":40921,"found":true,"ordersCount":50},"timestamp":1770234346849,"sessionId":"debug-session","hypothesisId":"C"}
{"location":"orders-crud.js:selectOrder","message":"selectOrder called","data":{"orderId":"40921","updateUrl":true},"timestamp":1770234346849,"sessionId":"debug-session","hypothesisId":"D"}
{"location":"orders-crud.js:parseInt","message":"Parsed orderId","data":{"orderId":40921,"isNaN":false},"timestamp":1770234346849,"sessionId":"debug-session","hypothesisId":"B"}
{"location":"orders-crud.js:renderDetail","message":"renderDetail called","data":{"hasContainer":true,"hasSelectedOrder":true,"selectedOrderId":"40921"},"timestamp":1770234346855,"sessionId":"debug-session","hypothesisId":"E"}

124
CLAUDE.md Normal file
View File

@@ -0,0 +1,124 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Development
npm run dev # Start with nodemon auto-reload
npm start # Production start
# Testing
npm test # Run all tests once (vitest run)
npm run test:watch # Watch mode
npm run test:coverage
# Run a single test file
npx vitest run src/modules/3-turn-engine/orderModel.test.js
# Database migrations (requires DATABASE_URL in .env)
npm run migrate:up
npm run migrate:down
npm run migrate:redo
npm run migrate:status
npm run seed # Seed a tenant via scripts/seed-tenant.mjs
```
No lint command is configured.
## Product goal
The bot must be **conversational and intelligent**, not a menu-driven flow. Customers reach out via WhatsApp **with intent to buy** — the bot's job is to:
1. **Engage in conversation** — answer questions about products, prices, availability/stock; recommend; clarify.
2. **Take orders** — build a cart through natural dialogue (multi-product turns, quantities, units).
3. **Collect delivery data** — address, delivery vs pickup, payment method.
4. **Operate within store rules** — delivery zones, days/hours, pickup windows. These config tables (`delivery_zones`, store schedule in `tenant_settings`) will be populated later; the bot has to read and respect them when present.
Repetitive, hardcoded responses are a known quality problem and the focus of the active improvement plan (see `~/.claude/plans/ok-creo-que-tiene-humming-sutton.md`). The system is **not yet in production** — refactors that change behavior are acceptable.
## Architecture
This is a **mono-tenant WhatsApp e-commerce chatbot** powered by Express.js. The store operator hooks the bot to a single WooCommerce shop; customers interact via WhatsApp to browse products, build carts, and place orders.
The DB schema retains `tenant_id` columns (it was originally multi-tenant) but the app boots with a single tenant resolved at startup. The single id is exposed via `src/modules/shared/tenant.js` (`getTenantId()`); webhook handlers and intake routes read from there instead of looking up tenants per-request.
### Request flow
```
WhatsApp → Evolution API webhook → /webhook/evolution (or /sim/send)
1-intake: route & normalize message
2-identity/pipeline.processMessage (idempotency, history, side effects)
3-turn-engine/agent: tool-calling LLM loop
Response persisted to DB + sent back via Evolution API
```
### Turn engine: tool-calling agent (DeepSeek)
`src/modules/3-turn-engine/agent/` es el único motor. Cada turno arma un **WorkingMemory** (cart, pending, last_shown_options, store, history truncado, customer_profile, preparsed quantity) y se lo pasa al LLM como user message. El LLM decide qué tools llamar:
- `search_catalog`, `add_to_cart`, `set_quantity`, `select_candidate`, `remove_from_cart`
- `set_shipping`, `set_address`, `confirm_order`
- `pause`, `escalate_to_human`
- `say` (último siempre — es el reply al usuario)
El system prompt es **estático** (en `agent/systemPrompt.js` como `SYSTEM_PROMPT` const) para que DeepSeek lo cachée prefix-cache automáticamente. Cache hit ratio típico ≥70% después de 2 turnos. El parser de cantidades (`agent/quantityParser.js`) preprocesa el texto y se pasa como `working_memory.preparsed` (fracciones, "media docena", "cuarto kilo", etc.).
La FSM (`fsm.js`) sigue siendo guardrail: estados `IDLE / CART / SHIPPING / PAUSED / AWAITING_HUMAN` con transiciones validadas. PAUSED tiene TTL 7d (cart preservado para "después te digo").
### Module structure (numbered layers)
- **`src/modules/0-UI/`** — Admin dashboard: REST controllers para products, conversations, settings, takeovers, recommendations, aliases.
- **`src/modules/1-intake/`** — Message ingestion. Routes: `/simulator` (dev UI), `/webhook/evolution` (WhatsApp).
- **`src/modules/2-identity/`** — User mapping (WhatsApp ↔ WooCommerce customer), encrypted WooCommerce credentials, pipeline orchestrator.
- **`src/modules/3-turn-engine/`** — Agente tool-calling (`agent/`), FSM (`fsm.js`), order model (`orderModel.js`), catalog retrieval (`catalogRetrieval.js`), store context (`storeContext.js`).
- **`src/modules/4-woo-orders/`** — WooCommerce order sync (lectura). El bot crea orders nuevas vía `wooOrders.createOrder` desde `pipeline.js` cuando emite la action `create_order`.
- **`src/modules/shared/`** — DB pool, SSE, WooSnapshot, tenant resolver (`getTenantId()`), debug.
### Key integrations
| System | Purpose | Config |
|--------|---------|--------|
| LLM (DeepSeek) | Agente tool-calling — único motor | `OPENAI_API_KEY`, `OPENAI_BASE_URL=https://api.deepseek.com/v1`, `OPENAI_MODEL=deepseek-chat` |
| Evolution API | WhatsApp send/receive | `EVOLUTION_*`, `EVOLUTION_SEND_ENABLED` |
| WooCommerce REST API | Products, orders, customers | `WOO_BASE_URL`, `WOO_CONSUMER_KEY`, `WOO_CONSUMER_SECRET` |
| PostgreSQL | Primary database | `DATABASE_URL` |
### Database
Migrations live in `db/migrations/` as timestamped SQL files managed by `dbmate`. Key tables:
- `tenants`, `tenant_config`, `tenant_settings`, `tenant_ecommerce_config`, `tenant_channels`
- `wa_identity_map` — WhatsApp ↔ WooCommerce customer mapping
- `wa_conversation_state` — FSM state + context (cart, pending, last_shown_options, paused_until) en JSONB
- `wa_messages` — Message history (idempotencia por message_id)
- `woo_products_snapshot` — Cached product catalog (con índices pg_trgm en aliases)
- `product_aliases`, `alias_product_mappings` — fuzzy alias resolution
- `woo_orders_cache` + `woo_order_items` — orders sync para customer_profile / stats
- `human_takeovers`, `audit_log`, `conversation_runs`
### Feature flags (env vars)
- `AGENT_MAX_TOOL_CALLS=10` — cap de tool calls por turno
- `AGENT_TURN_TIMEOUT_MS=25000` — timeout total del turno
- `EVOLUTION_SEND_ENABLED=1` — enviar a WhatsApp real (off en dev)
- `DEBUG_PERF`, `DEBUG_WOO_HTTP`, `DEBUG_LLM`, `DEBUG_EVOLUTION` — debug logs granular
### Métricas
- `GET /api/metrics/agent` — turns, avg tool calls, fallback rate, escalations, **cache_hit_ratio** (prompt caching de DeepSeek)
### Local development
Copy `env.example` to `.env` and fill in values. Use `docker-compose.override.yaml` for local overrides. Run `docker compose up` to start app + Postgres + Redis. The Dockerfile runs migrations automatically on startup (`migrate:up && seed && start`).
Test files use Vitest with `globals: true` — no need to import `describe`, `it`, `expect`.

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-alpine
WORKDIR /usr/src/app
# Copiar archivos de dependencias
COPY package*.json ./
# Instalar dependencias de producción
RUN npm ci --only=production
# Copiar código fuente
COPY . .
# Puerto de la aplicación
EXPOSE 3000
# Ejecutar migraciones, seed y luego iniciar la app
CMD ["sh", "-c", "npm run migrate:up && npm run seed && npm start"]

View File

@@ -0,0 +1,90 @@
-- migrate:up
-- Tabla de cache de pedidos de WooCommerce
-- Almacena pedidos localmente para estadísticas y listado rápido
CREATE TABLE woo_orders_cache (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
woo_order_id BIGINT NOT NULL,
status VARCHAR(50),
total NUMERIC(12,2),
currency VARCHAR(10) DEFAULT 'ARS',
date_created TIMESTAMPTZ NOT NULL,
date_paid TIMESTAMPTZ,
-- Filtros del dashboard
source VARCHAR(20) DEFAULT 'web', -- 'whatsapp' | 'web'
is_delivery BOOLEAN DEFAULT false,
is_cash BOOLEAN DEFAULT false,
-- Cliente
customer_name VARCHAR(255),
customer_phone VARCHAR(50),
customer_email VARCHAR(255),
-- Dirección de envío (para futuro mapa de calor)
shipping_address_1 VARCHAR(255),
shipping_address_2 VARCHAR(255),
shipping_city VARCHAR(100),
shipping_state VARCHAR(100),
shipping_postcode VARCHAR(20),
shipping_country VARCHAR(10) DEFAULT 'AR',
-- Dirección de facturación
billing_address_1 VARCHAR(255),
billing_city VARCHAR(100),
billing_state VARCHAR(100),
billing_postcode VARCHAR(20),
-- Raw para debugging
raw JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, woo_order_id)
);
CREATE INDEX idx_woo_orders_tenant_date ON woo_orders_cache(tenant_id, date_created DESC);
CREATE INDEX idx_woo_orders_source ON woo_orders_cache(tenant_id, source);
CREATE INDEX idx_woo_orders_city ON woo_orders_cache(tenant_id, shipping_city);
-- Tabla de detalle de items (productos por pedido)
-- Permite calcular stats por producto (kg vendidos, unidades, facturación)
CREATE TABLE woo_order_items (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
woo_order_id BIGINT NOT NULL,
woo_product_id BIGINT,
-- Datos del producto
product_name VARCHAR(255) NOT NULL,
sku VARCHAR(100),
-- Cantidades y precios
quantity NUMERIC(10,3) NOT NULL, -- Soporta decimales para kg
unit_price NUMERIC(12,2), -- Precio unitario
line_total NUMERIC(12,2), -- quantity * unit_price
-- Tipo de unidad (para stats de kg vs unidades)
sell_unit VARCHAR(20) DEFAULT 'unit', -- 'kg' | 'unit' | 'pack'
created_at TIMESTAMPTZ DEFAULT NOW(),
FOREIGN KEY (tenant_id, woo_order_id)
REFERENCES woo_orders_cache(tenant_id, woo_order_id) ON DELETE CASCADE
);
CREATE INDEX idx_woo_items_order ON woo_order_items(tenant_id, woo_order_id);
CREATE INDEX idx_woo_items_product ON woo_order_items(tenant_id, woo_product_id);
-- migrate:down
DROP INDEX IF EXISTS idx_woo_items_product;
DROP INDEX IF EXISTS idx_woo_items_order;
DROP TABLE IF EXISTS woo_order_items;
DROP INDEX IF EXISTS idx_woo_orders_city;
DROP INDEX IF EXISTS idx_woo_orders_source;
DROP INDEX IF EXISTS idx_woo_orders_tenant_date;
DROP TABLE IF EXISTS woo_orders_cache;

View File

@@ -0,0 +1,21 @@
-- migrate:up
-- Eliminar la tabla mp_payments (integración de MercadoPago removida)
drop table if exists mp_payments;
-- migrate:down
-- Recrear la tabla si se necesita rollback
create table if not exists mp_payments (
tenant_id uuid not null references tenants(id) on delete cascade,
woo_order_id bigint null,
preference_id text null,
payment_id text null,
status text null,
paid_at timestamptz null,
raw jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (tenant_id, payment_id)
);
create index if not exists mp_payments_tenant_order_idx
on mp_payments (tenant_id, woo_order_id);

View File

@@ -0,0 +1,7 @@
-- migrate:up
-- Agregar columna delivery_zones para configurar zonas de entrega por barrio CABA
ALTER TABLE tenant_settings
ADD COLUMN IF NOT EXISTS delivery_zones JSONB DEFAULT '{}';
-- migrate:down
ALTER TABLE tenant_settings DROP COLUMN IF EXISTS delivery_zones;

View File

@@ -0,0 +1,13 @@
-- migrate:up
-- Crear tenant Piaf (sin credenciales sensibles - esas van por variable de entorno)
INSERT INTO tenants (id, key, name)
VALUES (
'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid,
'piaf',
'Piaf'
)
ON CONFLICT (id) DO NOTHING;
-- migrate:down
DELETE FROM tenants WHERE id = 'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid;

View File

@@ -0,0 +1,17 @@
-- migrate:up
-- pg_trgm para fuzzy matching de aliases:
-- - Captura plurales (vacio↔vacios), diminutivos (costillita↔costilla),
-- typos (vasio↔vacio) sin escribir reglas.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX IF NOT EXISTS product_aliases_norm_trgm_idx
ON product_aliases USING gin (normalized_alias gin_trgm_ops);
CREATE INDEX IF NOT EXISTS alias_product_mappings_alias_trgm_idx
ON alias_product_mappings USING gin (alias gin_trgm_ops);
-- migrate:down
DROP INDEX IF EXISTS alias_product_mappings_alias_trgm_idx;
DROP INDEX IF EXISTS product_aliases_norm_trgm_idx;
-- Intencionalmente NO se hace DROP EXTENSION pg_trgm:
-- puede ser usada por otras consultas/migraciones futuras.

View File

@@ -0,0 +1,24 @@
-- migrate:up
-- Templates de respuestas con variantes para evitar repetición.
-- Filosofía: cada slot semántico (ej. cart.ask_more) tiene N variantes;
-- el código rota entre ellas excluyendo las recientemente usadas.
CREATE TABLE reply_templates (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
template_key VARCHAR(80) NOT NULL,
variant INTEGER NOT NULL DEFAULT 1,
content TEXT NOT NULL,
weight INTEGER NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_reply_variant UNIQUE(tenant_id, template_key, variant)
);
CREATE INDEX idx_reply_active
ON reply_templates(tenant_id, template_key)
WHERE is_active = true;
-- migrate:down
DROP INDEX IF EXISTS idx_reply_active;
DROP TABLE IF EXISTS reply_templates;

View File

@@ -0,0 +1,88 @@
-- 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'),
pickup_enabled = true,
schedule = '{
"pickup": {
"lun": {"enabled": true, "start": "09:00", "end": "20:00"},
"mar": {"enabled": true, "start": "09:00", "end": "20:00"},
"mie": {"enabled": true, "start": "09:00", "end": "20:00"},
"jue": {"enabled": true, "start": "09:00", "end": "20:00"},
"vie": {"enabled": true, "start": "09:00", "end": "20:00"},
"sab": {"enabled": true, "start": "09:00", "end": "13:00"}
}
}'::jsonb,
delivery_zones = '{}'::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;

View File

@@ -0,0 +1,13 @@
-- migrate:up
-- Limpiar formato legacy de delivery_zones (caba.barrios, flat) y dejar solo
-- el schema nuevo { zones: [...] }. Pre-prod: no preservamos data legacy.
UPDATE tenant_settings
SET delivery_zones = '{}'::jsonb
WHERE
delivery_zones IS NULL
OR NOT (delivery_zones ? 'zones')
OR jsonb_typeof(delivery_zones->'zones') <> 'array';
-- migrate:down
-- noop: no preservamos el formato legacy.
SELECT 1;

View File

@@ -0,0 +1,18 @@
-- migrate:up
-- Borrar columnas obsoletas de tenant_settings: ahora cada zona de delivery
-- tiene su propio costo, días y rango horario. Schedule.delivery también queda
-- obsoleto (sólo pickup hace sentido como horario único de la tienda física).
ALTER TABLE tenant_settings
DROP COLUMN IF EXISTS delivery_enabled,
DROP COLUMN IF EXISTS delivery_days,
DROP COLUMN IF EXISTS delivery_hours_start,
DROP COLUMN IF EXISTS delivery_hours_end,
DROP COLUMN IF EXISTS delivery_min_order;
UPDATE tenant_settings
SET schedule = (schedule - 'delivery')
WHERE schedule ? 'delivery';
-- migrate:down
-- noop: no preservamos los campos legacy.
SELECT 1;

View File

@@ -0,0 +1,17 @@
# Override local: expone puertos para desarrollo
# Este archivo se aplica automáticamente con `docker compose up`
# Coolify ignora este archivo y usa solo docker-compose.yaml
services:
app:
ports:
- "3001:3000"
env_file:
- .env
environment:
- NODE_ENV=development
command: sh -c "npm install && npm run dev"
volumes:
- .:/usr/src/app
- /usr/src/app/node_modules
extra_hosts:
- "host.docker.internal:host-gateway"

View File

@@ -1,20 +1,18 @@
services:
app:
image: node:20-alpine
build: .
working_dir: /usr/src/app
command: sh -c "npm install && npm run dev"
ports:
- "3000:3000"
env_file:
- .env
expose:
- "3000"
environment:
- NODE_ENV=development
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=postgres://${POSTGRES_USER:-botino}:${POSTGRES_PASSWORD:-botino}@db:5432/${POSTGRES_DB:-botino}
- REDIS_URL=redis://redis:6379
volumes:
- .:/usr/src/app
- /usr/src/app/node_modules
# Variables para seed (configurar en Coolify)
- APP_ENCRYPTION_KEY=${APP_ENCRYPTION_KEY:-}
- WOO_CONSUMER_KEY=${WOO_CONSUMER_KEY:-}
- WOO_CONSUMER_SECRET=${WOO_CONSUMER_SECRET:-}
- WOO_BASE_URL=${WOO_BASE_URL:-}
depends_on:
db:
condition: service_healthy
@@ -35,7 +33,11 @@ services:
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-botino}"]
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-botino} -d ${POSTGRES_DB:-botino}",
]
interval: 10s
timeout: 5s
retries: 5

View File

@@ -13,22 +13,17 @@ PG_CONN_TIMEOUT_MS=5000
APP_ENCRYPTION_KEY=your-32-char-encryption-key-here
# ===================
# OpenAI
# LLM (OpenAI-compatible: DeepSeek, OpenAI, Anthropic via gateway, etc.)
# ===================
# Default actual: DeepSeek V3.x con tool-calling nativo + prompt caching automático.
OPENAI_API_KEY=sk-xxx
OPENAI_MODEL=gpt-4o-mini
OPENAI_BASE_URL=https://api.deepseek.com/v1
OPENAI_MODEL=deepseek-chat
# ===================
# Turn Engine
# WooCommerce
# ===================
# v1 = pipeline actual (heurísticas + guardrails + LLM plan final)
# v2 = LLM-first NLU, deterministic core (nuevo motor)
TURN_ENGINE=v1
# ===================
# WooCommerce (fallback si falta config por tenant)
# ===================
WOO_BASE_URL=https://tu-tienda.com
WOO_BASE_URL=https://tu-tienda.com/wp-json/wc/v3
WOO_CONSUMER_KEY=ck_xxx
WOO_CONSUMER_SECRET=cs_xxx
@@ -40,6 +35,18 @@ EVOLUTION_API_KEY=your-api-key
EVOLUTION_INSTANCE_NAME=piaf
EVOLUTION_SEND_ENABLED=0 # 0=solo BD (pruebas), 1=envía a WhatsApp (producción)
# ===================
# Limits
# ===================
LIMIT_CONVERSATIONS=100
MAX_CHARS_PER_MESSAGE=4000
# ===================
# Agent (tool-calling) — único motor de turno
# ===================
AGENT_MAX_TOOL_CALLS=10
AGENT_TURN_TIMEOUT_MS=25000
# ===================
# Debug Flags (1/true/yes/on para activar)
# ===================

View File

@@ -1,10 +1,10 @@
import "dotenv/config";
import { ensureTenant } from "./src/modules/2-identity/db/repo.js";
import { setTenant } from "./src/modules/shared/tenant.js";
import { createApp } from "./src/app.js";
async function configureUndiciDispatcher() {
// Node 18+ usa undici debajo de fetch. Esto suele arreglar timeouts fantasma por keep-alive/pooling.
// Nota: si el módulo `undici` no está disponible, no rompemos el arranque (solo logueamos warning).
// Node 18+ usa undici debajo de fetch. Esto suele arreglar timeouts "fantasma" por keep-alive/pooling.
try {
const { setGlobalDispatcher, Agent } = await import("undici");
setGlobalDispatcher(
@@ -21,21 +21,14 @@ async function configureUndiciDispatcher() {
}
}
/**
* --- Tenant ---
*/
const TENANT_KEY = process.env.TENANT_KEY || "piaf";
let TENANT_ID = null;
/**
* --- Boot ---
*/
const port = process.env.PORT || 3000;
(async function boot() {
await configureUndiciDispatcher();
TENANT_ID = await ensureTenant({ key: TENANT_KEY, name: TENANT_KEY.toUpperCase() });
const app = createApp({ tenantId: TENANT_ID });
const tenantId = await ensureTenant({ key: TENANT_KEY, name: TENANT_KEY.toUpperCase() });
setTenant({ id: tenantId, key: TENANT_KEY });
const app = createApp({ tenantId });
app.listen(port, () => console.log(`UI: http://localhost:${port} (tenant=${TENANT_KEY})`));
})().catch((err) => {
console.error("Boot failed:", err);

144
package-lock.json generated
View File

@@ -12,16 +12,18 @@
"ajv": "^8.17.1",
"cors": "^2.8.5",
"csv-parse": "^6.1.0",
"dbmate": "^2.0.0",
"dotenv": "^17.2.3",
"express": "^4.19.2",
"mysql2": "^3.16.2",
"openai": "^6.15.0",
"pg": "^8.16.3",
"undici": "^7.16.0",
"xstate": "^5.31.0",
"zod": "^4.3.4"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.0.18",
"dbmate": "^2.0.0",
"nodemon": "^3.0.3",
"vitest": "^4.0.18"
}
@@ -93,7 +95,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -107,7 +108,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -121,7 +121,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -135,7 +134,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -149,7 +147,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -163,7 +160,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -177,7 +173,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1249,6 +1244,15 @@
"js-tokens": "^9.0.1"
}
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1456,7 +1460,6 @@
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/dbmate/-/dbmate-2.28.0.tgz",
"integrity": "sha512-kbJ+Aqna/SOsS86RuimX8X/qmo9ItG00EYMUfUQmo3xGoXBac4+PZwOYV0fVPIGakIRljNolhfJySkPAFjmZsA==",
"dev": true,
"license": "MIT",
"bin": {
"dbmate": "dist/cli.js"
@@ -1480,6 +1483,15 @@
"ms": "2.0.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1795,6 +1807,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -1998,6 +2019,12 @@
"node": ">=0.12.0"
}
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@@ -2073,6 +2100,27 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru.min": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz",
"integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2199,6 +2247,54 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/mysql2": {
"version": "3.16.2",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.16.2.tgz",
"integrity": "sha512-JsqBpYNy7pH20lGfPuSyRSIcCxSeAIwxWADpV64nP9KeyN3ZKpHZgjKXuBKsh7dH6FbOvf1bOgoVKjSUPXRMTw==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.2",
"long": "^5.3.2",
"lru.min": "^1.1.3",
"named-placeholders": "^1.1.6",
"seq-queue": "^0.0.5",
"sqlstring": "^2.3.3"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/mysql2/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"license": "MIT",
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -2750,6 +2846,11 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
@@ -2882,6 +2983,15 @@
"node": ">= 10.x"
}
},
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -3046,9 +3156,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
"integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==",
"version": "7.19.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.19.1.tgz",
"integrity": "sha512-Gpq0iNm5M6cQWlyHQv9MV+uOj1jWk7LpkoE5vSp/7zjb4zMdAcUD+VL5y0nH4p9EbUklq00eVIIX/XcDHzu5xg==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
@@ -3295,6 +3405,16 @@
"node": ">=8"
}
},
"node_modules/xstate": {
"version": "5.31.0",
"resolved": "https://registry.npmjs.org/xstate/-/xstate-5.31.0.tgz",
"integrity": "sha512-5B+0DqC0uNUrcLUEY3pn3iNy+swvK2E0ZpYp5gnV3oxMX5y87vzXkU5YXv9CAtyG5c5FOJ1SzvTWHrwE8fMZNQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/xstate"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@@ -13,7 +13,8 @@
"migrate:up": "dbmate up",
"migrate:down": "dbmate down",
"migrate:redo": "dbmate rollback && dbmate up",
"migrate:status": "dbmate status"
"migrate:status": "dbmate status",
"seed": "node scripts/seed-tenant.mjs"
},
"keywords": [],
"author": "Lucas Tettamanti",
@@ -22,16 +23,18 @@
"ajv": "^8.17.1",
"cors": "^2.8.5",
"csv-parse": "^6.1.0",
"dbmate": "^2.0.0",
"dotenv": "^17.2.3",
"express": "^4.19.2",
"mysql2": "^3.16.2",
"openai": "^6.15.0",
"pg": "^8.16.3",
"undici": "^7.16.0",
"xstate": "^5.31.0",
"zod": "^4.3.4"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.0.18",
"dbmate": "^2.0.0",
"nodemon": "^3.0.3",
"vitest": "^4.0.18"
}

View File

@@ -1,4 +1,5 @@
import "./components/ops-shell.js";
import "./components/home-dashboard.js";
import "./components/run-timeline.js";
import "./components/chat-simulator.js";
import "./components/conversation-inspector.js";
@@ -9,9 +10,8 @@ import "./components/aliases-crud.js";
import "./components/recommendations-crud.js";
import "./components/quantities-crud.js";
import "./components/orders-crud.js";
import "./components/test-panel.js";
import "./components/prompts-crud.js";
import "./components/takeovers-crud.js";
import "./components/zone-map-editor.js";
import "./components/settings-crud.js";
import { connectSSE } from "./lib/sse.js";
import { initRouter } from "./lib/router.js";

View File

@@ -20,45 +20,45 @@ class AliasesCrud extends HTMLElement {
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
input, select, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:#1a5fd0; }
input, select, textarea { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
input:focus, select:focus, textarea:focus { outline:none; border-color:var(--accent); }
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:var(--accent-hover); }
button:disabled { opacity:.5; cursor:not-allowed; }
button.secondary { background:#253245; }
button.secondary:hover { background:#2d3e52; }
button.danger { background:#e74c3c; }
button.danger:hover { background:#c0392b; }
button.secondary { background:var(--border-hi); }
button.secondary:hover { background:var(--border-hi); }
button.danger { background:var(--err); }
button.danger:hover { background:var(--err); }
button.small { padding:4px 8px; font-size:11px; }
.list { flex:1; overflow-y:auto; }
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
.item:hover { border-color:#1f6feb; }
.item.active { border-color:#1f6feb; background:#111b2a; }
.item-alias { font-weight:600; color:#e7eef7; margin-bottom:4px; font-size:15px; }
.item-products { font-size:12px; color:#8aa0b5; }
.item-boost { color:#2ecc71; font-size:11px; }
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
.item:hover { border-color:var(--accent); }
.item.active { border-color:var(--accent); background:var(--accent-soft); }
.item-alias { font-weight:600; color:var(--text); margin-bottom:4px; font-size:15px; }
.item-products { font-size:12px; color:var(--text-muted); }
.item-boost { color:var(--ok); font-size:11px; }
.form { flex:1; overflow-y:auto; }
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
.form-empty { color:var(--text-muted); text-align:center; padding:40px; }
.field { margin-bottom:16px; }
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.field-hint { font-size:11px; color:#8aa0b5; margin-top:4px; }
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.field-hint { font-size:11px; color:var(--text-muted); margin-top:4px; }
.actions { display:flex; gap:8px; margin-top:16px; }
.loading { text-align:center; padding:40px; color:#8aa0b5; }
.loading { text-align:center; padding:40px; color:var(--text-muted); }
/* Product mappings table */
.mappings-table { width:100%; border-collapse:collapse; margin-top:8px; }
.mappings-table th { text-align:left; font-size:11px; color:#8aa0b5; padding:8px 4px; border-bottom:1px solid #253245; }
.mappings-table td { padding:6px 4px; border-bottom:1px solid #1e2a3a; vertical-align:middle; }
.mappings-table th { text-align:left; font-size:11px; color:var(--text-muted); padding:8px 4px; border-bottom:1px solid var(--border-hi); }
.mappings-table td { padding:6px 4px; border-bottom:1px solid var(--border); vertical-align:middle; }
.mappings-table input[type="number"] { width:70px; padding:6px 8px; font-size:12px; }
.mappings-table .product-name { font-size:13px; color:#e7eef7; }
.mappings-table .btn-remove { background:#e74c3c; padding:4px 8px; font-size:11px; }
.mappings-table .product-name { font-size:13px; color:var(--text); }
.mappings-table .btn-remove { background:var(--err); padding:4px 8px; font-size:11px; }
.add-mapping-row { display:flex; gap:8px; margin-top:12px; align-items:flex-end; }
.add-mapping-row .field { margin-bottom:0; }
@@ -67,19 +67,19 @@ class AliasesCrud extends HTMLElement {
.product-selector { position:relative; }
.product-dropdown {
position:absolute; top:100%; left:0; right:0; z-index:100;
background:#0f1520; border:1px solid #253245; border-radius:8px;
background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px;
max-height:200px; overflow-y:auto; display:none;
}
.product-dropdown.open { display:block; }
.product-option {
padding:8px 12px; cursor:pointer; font-size:13px; color:#e7eef7;
padding:8px 12px; cursor:pointer; font-size:13px; color:var(--text);
display:flex; justify-content:space-between; align-items:center;
}
.product-option:hover { background:#1a2535; }
.product-option .price { font-size:11px; color:#8aa0b5; }
.product-option:hover { background:var(--panel-2); }
.product-option .price { font-size:11px; color:var(--text-muted); }
.empty-hint { color:#8aa0b5; font-size:12px; font-style:italic; }
.badge { display:inline-block; padding:2px 6px; border-radius:999px; font-size:10px; background:#253245; color:#8aa0b5; margin-left:4px; }
.empty-hint { color:var(--text-muted); font-size:12px; font-style:italic; }
.badge { display:inline-block; padding:2px 6px; border-radius:999px; font-size:10px; background:var(--border-hi); color:var(--text-muted); margin-left:4px; }
</style>
<div class="container">

View File

@@ -1,6 +1,7 @@
import { api } from "../lib/api.js";
import { emit, on } from "../lib/bus.js";
import { modal } from "../lib/modal.js";
import { toast } from "../lib/toast.js";
class ChatSimulator extends HTMLElement {
constructor() {
@@ -10,25 +11,49 @@ class ChatSimulator extends HTMLElement {
this._sending = false;
this.shadowRoot.innerHTML = `
<style>
:host { display:block; height:100%; overflow:hidden; }
:host { display:block; height:100%; overflow:hidden; font-family: var(--font-sans); background: var(--panel); }
* { box-sizing:border-box; }
.container { display:grid; grid-template-columns:1fr 1fr; gap:0; height:100%; overflow:hidden; }
.col { display:flex; flex-direction:column; padding:10px 12px; border-right:1px solid #1e2a3a; min-width:0; overflow:hidden; }
.container { display:grid; grid-template-columns:1fr 1fr; gap:0; height:100%; min-height:0; overflow:hidden; }
.col {
display:flex; flex-direction:column;
padding: var(--space-4) var(--space-5);
border-right: 1px solid var(--border);
min-width:0; min-height:0; overflow:hidden;
}
.col:last-child { border-right:none; }
.muted { color:#8aa0b5; font-size:11px; margin-bottom:6px; }
.row { display:flex; gap:8px; align-items:center; }
input,textarea,button { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:6px; padding:6px 8px; font-size:12px; box-sizing:border-box; }
.muted { color: var(--text-muted); font-size: var(--fs-xs); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.06em; font-weight: var(--fw-semibold); }
.row { display:flex; gap: var(--space-2); align-items:center; }
input, textarea, button, select {
background: var(--panel);
color: var(--text);
border: 1px solid var(--border-hi);
border-radius: var(--r-md);
padding: 8px 12px;
font: 400 var(--fs-sm)/1.4 var(--font-sans);
box-sizing:border-box;
transition: border-color .15s, box-shadow .15s;
}
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); box-shadow: var(--focus-ring); }
input { width:100%; min-width:0; }
textarea { width:100%; resize:none; height:186px; min-width:0; margin-bottom:10px;}
button { cursor:pointer; white-space:nowrap; }
button.primary { background:#1f6feb; border-color:#1f6feb; }
button:disabled { opacity:.6; cursor:not-allowed; }
.status { font-size:11px; color:#8aa0b5; margin-top:6px; }
.inputs-col { display:flex; flex-direction:column; gap:6px; flex:1; overflow:hidden; min-width:0; }
textarea {
width:100%; resize:vertical;
min-height:120px; max-height:60vh;
min-width:0; margin-bottom: var(--space-3);
word-break:break-word; overflow-wrap:anywhere;
font-family: var(--font-mono);
font-size: var(--fs-sm);
}
button { cursor:pointer; white-space:nowrap; font-weight: var(--fw-medium); }
button:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-hover); }
button.primary { background: var(--accent); border-color: var(--accent); color: var(--text-on-acc); }
button.primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--text-on-acc); }
button:disabled { opacity:.5; cursor:not-allowed; }
.status { font-size: var(--fs-xs); color: var(--text-muted); margin-top: 8px; word-break:break-word; }
.inputs-col { display:flex; flex-direction:column; gap: 10px; flex:1; min-height:0; min-width:0; overflow-y:auto; padding-right: 4px; }
.field { display:flex; flex-direction:column; min-width:0; }
.field label { font-size:10px; color:#8aa0b5; margin-bottom:2px; }
.msg-col { display:flex; flex-direction:column; min-width:0; }
.msg-bottom { display:flex; gap:8px; align-items:center; margin-top:6px; }
.field label { font-size: var(--fs-xs); color: var(--text-muted); margin-bottom: 4px; font-weight: var(--fw-medium); }
.msg-col { display:flex; flex-direction:column; min-width:0; min-height:0; flex:1; }
.msg-bottom { display:flex; gap: var(--space-2); align-items:center; margin-top: 8px; flex-wrap:wrap; }
</style>
<div class="container">
@@ -167,14 +192,15 @@ class ChatSimulator extends HTMLElement {
console.log("[evolution sim] webhook response:", data);
if (!data.ok) {
statusEl.textContent = "Error enviando (ver consola)";
toast({ kind: "error", text: `Sim Evolution: ${data.error || "respuesta no-ok"}` });
return;
}
evoTextEl.value = "";
evoTextEl.focus();
} catch (e) {
statusEl.textContent = `Error: ${String(e?.message || e)}`;
// safeFetch ya disparó toast; sólo logueamos.
console.error("[chat-simulator] send error:", e);
} finally {
setSending(false);
}
@@ -187,10 +213,10 @@ class ChatSimulator extends HTMLElement {
api
.retryLast(chat_id)
.then((r) => {
if (!r?.ok) statusEl.textContent = `Retry error: ${r?.error || "unknown"}`;
else statusEl.textContent = "Retry enviado.";
if (!r?.ok) toast({ kind: "error", text: `Retry: ${r?.error || "fallo"}` });
else toast({ kind: "ok", text: "Retry enviado." });
})
.catch((e) => (statusEl.textContent = `Retry error: ${String(e?.message || e)}`))
.catch((e) => console.error("[chat-simulator] retry error:", e))
.finally(() => setSending(false));
};
retryEl.disabled = false;

View File

@@ -14,34 +14,68 @@ class ConversationInspector extends HTMLElement {
this._playing = false;
this._playIdx = 0;
this._timer = null;
this._userScrolledUp = false;
this._scrollRaf = null;
this.shadowRoot.innerHTML = `
<style>
:host { display:block; padding:12px; height:100%; overflow:hidden; }
.box { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; height:100%; display:flex; flex-direction:column; min-height:0; box-sizing:border-box; }
.row { display:flex; gap:8px; align-items:center; }
.muted { color:#8aa0b5; font-size:12px; }
.title { font-weight:800; }
.toolbar { display:flex; gap:8px; margin-top:8px; align-items:center; }
button { cursor:pointer; background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px; font-size:13px; }
.list { display:flex; flex-direction:column; gap:0; overflow-y:auto; padding-right:6px; margin-top:8px; flex:1; min-height:0; }
.item { border:1px solid #253245; border-radius:12px; padding:8px 10px; background:#0f1520; font-size:12px; margin-bottom:12px; box-sizing:border-box; }
.item.in { background:#0f1520; border-color:#2a3a55; }
.item.out { background:#111b2a; border-color:#2a3a55; }
.item.active { outline:2px solid #1f6feb; box-shadow: 0 0 0 2px rgba(31,111,235,.25); }
.item-row { display:flex; gap:8px; }
:host { display:block; padding: var(--space-4); height:100%; overflow:hidden; font-family: var(--font-sans); }
.box {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: var(--space-5);
height:100%; display:flex; flex-direction:column; min-height:0; box-sizing:border-box;
box-shadow: var(--shadow-sm);
}
.row { display:flex; gap: var(--space-2); align-items:center; }
.muted { color: var(--text-muted); font-size: var(--fs-sm); }
.title { font-weight: var(--fw-semibold); font-size: var(--fs-md); color: var(--text); }
.toolbar { display:flex; gap: var(--space-2); margin-top: var(--space-3); align-items:center; }
button {
cursor:pointer;
background: var(--panel); color: var(--text);
border: 1px solid var(--border-hi);
border-radius: var(--r-md);
padding: 6px 12px;
font: var(--fw-medium) var(--fs-sm)/1 var(--font-sans);
transition: all .15s;
}
button:hover { border-color: var(--accent); color: var(--accent-hover); background: var(--accent-soft); }
button:focus-visible { outline:none; box-shadow: var(--focus-ring); }
.list { display:flex; flex-direction:column; gap:0; overflow-y:auto; padding-right: 8px; margin-top: var(--space-3); flex:1; min-height:0; }
.item {
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 10px 12px;
background: var(--panel-2);
font-size: var(--fs-sm);
margin-bottom: var(--space-3);
box-sizing:border-box;
transition: border-color .15s;
}
.item.in { background: var(--panel-2); border-color: var(--border); }
.item.out { background: var(--bot-bubble); border-color: var(--bot-border); }
.item.active { outline: 2px solid var(--accent); outline-offset: 1px; }
.item-row { display:flex; gap: var(--space-2); }
.item-left { flex:1; min-width:0; }
.item-right { display:flex; flex-direction:column; gap:4px; align-items:flex-end; justify-content:flex-start; min-width:60px; }
.kv { display:grid; grid-template-columns:55px 1fr; gap:4px 6px; }
.k { color:#8aa0b5; font-size:10px; letter-spacing:.3px; text-transform:uppercase; }
.v { font-size:11px; color:#e7eef7; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.chips { display:flex; flex-direction:column; gap:3px; align-items:flex-end; }
.chip { display:inline-flex; align-items:center; gap:3px; padding:2px 5px; border-radius:999px; background:#1d2a3a; border:1px solid #243247; font-size:9px; color:#8aa0b5; white-space:nowrap; }
.item-right { display:flex; flex-direction:column; gap: 4px; align-items:flex-end; justify-content:flex-start; min-width:60px; }
.kv { display:grid; grid-template-columns: 60px 1fr; gap: 4px 8px; }
.k { color: var(--text-muted); font-size: 10px; letter-spacing: 0.06em; text-transform:uppercase; font-weight: var(--fw-semibold); }
.v { font-size: var(--fs-xs); color: var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.chips { display:flex; flex-direction:column; gap: 4px; align-items:flex-end; }
.chip {
display:inline-flex; align-items:center; gap: 4px;
padding: 3px 8px; border-radius:999px;
background: var(--panel); border: 1px solid var(--border);
font-size: 10px; color: var(--text-muted); white-space:nowrap;
font-weight: var(--fw-medium);
}
.chip .dot { flex-shrink:0; }
.cart { margin-top:4px; font-size:10px; color:#c7d8ee; line-height:1.3; }
.tool { margin-top:6px; font-size:11px; color:#8aa0b5; }
.cart { margin-top: 6px; font-size: 11px; color: var(--bot-name); line-height: var(--lh-base); }
.tool { margin-top: 8px; font-size: var(--fs-xs); color: var(--text-muted); }
.dot { width:8px; height:8px; border-radius:50%; }
.ok { background:#2ecc71; } .warn { background:#f1c40f; } .err { background:#e74c3c; }
.ok { background: var(--ok); } .warn { background: var(--warn); } .err { background: var(--err); }
</style>
<div class="box">
@@ -83,8 +117,11 @@ class ConversationInspector extends HTMLElement {
this.applyHeights();
});
this._unsubScroll = on("ui:chatScroll", ({ chat_id, scrollTop }) => {
this._unsubScroll = on("ui:chatScroll", ({ chat_id, scrollTop, userScrolledUp }) => {
if (!this.chatId || chat_id !== this.chatId) return;
// Si el otro panel está scrolleado-up, sincronizamos. Si no, dejamos
// a este panel manejar su propio scroll para evitar saltos cruzados.
if (!userScrolledUp) return;
const list = this.shadowRoot.getElementById("list");
list.scrollTop = scrollTop || 0;
});
@@ -206,13 +243,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 +266,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",
@@ -340,9 +373,25 @@ class ConversationInspector extends HTMLElement {
this.rowOrder.push(msgId);
}
// Auto-scroll al final
// Auto-scroll al final, salvo que el usuario esté leyendo arriba.
if (!this._userScrolledUp) {
list.scrollTop = list.scrollHeight;
}
this._bindScroll(list);
}
_bindScroll(list) {
if (this._scrollBound) return;
this._scrollBound = true;
list.addEventListener("scroll", () => {
if (this._scrollRaf) return;
this._scrollRaf = requestAnimationFrame(() => {
this._scrollRaf = null;
const distFromBottom = list.scrollHeight - list.scrollTop - list.clientHeight;
this._userScrolledUp = distFromBottom > 150;
});
});
}
applyHeights() {
const BUBBLE_MARGIN = 12; // same as .bubble margin-bottom in run-timeline
@@ -442,7 +491,9 @@ class ConversationInspector extends HTMLElement {
`;
list.appendChild(el);
// Optimistic: el usuario acaba de mandar — forzamos al final.
list.scrollTop = list.scrollHeight;
this._userScrolledUp = false;
this.rowMap.set(msg.message_id, el);
this.rowOrder.push(msg.message_id);

View File

@@ -14,27 +14,68 @@ class ConversationList extends HTMLElement {
this.shadowRoot.innerHTML = `
<style>
:host { display:block; padding:12px; }
.box { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; margin-bottom:10px; }
.row { display:flex; gap:8px; align-items:center; }
input,select,button { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px; font-size:13px; }
button { cursor:pointer; }
button.ghost { background:transparent; }
button:disabled { opacity:.6; cursor:not-allowed; }
.tabs { display:flex; gap:8px; margin-bottom:10px; }
.tab { flex:1; text-align:center; padding:8px; border-radius:8px; border:1px solid #253245; cursor:pointer; color:#8aa0b5; background:#121823; }
.tab.active { border-color:#1f6feb; color:#e7eef7; background:#0f1520; }
.list { display:flex; flex-direction:column; gap:8px; }
.item { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; cursor:pointer; }
.item:hover { border-color:#2b3b52; }
.item.active { border-color:#1f6feb; }
.title { font-weight:800; }
.muted { color:#8aa0b5; font-size:12px; }
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.chip { display:inline-flex; align-items:center; gap:6px; padding:3px 8px; border-radius:999px; background:#1d2a3a; border:1px solid #243247; font-size:12px; color:#8aa0b5; }
:host { display:block; padding: var(--space-4); font-family: var(--font-sans); }
.box {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: var(--space-4);
margin-bottom: var(--space-3);
box-shadow: var(--shadow-sm);
}
.row { display:flex; gap: var(--space-2); align-items:center; }
input, select, button {
background: var(--panel);
color: var(--text);
border: 1px solid var(--border-hi);
border-radius: var(--r-md);
padding: 8px 12px;
font: 400 var(--fs-sm)/1.4 var(--font-sans);
transition: border-color .15s, box-shadow .15s;
}
input:focus, select:focus { outline:none; border-color: var(--accent); box-shadow: var(--focus-ring); }
button { cursor:pointer; font-weight: var(--fw-medium); }
button:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-hover); }
button:focus-visible { outline:none; box-shadow: var(--focus-ring); }
button.ghost { background:transparent; border-color: transparent; }
button:disabled { opacity:.5; cursor:not-allowed; }
.tabs { display:flex; gap: var(--space-2); margin-bottom: var(--space-3); }
.tab {
flex:1; text-align:center;
padding: 8px 12px; border-radius: var(--r-md);
border: 1px solid var(--border);
cursor:pointer;
color: var(--text-muted);
background: var(--panel);
font: var(--fw-medium) var(--fs-sm)/1 var(--font-sans);
transition: all .15s;
}
.tab:hover { color: var(--text); }
.tab.active { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
.list { display:flex; flex-direction:column; gap: var(--space-2); }
.item {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: var(--space-3) var(--space-4);
cursor:pointer;
transition: all .15s;
}
.item:hover { border-color: var(--border-hi); box-shadow: var(--shadow-sm); }
.item.active { border-color: var(--accent); background: var(--accent-soft); }
.title { font-weight: var(--fw-semibold); font-size: var(--fs-base); color: var(--text); }
.muted { color: var(--text-muted); font-size: var(--fs-sm); }
.chips { display:flex; flex-wrap:wrap; gap: 6px; margin-top: 8px; }
.chip {
display:inline-flex; align-items:center; gap: 4px;
padding: 3px 8px; border-radius:999px;
background: var(--panel-2); border: 1px solid var(--border);
font-size: var(--fs-xs); color: var(--text-muted);
font-weight: var(--fw-medium);
}
.dot { width:8px; height:8px; border-radius:50%; }
.ok{ background:#2ecc71 } .warn{ background:#f1c40f } .err{ background:#e74c3c }
.actions { display:flex; gap:8px; justify-content:flex-end; margin-top:8px; flex-wrap:wrap; }
.ok{ background: var(--ok) } .warn{ background: var(--warn) } .err{ background: var(--err) }
.actions { display:flex; gap: var(--space-2); justify-content:flex-end; margin-top: var(--space-2); flex-wrap:wrap; }
</style>
<div class="tabs">
@@ -62,7 +103,10 @@ class ConversationList extends HTMLElement {
</select>
<select id="state" style="flex:1">
<option value="">State: all</option>
<option>IDLE</option><option>BROWSING</option><option>BUILDING_ORDER</option><option>WAITING_ADDRESS</option><option>WAITING_PAYMENT</option><option>COMPLETED</option>
<option>IDLE</option>
<option>CART</option>
<option>SHIPPING</option>
<option>AWAITING_HUMAN</option>
</select>
</div>
</div>

View File

@@ -18,40 +18,40 @@ class ConversationsCrud extends HTMLElement {
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
.toolbar { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; }
input, select { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; }
input:focus, select:focus { outline:none; border-color:#1f6feb; }
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:#1a5fd0; }
input, select { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; }
input:focus, select:focus { outline:none; border-color:var(--accent); }
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:var(--accent-hover); }
button:disabled { opacity:.5; cursor:not-allowed; }
button.secondary { background:#253245; }
button.secondary:hover { background:#2d3e52; }
button.danger { background:#e74c3c; }
button.danger:hover { background:#c0392b; }
button.secondary { background:var(--border-hi); }
button.secondary:hover { background:var(--border-hi); }
button.danger { background:var(--err); }
button.danger:hover { background:var(--err); }
.list { flex:1; overflow-y:auto; }
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
.item:hover { border-color:#1f6feb; }
.item.active { border-color:#1f6feb; background:#111b2a; }
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
.item:hover { border-color:var(--accent); }
.item.active { border-color:var(--accent); background:var(--accent-soft); }
.item-header { display:flex; align-items:center; gap:8px; margin-bottom:4px; }
.item-name { font-weight:600; color:#e7eef7; flex:1; }
.item-meta { font-size:12px; color:#8aa0b5; }
.item-name { font-weight:600; color:var(--text); flex:1; }
.item-meta { font-size:12px; color:var(--text-muted); }
.dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
.ok { background:#2ecc71; } .warn { background:#f1c40f; } .err { background:#e74c3c; }
.ok { background:var(--ok); } .warn { background:var(--warn); } .err { background:var(--err); }
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; }
.chip { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:#253245; color:#8aa0b5; }
.chip { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:var(--border-hi); color:var(--text-muted); }
.detail { flex:1; overflow-y:auto; }
.detail-empty { color:#8aa0b5; text-align:center; padding:40px; }
.detail-empty { color:var(--text-muted); text-align:center; padding:40px; }
.field { margin-bottom:16px; }
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.field-value { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:10px 12px; color:#e7eef7; font-size:13px; }
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.field-value { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:10px 12px; color:var(--text); font-size:13px; }
.actions { display:flex; gap:8px; margin-top:16px; flex-wrap:wrap; }
.loading { text-align:center; padding:40px; color:#8aa0b5; }
.loading { text-align:center; padding:40px; color:var(--text-muted); }
</style>
<div class="container">
@@ -68,11 +68,9 @@ class ConversationsCrud extends HTMLElement {
<select id="state">
<option value="">State: todos</option>
<option>IDLE</option>
<option>BROWSING</option>
<option>BUILDING_ORDER</option>
<option>WAITING_ADDRESS</option>
<option>WAITING_PAYMENT</option>
<option>COMPLETED</option>
<option>CART</option>
<option>SHIPPING</option>
<option>AWAITING_HUMAN</option>
</select>
</div>
<div class="list" id="list">

View File

@@ -9,12 +9,12 @@ class DebugPanel extends HTMLElement {
this.shadowRoot.innerHTML = `
<style>
:host { display:block; padding:12px; }
.box { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; }
.muted { color:#8aa0b5; font-size:12px; }
.box { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:10px; }
.muted { color:var(--text-muted); font-size:12px; }
.kv { display:grid; grid-template-columns:90px 1fr; gap:6px 10px; margin:10px 0 12px; }
.k { color:#8aa0b5; font-size:12px; letter-spacing:.4px; text-transform:uppercase; }
.v { font-size:13px; font-weight:800; color:#e7eef7; }
pre { white-space:pre-wrap; word-break:break-word; background:#0f1520; border:1px solid #253245; border-radius:10px; padding:10px; margin:0; font-size:12px; color:#d7e2ef; }
.k { color:var(--text-muted); font-size:12px; letter-spacing:.4px; text-transform:uppercase; }
.v { font-size:13px; font-weight:800; color:var(--text); }
pre { white-space:pre-wrap; word-break:break-word; background:var(--panel-2); border:1px solid var(--border-hi); border-radius:10px; padding:10px; margin:0; font-size:12px; color:var(--text-dim); }
</style>
<div class="box">

View File

@@ -0,0 +1,589 @@
import { api } from "../lib/api.js";
function formatCurrency(value) {
if (value == null) return "$0";
return new Intl.NumberFormat("es-AR", { style: "currency", currency: "ARS", maximumFractionDigits: 0 }).format(value);
}
function formatNumber(value) {
if (value == null) return "0";
return new Intl.NumberFormat("es-AR", { maximumFractionDigits: 1 }).format(value);
}
/** Lee una CSS custom property del :root. Fallback al hex provisto. */
function cssVar(name, fallback = "") {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
/** rgba con alpha desde un hex (#RRGGBB) o sky/etc — para fills de charts. */
function withAlpha(hex, alpha) {
const m = /^#?([0-9a-f]{6})$/i.exec(hex || "");
if (!m) return hex;
const n = parseInt(m[1], 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
}
class HomeDashboard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.stats = null;
this.loading = false;
this.charts = {};
this.shadowRoot.innerHTML = `
<style>
:host { font-family: var(--font-sans); }
* { box-sizing: border-box; }
.container {
min-height: 100%;
background: var(--bg);
color: var(--text);
padding: var(--space-6);
overflow-y: auto;
}
.header {
display: flex; justify-content: space-between; align-items: baseline;
margin-bottom: var(--space-6);
}
.header h1 {
font-size: var(--fs-xl);
font-weight: var(--fw-semibold);
letter-spacing: -0.02em;
margin: 0;
}
.sync-info { font-size: var(--fs-sm); color: var(--text-muted); }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: var(--space-6);
}
.chart-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: var(--space-5);
box-shadow: var(--shadow-sm);
}
.chart-card.full-width { grid-column: 1 / -1; }
.chart-title {
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: var(--space-4);
}
.chart-container { position: relative; height: 260px; }
.chart-container.tall { height: 320px; }
.chart-container.short { height: 200px; }
.loading {
display: flex; align-items: center; justify-content: center;
height: 200px; color: var(--text-muted);
}
.kpi-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.kpi-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: var(--space-5);
box-shadow: var(--shadow-sm);
}
.kpi-value {
font-size: var(--fs-xl);
font-weight: var(--fw-bold);
letter-spacing: -0.02em;
margin-bottom: 4px;
}
.kpi-label {
font-size: var(--fs-xs);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: var(--fw-medium);
}
.donut-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-4);
}
canvas { max-width: 100%; }
</style>
<div class="container">
<div class="header">
<h1>Dashboard de Ventas</h1>
<div class="sync-info"></div>
</div>
<div class="kpi-row"></div>
<div class="grid">
<div class="chart-card full-width">
<div class="chart-title">Ventas Totales por Mes</div>
<div class="chart-container tall">
<canvas id="monthly-chart"></canvas>
</div>
</div>
<div class="chart-card">
<div class="chart-title">Web vs WhatsApp</div>
<div class="chart-container">
<canvas id="source-chart"></canvas>
</div>
</div>
<div class="chart-card">
<div class="chart-title">Comparativa Año a Año</div>
<div class="chart-container">
<canvas id="yoy-chart"></canvas>
</div>
</div>
<div class="donut-row">
<div class="chart-card">
<div class="chart-title">Por Canal</div>
<div class="chart-container short">
<canvas id="source-donut"></canvas>
</div>
</div>
<div class="chart-card">
<div class="chart-title">Delivery vs Retiro</div>
<div class="chart-container short">
<canvas id="shipping-donut"></canvas>
</div>
</div>
</div>
<div class="chart-card full-width">
<div class="chart-title">Top Productos por Facturación</div>
<div class="chart-container tall">
<canvas id="products-chart"></canvas>
</div>
</div>
<div class="chart-card">
<div class="chart-title">Top por Kg Vendidos</div>
<div class="chart-container">
<canvas id="kg-chart"></canvas>
</div>
</div>
<div class="chart-card">
<div class="chart-title">Top por Unidades</div>
<div class="chart-container">
<canvas id="units-chart"></canvas>
</div>
</div>
</div>
</div>
`;
}
connectedCallback() {
this.loadStats();
}
disconnectedCallback() {
// Destruir charts para liberar memoria
Object.values(this.charts).forEach(chart => chart?.destroy?.());
this.charts = {};
}
async loadStats() {
this.loading = true;
try {
this.stats = await api.getOrderStats();
this.render();
} catch (err) {
console.error("[home-dashboard] loadStats error:", err);
} finally {
this.loading = false;
}
}
render() {
if (!this.stats) return;
// Actualizar sync info
const syncInfo = this.shadowRoot.querySelector(".sync-info");
syncInfo.textContent = `${this.stats.total_in_cache || 0} pedidos en cache`;
if (this.stats.synced > 0) {
syncInfo.textContent += ` (${this.stats.synced} nuevos sincronizados)`;
}
// Renderizar KPIs
this.renderKPIs();
// Renderizar charts
this.renderMonthlyChart();
this.renderSourceChart();
this.renderYoyChart();
this.renderDonuts();
this.renderProductsChart();
this.renderKgChart();
this.renderUnitsChart();
}
renderKPIs() {
const totals = this.stats.totals_aggregated || {};
const kpiRow = this.shadowRoot.querySelector(".kpi-row");
kpiRow.innerHTML = `
<div class="kpi-card">
<div class="kpi-value" style="color: var(--chart-blue)">${formatCurrency(totals.total_revenue)}</div>
<div class="kpi-label">Total Facturado</div>
</div>
<div class="kpi-card">
<div class="kpi-value">${formatNumber(totals.total_orders)}</div>
<div class="kpi-label">Pedidos</div>
</div>
<div class="kpi-card">
<div class="kpi-value" style="color: var(--chart-green)">${formatCurrency(totals.by_source?.whatsapp)}</div>
<div class="kpi-label">WhatsApp</div>
</div>
<div class="kpi-card">
<div class="kpi-value" style="color: var(--chart-blue)">${formatCurrency(totals.by_source?.web)}</div>
<div class="kpi-label">Web</div>
</div>
`;
}
renderMonthlyChart() {
const ctx = this.shadowRoot.getElementById("monthly-chart");
if (!ctx) return;
if (this.charts.monthly) this.charts.monthly.destroy();
const months = this.stats.months || [];
const totals = this.stats.totals || [];
this.charts.monthly = new Chart(ctx, {
type: "bar",
data: {
labels: months.map(m => {
const [y, mo] = m.split("-");
return `${mo}/${y.slice(2)}`;
}),
datasets: [{
label: "Ventas",
data: totals,
backgroundColor: withAlpha(cssVar("--chart-blue"), 0.7),
borderColor: cssVar("--chart-blue"),
borderWidth: 1,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
y: {
beginAtZero: true,
ticks: { color: cssVar("--text-muted") },
grid: { color: cssVar("--border") },
},
x: {
ticks: { color: cssVar("--text-muted") },
grid: { display: false },
},
},
},
});
}
renderSourceChart() {
const ctx = this.shadowRoot.getElementById("source-chart");
if (!ctx) return;
if (this.charts.source) this.charts.source.destroy();
const months = this.stats.months || [];
const waData = this.stats.by_source?.whatsapp || [];
const webData = this.stats.by_source?.web || [];
this.charts.source = new Chart(ctx, {
type: "bar",
data: {
labels: months.map(m => {
const [y, mo] = m.split("-");
return `${mo}/${y.slice(2)}`;
}),
datasets: [
{
label: "WhatsApp",
data: waData,
backgroundColor: cssVar("--chart-green"),
},
{
label: "Web",
data: webData,
backgroundColor: cssVar("--chart-blue"),
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "top",
labels: { color: cssVar("--text-muted") },
},
},
scales: {
y: {
stacked: true,
beginAtZero: true,
ticks: { color: cssVar("--text-muted") },
grid: { color: cssVar("--border") },
},
x: {
stacked: true,
ticks: { color: cssVar("--text-muted") },
grid: { display: false },
},
},
},
});
}
renderYoyChart() {
const ctx = this.shadowRoot.getElementById("yoy-chart");
if (!ctx) return;
if (this.charts.yoy) this.charts.yoy.destroy();
const yoy = this.stats.yoy || {};
this.charts.yoy = new Chart(ctx, {
type: "line",
data: {
labels: yoy.months || [],
datasets: [
{
label: String(yoy.current_year || "Actual"),
data: yoy.current_year_data || [],
borderColor: cssVar("--chart-blue"),
backgroundColor: withAlpha(cssVar("--chart-blue"), 0.15),
fill: true,
tension: 0.3,
},
{
label: String(yoy.last_year || "Anterior"),
data: yoy.last_year_data || [],
borderColor: cssVar("--chart-gray"),
backgroundColor: withAlpha(cssVar("--chart-gray"), 0.15),
fill: true,
tension: 0.3,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "top",
labels: { color: cssVar("--text-muted") },
},
},
scales: {
y: {
beginAtZero: true,
ticks: { color: cssVar("--text-muted") },
grid: { color: cssVar("--border") },
},
x: {
ticks: { color: cssVar("--text-muted") },
grid: { display: false },
},
},
},
});
}
renderDonuts() {
const totals = this.stats.totals_aggregated || {};
// Source donut
const sourceCtx = this.shadowRoot.getElementById("source-donut");
if (sourceCtx) {
if (this.charts.sourceDonut) this.charts.sourceDonut.destroy();
this.charts.sourceDonut = new Chart(sourceCtx, {
type: "doughnut",
data: {
labels: ["WhatsApp", "Web"],
datasets: [{
data: [totals.by_source?.whatsapp || 0, totals.by_source?.web || 0],
backgroundColor: [cssVar("--chart-green"), cssVar("--chart-blue")],
}],
},
options: this.getDonutOptions(),
});
}
// Shipping donut
const shippingCtx = this.shadowRoot.getElementById("shipping-donut");
if (shippingCtx) {
if (this.charts.shippingDonut) this.charts.shippingDonut.destroy();
this.charts.shippingDonut = new Chart(shippingCtx, {
type: "doughnut",
data: {
labels: ["Delivery", "Retiro"],
datasets: [{
data: [totals.by_shipping?.delivery || 0, totals.by_shipping?.pickup || 0],
backgroundColor: [cssVar("--chart-purple"), cssVar("--chart-orange")],
}],
},
options: this.getDonutOptions(),
});
}
}
getDonutOptions() {
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom",
labels: { color: cssVar("--text-muted") },
},
},
};
}
renderProductsChart() {
const ctx = this.shadowRoot.getElementById("products-chart");
if (!ctx) return;
if (this.charts.products) this.charts.products.destroy();
const products = this.stats.top_products_revenue || [];
const labels = products.map(p => p.name?.slice(0, 30) || "Sin nombre");
const data = products.map(p => p.revenue || 0);
this.charts.products = new Chart(ctx, {
type: "bar",
data: {
labels,
datasets: [{
label: "Facturación",
data,
backgroundColor: withAlpha(cssVar("--chart-blue"), 0.7),
borderColor: cssVar("--chart-blue"),
borderWidth: 1,
}],
},
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
x: {
beginAtZero: true,
ticks: { color: cssVar("--text-muted") },
grid: { color: cssVar("--border") },
},
y: {
ticks: { color: cssVar("--text-muted") },
grid: { display: false },
},
},
},
});
}
renderKgChart() {
const ctx = this.shadowRoot.getElementById("kg-chart");
if (!ctx) return;
if (this.charts.kg) this.charts.kg.destroy();
const products = this.stats.top_products_kg || [];
const labels = products.map(p => p.name?.slice(0, 25) || "Sin nombre");
const data = products.map(p => p.kg || 0);
this.charts.kg = new Chart(ctx, {
type: "bar",
data: {
labels,
datasets: [{
label: "Kg",
data,
backgroundColor: withAlpha(cssVar("--chart-purple"), 0.7),
borderColor: cssVar("--chart-purple"),
borderWidth: 1,
}],
},
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
x: {
beginAtZero: true,
ticks: { color: cssVar("--text-muted") },
grid: { color: cssVar("--border") },
},
y: {
ticks: { color: cssVar("--text-muted"), font: { size: 10 } },
grid: { display: false },
},
},
},
});
}
renderUnitsChart() {
const ctx = this.shadowRoot.getElementById("units-chart");
if (!ctx) return;
if (this.charts.units) this.charts.units.destroy();
const products = this.stats.top_products_units || [];
const labels = products.map(p => p.name?.slice(0, 25) || "Sin nombre");
const data = products.map(p => p.units || 0);
this.charts.units = new Chart(ctx, {
type: "bar",
data: {
labels,
datasets: [{
label: "Unidades",
data,
backgroundColor: withAlpha(cssVar("--chart-orange"), 0.7),
borderColor: cssVar("--chart-orange"),
borderWidth: 1,
}],
},
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
x: {
beginAtZero: true,
ticks: { color: cssVar("--text-muted") },
grid: { color: cssVar("--border") },
},
y: {
ticks: { color: cssVar("--text-muted"), font: { size: 10 } },
grid: { display: false },
},
},
},
});
}
}
customElements.define("home-dashboard", HomeDashboard);

View File

@@ -12,35 +12,69 @@ class OpsShell extends HTMLElement {
this.shadowRoot.innerHTML = `
<style>
:host { --bg:#0b0f14; --panel:#121823; --muted:#8aa0b5; --text:#e7eef7; --line:#1e2a3a; --blue:#1f6feb; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
* { box-sizing:border-box; }
:host { font-family: var(--font-sans); }
.app { height:100vh; background:var(--bg); color:var(--text); display:flex; flex-direction:column; }
header { display:flex; gap:12px; align-items:center; padding:12px 16px; border-bottom:1px solid var(--line); flex-wrap:wrap; }
header h1 { font-size:14px; margin:0; color:var(--muted); font-weight:700; letter-spacing:.4px; text-transform:uppercase; }
.nav { display:flex; gap:4px; margin-left:24px; flex-wrap:wrap; }
.nav-btn { background:transparent; border:1px solid var(--line); color:var(--muted); padding:6px 12px; border-radius:6px; font-size:12px; cursor:pointer; transition:all .15s; text-decoration:none; }
.nav-btn:hover { border-color:var(--blue); color:var(--text); }
.nav-btn.active { background:var(--blue); border-color:var(--blue); color:#fff; }
header {
display:flex; gap:var(--space-3); align-items:center;
padding: var(--space-3) var(--space-6);
background: var(--panel);
border-bottom: 1px solid var(--border);
flex-wrap:wrap;
}
header h1 {
font-size: var(--fs-md);
margin:0; color: var(--text);
font-weight: var(--fw-semibold);
letter-spacing:-0.01em;
}
.nav { display:flex; gap: var(--space-1); margin-left: var(--space-6); flex-wrap:wrap; }
.nav-btn {
position:relative;
background:transparent; border:none;
color: var(--text-muted);
padding: 8px 12px;
font: var(--fw-medium) var(--fs-sm)/1 var(--font-sans);
cursor:pointer; transition:color .15s;
text-decoration:none;
border-radius: var(--r-sm);
}
.nav-btn:hover { color: var(--text); background: var(--panel-2); }
.nav-btn.active { color: var(--accent); background: var(--accent-soft); }
.nav-btn:focus-visible { outline:none; box-shadow: var(--focus-ring); }
.spacer { flex:1; }
.status { font-size:12px; color:var(--muted); }
.status {
font-size: var(--fs-sm); color: var(--ok);
display:flex; align-items:center; gap: 6px;
padding: 4px 10px;
background: var(--ok-soft);
border-radius: 999px;
}
.status .dot { width:7px; height:7px; border-radius:50%; background: var(--ok); }
.status.disconnected { color: var(--warn); background: var(--warn-soft); }
.status.disconnected .dot { background: var(--warn); animation: pulse 1.2s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
/* Notification bell */
.notification-bell { position:relative; cursor:pointer; padding:8px; margin-right:12px; }
.notification-bell svg { width:20px; height:20px; fill:var(--muted); transition:fill .15s; }
.notification-bell:hover svg { fill:var(--text); }
.notification-bell.has-pending svg { fill:#f39c12; }
.notification-bell { position:relative; cursor:pointer; padding: 8px; border-radius: var(--r-sm); transition: background .15s; }
.notification-bell:hover { background: var(--panel-2); }
.notification-bell svg { width:18px; height:18px; fill: var(--text-muted); transition:fill .15s; display:block; }
.notification-bell:hover svg { fill: var(--text); }
.notification-bell.has-pending svg { fill: var(--warn); }
.notification-bell .badge {
position:absolute; top:2px; right:2px;
background:#e74c3c; color:#fff;
font-size:10px; padding:2px 6px; border-radius:10px;
font-weight:700; min-width:18px; text-align:center;
background: var(--err); color:#fff;
font: var(--fw-bold) 10px/1 var(--font-sans);
padding: 3px 6px; border-radius:10px;
min-width:18px; text-align:center;
box-shadow: 0 0 0 2px var(--panel);
}
/* Layout para chat activo (2 columnas: burbujas + inspector) */
.layout-chat { height:100%; display:grid; grid-template-columns:1fr 1fr; grid-template-rows:1fr 310px; min-height:0; overflow:hidden; }
.col { border-right:1px solid var(--line); min-height:0; overflow:hidden; }
.chatTop { grid-column:1; grid-row:1; border-bottom:1px solid var(--line); }
.chatBottom { grid-column:1 / 3; grid-row:2; overflow:hidden; border-top:1px solid var(--line); }
.col { border-right:1px solid var(--border); min-height:0; overflow:hidden; }
.chatTop { grid-column:1; grid-row:1; border-bottom:1px solid var(--border); }
.chatBottom { grid-column:1 / 3; grid-row:2; overflow:hidden; border-top:1px solid var(--border); }
.inspectorTop { grid-column:2; grid-row:1; border-right:none; }
/* Layout para CRUDs */
@@ -52,9 +86,10 @@ class OpsShell extends HTMLElement {
<div class="app">
<header>
<h1>Bot Ops Console</h1>
<h1>Piaf Console</h1>
<nav class="nav">
<a class="nav-btn active" href="/chat" data-view="chat">Chat</a>
<a class="nav-btn active" href="/home" data-view="home">Home</a>
<a class="nav-btn" href="/chat" data-view="chat">Chat</a>
<a class="nav-btn" href="/conversaciones" data-view="conversations">Conversaciones</a>
<a class="nav-btn" href="/usuarios" data-view="users">Usuarios</a>
<a class="nav-btn" href="/productos" data-view="products">Productos</a>
@@ -62,19 +97,23 @@ class OpsShell extends HTMLElement {
<a class="nav-btn" href="/crosssell" data-view="crosssell">Cross-sell</a>
<a class="nav-btn" href="/cantidades" data-view="quantities">Cantidades</a>
<a class="nav-btn" href="/pedidos" data-view="orders">Pedidos</a>
<a class="nav-btn" href="/config-prompts" data-view="prompts">Prompts</a>
<a class="nav-btn" href="/configuracion" data-view="settings">Config</a>
<a class="nav-btn" href="/test" data-view="test">Test</a>
</nav>
<div class="spacer"></div>
<div class="notification-bell" id="notificationBell" title="Takeovers pendientes">
<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.48 1.51-4.5 4-4.5s4 2.02 4 4.5v6z"/></svg>
<span class="badge" id="takeoverBadge" style="display:none;">0</span>
</div>
<div class="status" id="sseStatus">SSE: connecting…</div>
<div class="status disconnected" id="sseStatus"><span class="dot"></span><span class="label">Conectando…</span></div>
</header>
<div id="viewChat" class="view active">
<div id="viewHome" class="view active">
<div class="layout-crud">
<home-dashboard></home-dashboard>
</div>
</div>
<div id="viewChat" class="view">
<div class="layout-chat">
<div class="col chatTop"><run-timeline></run-timeline></div>
<div class="col inspectorTop"><conversation-inspector></conversation-inspector></div>
@@ -124,18 +163,6 @@ class OpsShell extends HTMLElement {
</div>
</div>
<div id="viewTest" class="view">
<div class="layout-crud">
<test-panel></test-panel>
</div>
</div>
<div id="viewPrompts" class="view">
<div class="layout-crud">
<prompts-crud></prompts-crud>
</div>
</div>
<div id="viewTakeovers" class="view">
<div class="layout-crud">
<takeovers-crud></takeovers-crud>
@@ -154,7 +181,10 @@ class OpsShell extends HTMLElement {
connectedCallback() {
this._unsub = on("sse:status", (s) => {
const el = this.shadowRoot.getElementById("sseStatus");
el.textContent = s.ok ? "SSE: connected" : "SSE: disconnected (retrying…)";
if (!el) return;
el.classList.toggle("disconnected", !s.ok);
const label = el.querySelector(".label");
if (label) label.textContent = s.ok ? "En vivo" : "Reconectando…";
});
// Listen for view switch requests from other components

View File

@@ -23,15 +23,15 @@ function statusLabel(status) {
function statusColor(status) {
const map = {
pending: "#f59e0b",
processing: "#3b82f6",
"on-hold": "#8b5cf6",
completed: "#22c55e",
cancelled: "#6b7280",
refunded: "#ec4899",
failed: "#ef4444",
pending: "var(--warn)",
processing: "var(--chart-blue)",
"on-hold": "var(--chart-purple)",
completed: "var(--ok)",
cancelled: "var(--text-muted)",
refunded: "var(--chart-pink)",
failed: "var(--err)",
};
return map[status] || "#8aa0b5";
return map[status] || "var(--text-muted)";
}
class OrdersCrud extends HTMLElement {
@@ -42,18 +42,24 @@ class OrdersCrud extends HTMLElement {
this.selectedOrder = null;
this.loading = false;
// Paginación
this.page = 1;
this.limit = 50;
this.totalPages = 1;
this.totalOrders = 0;
this.shadowRoot.innerHTML = `
<style>
:host {
--bg: #0b0f14;
--panel: #121823;
--muted: #8aa0b5;
--text: #e7eef7;
--line: #1e2a3a;
--blue: #1f6feb;
--green: #238636;
--red: #da3633;
--orange: #f59e0b;
--bg: var(--bg);
--panel: var(--panel);
--muted: var(--text-muted);
--text: var(--text);
--line: var(--border);
--blue: var(--accent);
--green: var(--ok);
--red: var(--err);
--orange: var(--warn);
}
* { box-sizing: border-box; font-family: system-ui, Segoe UI, Roboto, Arial; }
.container {
@@ -148,10 +154,10 @@ class OrdersCrud extends HTMLElement {
font-weight: 600;
text-transform: uppercase;
}
.badge.test { background: var(--orange); color: #000; }
.badge.test { background: var(--orange); color: var(--text); }
.badge.real { background: var(--green); color: #fff; }
.badge.whatsapp { background: #25d366; color: #fff; }
.badge.web { background: var(--muted); color: #000; }
.badge.whatsapp { background: var(--ok); color: #fff; }
.badge.web { background: var(--muted); color: var(--text); }
.status-badge {
padding: 3px 8px;
border-radius: 4px;
@@ -214,17 +220,68 @@ class OrdersCrud extends HTMLElement {
text-align: center;
padding: 60px 20px;
}
/* Paginación */
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-top: 1px solid var(--line);
margin-top: auto;
font-size: 12px;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 8px;
}
.pagination-info {
color: var(--muted);
}
.pagination select {
background: var(--bg);
color: var(--text);
border: 1px solid var(--line);
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
}
.pagination select:hover {
border-color: var(--blue);
}
.pagination button {
padding: 4px 10px;
font-size: 11px;
}
</style>
<div class="container">
<div class="panel">
<div class="panel-title">
<span>Pedidos de WooCommerce</span>
<span>Pedidos</span>
<button id="btnRefresh" class="secondary small">Actualizar</button>
</div>
<div class="orders-table" id="ordersTable">
<div class="empty">Cargando pedidos...</div>
</div>
<div class="pagination" id="pagination">
<div class="pagination-controls">
<span>Mostrar:</span>
<select id="limitSelect">
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
</div>
<div class="pagination-controls">
<button id="btnPrev" class="secondary small" disabled>← Anterior</button>
<span class="pagination-info" id="pageInfo">Página 1 de 1</span>
<button id="btnNext" class="secondary small" disabled>Siguiente →</button>
</div>
<div class="pagination-info" id="totalInfo">0 pedidos</div>
</div>
</div>
<div class="panel">
@@ -240,6 +297,25 @@ class OrdersCrud extends HTMLElement {
connectedCallback() {
this.shadowRoot.getElementById("btnRefresh").onclick = () => this.loadOrders();
// Paginación
this.shadowRoot.getElementById("limitSelect").onchange = (e) => {
this.limit = parseInt(e.target.value);
this.page = 1;
this.loadOrders();
};
this.shadowRoot.getElementById("btnPrev").onclick = () => {
if (this.page > 1) {
this.page--;
this.loadOrders();
}
};
this.shadowRoot.getElementById("btnNext").onclick = () => {
if (this.page < this.totalPages) {
this.page++;
this.loadOrders();
}
};
// Escuchar cambios de ruta para deep-linking
this._unsubRouter = on("router:viewChanged", ({ view, params }) => {
if (view === "orders" && params.id) {
@@ -248,7 +324,6 @@ class OrdersCrud extends HTMLElement {
});
// Escuchar nuevos pedidos para actualizar automáticamente
// Usa retry con backoff porque WooCommerce puede tardar en devolver el pedido recién creado
this._unsubOrderCreated = on("order:created", ({ order_id }) => {
console.log("[orders-crud] order:created received, order_id:", order_id);
this.refreshWithRetry(order_id);
@@ -298,9 +373,17 @@ class OrdersCrud extends HTMLElement {
container.innerHTML = `<div class="empty">Cargando pedidos...</div>`;
try {
const result = await api.listRecentOrders({ limit: 50 });
const result = await api.listOrders({ page: this.page, limit: this.limit });
this.orders = result.items || [];
// Actualizar paginación
if (result.pagination) {
this.totalPages = result.pagination.pages || 1;
this.totalOrders = result.pagination.total || 0;
}
this.renderTable();
this.updatePagination();
// Si hay un pedido pendiente de selección (deep-link), seleccionarlo
if (this._pendingOrderId) {
@@ -316,6 +399,22 @@ class OrdersCrud extends HTMLElement {
}
}
updatePagination() {
const pageInfo = this.shadowRoot.getElementById("pageInfo");
const totalInfo = this.shadowRoot.getElementById("totalInfo");
const btnPrev = this.shadowRoot.getElementById("btnPrev");
const btnNext = this.shadowRoot.getElementById("btnNext");
const limitSelect = this.shadowRoot.getElementById("limitSelect");
pageInfo.textContent = `Página ${this.page} de ${this.totalPages}`;
totalInfo.textContent = `${this.totalOrders.toLocaleString("es-AR")} pedidos`;
btnPrev.disabled = this.page <= 1;
btnNext.disabled = this.page >= this.totalPages;
limitSelect.value = String(this.limit);
}
renderTable() {
const container = this.shadowRoot.getElementById("ordersTable");
@@ -332,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>
@@ -353,13 +451,7 @@ class OrdersCrud extends HTMLElement {
</div>
</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><span class="badge" style="background:${order.is_delivery ? 'var(--chart-blue)' : 'var(--chart-purple)'};color:#fff">${order.is_delivery ? 'DEL' : 'RET'}</span></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>
@@ -373,7 +465,7 @@ class OrdersCrud extends HTMLElement {
container.querySelectorAll("tr[data-order-id]").forEach(row => {
row.onclick = () => {
const orderId = parseInt(row.dataset.orderId);
const order = this.orders.find(o => o.id === orderId);
const order = this.orders.find(o => o.id == orderId);
if (order) {
this.selectOrder(order);
}
@@ -466,7 +558,7 @@ class OrdersCrud extends HTMLElement {
<div class="detail-row">
<span class="detail-label">Método</span>
<span class="detail-value">
<span class="badge ${order.is_delivery ? 'delivery' : 'pickup'}" style="background:${order.is_delivery ? '#3b82f6' : '#8b5cf6'};color:#fff;padding:3px 8px;border-radius:4px;font-size:10px;">
<span class="badge ${order.is_delivery ? 'delivery' : 'pickup'}" style="background:${order.is_delivery ? 'var(--chart-blue)' : 'var(--chart-purple)'};color:#fff;padding:3px 8px;border-radius:4px;font-size:10px;">
${order.is_delivery ? 'DELIVERY' : 'RETIRO'}
</span>
${order.shipping_method ? `<span style="margin-left:8px;color:var(--muted);font-size:11px;">${order.shipping_method}</span>` : ''}
@@ -480,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">

View File

@@ -19,46 +19,46 @@ class ProductsCrud extends HTMLElement {
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
input, select { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; }
input:focus, select:focus { outline:none; border-color:#1f6feb; }
input, select { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; }
input:focus, select:focus { outline:none; border-color:var(--accent); }
input { flex:1; }
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:#1a5fd0; }
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:var(--accent-hover); }
button:disabled { opacity:.5; cursor:not-allowed; }
button.secondary { background:#253245; }
button.secondary:hover { background:#2d3e52; }
button.secondary { background:var(--border-hi); }
button.secondary:hover { background:var(--border-hi); }
.list { flex:1; overflow-y:auto; }
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; user-select:none; }
.item:hover { border-color:#1f6feb; }
.item.active { border-color:#1f6feb; background:#111b2a; }
.item.selected { border-color:#2ecc71; background:#0f2a1a; }
.item-name { font-weight:600; color:#e7eef7; margin-bottom:4px; }
.item-meta { font-size:12px; color:#8aa0b5; }
.item-price { color:#2ecc71; font-weight:600; }
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; user-select:none; }
.item:hover { border-color:var(--accent); }
.item.active { border-color:var(--accent); background:var(--accent-soft); }
.item.selected { border-color:var(--ok); background:var(--ok-soft); }
.item-name { font-weight:600; color:var(--text); margin-bottom:4px; }
.item-meta { font-size:12px; color:var(--text-muted); }
.item-price { color:var(--ok); font-weight:600; }
.detail { flex:1; overflow-y:auto; }
.detail-empty { color:#8aa0b5; text-align:center; padding:40px; }
.detail-empty { color:var(--text-muted); text-align:center; padding:40px; }
.field { margin-bottom:16px; }
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.field-value { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:10px 12px; color:#e7eef7; font-size:13px; }
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.field-value { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:10px 12px; color:var(--text); font-size:13px; }
.field-value.json { font-family:monospace; font-size:11px; white-space:pre-wrap; max-height:200px; overflow-y:auto; }
.stats { display:flex; gap:16px; margin-bottom:16px; }
.stat { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; flex:1; text-align:center; cursor:pointer; transition:all .15s; }
.stat:hover { border-color:#1f6feb; }
.stat.active { border-color:#1f6feb; background:#111b2a; }
.stat-value { font-size:24px; font-weight:700; color:#1f6feb; }
.stat-label { font-size:11px; color:#8aa0b5; text-transform:uppercase; margin-top:4px; }
.stat { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; flex:1; text-align:center; cursor:pointer; transition:all .15s; }
.stat:hover { border-color:var(--accent); }
.stat.active { border-color:var(--accent); background:var(--accent-soft); }
.stat-value { font-size:24px; font-weight:700; color:var(--accent); }
.stat-label { font-size:11px; color:var(--text-muted); text-transform:uppercase; margin-top:4px; }
.loading { text-align:center; padding:40px; color:#8aa0b5; }
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:#253245; color:#8aa0b5; margin-left:8px; }
.badge.stock { background:#0f2a1a; color:#2ecc71; }
.badge.nostock { background:#241214; color:#e74c3c; }
.loading { text-align:center; padding:40px; color:var(--text-muted); }
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:var(--border-hi); color:var(--text-muted); margin-left:8px; }
.badge.stock { background:var(--ok-soft); color:var(--ok); }
.badge.nostock { background:var(--err-soft); color:var(--err); }
</style>
<div class="container">
@@ -272,7 +272,7 @@ class ProductsCrud extends HTMLElement {
// Mostrar unidad actual si está definida
const unit = item.sell_unit || item.payload?._sell_unit_override;
const unitBadge = unit ? `<span class="badge" style="background:#1a3a5c;color:#7eb8e7;">${unit === 'unit' ? 'Unidad' : 'Kg'}</span>` : '';
const unitBadge = unit ? `<span class="badge" style="background:var(--accent-soft);color:var(--accent-hover);">${unit === 'unit' ? 'Unidad' : 'Kg'}</span>` : '';
el.innerHTML = `
<div class="item-name">${item.name || "Sin nombre"} ${stockBadge} ${unitBadge}</div>
@@ -328,7 +328,7 @@ class ProductsCrud extends HTMLElement {
} catch (err) {
console.error("[products-crud] Error in renderDetail:", err);
const detail = this.shadowRoot.getElementById("detail");
detail.innerHTML = `<div class="detail-empty" style="color:#e74c3c;">Error: ${err.message}</div>`;
detail.innerHTML = `<div class="detail-empty" style="color:var(--err);">Error: ${err.message}</div>`;
}
// Scroll detail panel to top
@@ -449,12 +449,12 @@ class ProductsCrud extends HTMLElement {
<div class="field">
<label>Unidad de venta</label>
<div style="display:flex;gap:8px;align-items:center;">
<select id="sellUnit" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;">
<select id="sellUnit" style="background:var(--panel-2);color:var(--text);border:1px solid var(--border-hi);border-radius:8px;padding:8px 12px;font-size:13px;">
<option value="kg" ${currentUnit === "kg" ? "selected" : ""}>Por peso (kg)</option>
<option value="unit" ${currentUnit === "unit" ? "selected" : ""}>Por unidad</option>
</select>
</div>
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">
Define si este producto se vende por peso o por unidad
</div>
</div>
@@ -464,16 +464,16 @@ class ProductsCrud extends HTMLElement {
${categoriesArray.length > 0
? categoriesArray.map(cat => `
<span class="category-tag" data-category="${this.escapeHtml(cat)}"
style="display:inline-flex;align-items:center;gap:4px;background:#1a3a5c;color:#7eb8e7;padding:4px 8px;border-radius:6px;font-size:12px;">
style="display:inline-flex;align-items:center;gap:4px;background:var(--accent-soft);color:var(--accent-hover);padding:4px 8px;border-radius:6px;font-size:12px;">
${this.escapeHtml(cat)}
<span class="remove-cat" style="cursor:pointer;font-weight:bold;opacity:0.7;">&times;</span>
</span>
`).join("")
: '<span style="color:#8aa0b5;font-size:12px;">Sin categorías</span>'
: '<span style="color:var(--text-muted);font-size:12px;">Sin categorías</span>'
}
</div>
<div style="display:flex;gap:8px;align-items:center;">
<select id="addCategorySelect" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
<select id="addCategorySelect" style="background:var(--panel-2);color:var(--text);border:1px solid var(--border-hi);border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
<option value="">-- Agregar categoría --</option>
${this.getAllCategories().filter(c => !categoriesArray.includes(c)).map(cat =>
`<option value="${this.escapeHtml(cat)}">${this.escapeHtml(cat)}</option>`
@@ -489,7 +489,7 @@ class ProductsCrud extends HTMLElement {
<div class="field">
<div style="display:flex;gap:8px;align-items:center;">
<button id="saveProduct" style="flex:1;padding:10px;">Guardar cambios</button>
<span id="saveStatus" style="font-size:12px;color:#2ecc71;"></span>
<span id="saveStatus" style="font-size:12px;color:var(--ok);"></span>
</div>
</div>
<div class="field">
@@ -533,7 +533,7 @@ class ProductsCrud extends HTMLElement {
const tag = document.createElement("span");
tag.className = "category-tag";
tag.dataset.category = categoryName;
tag.style = "display:inline-flex;align-items:center;gap:4px;background:#1a3a5c;color:#7eb8e7;padding:4px 8px;border-radius:6px;font-size:12px;";
tag.style = "display:inline-flex;align-items:center;gap:4px;background:var(--accent-soft);color:var(--accent-hover);padding:4px 8px;border-radius:6px;font-size:12px;";
tag.innerHTML = `${this.escapeHtml(categoryName)}<span class="remove-cat" style="cursor:pointer;font-weight:bold;opacity:0.7;">&times;</span>`;
// Bind remove
@@ -545,7 +545,7 @@ class ProductsCrud extends HTMLElement {
};
// Remover el mensaje "Sin categorías" si existe
const emptyMsg = container.querySelector('span[style*="color:#8aa0b5"]');
const emptyMsg = container.querySelector('span[style*="color:var(--text-muted)"]');
if (emptyMsg) emptyMsg.remove();
container.appendChild(tag);
@@ -652,8 +652,8 @@ class ProductsCrud extends HTMLElement {
detail.innerHTML = `
<div class="field">
<label>Productos seleccionados</label>
<div class="field-value" style="color:#2ecc71;font-weight:600;">${count} productos</div>
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">${names}${moreText}</div>
<div class="field-value" style="color:var(--ok);font-weight:600;">${count} productos</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">${names}${moreText}</div>
<div style="font-size:11px;margin-top:4px;">
<span class="badge stock" style="margin-left:0;">${inStockCount} en stock</span>
<span class="badge nostock">${count - inStockCount} sin stock</span>
@@ -662,26 +662,26 @@ class ProductsCrud extends HTMLElement {
<div class="field">
<label>Unidad de venta (para todos)</label>
<div style="display:flex;gap:8px;align-items:center;">
<select id="sellUnit" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
<select id="sellUnit" style="background:var(--panel-2);color:var(--text);border:1px solid var(--border-hi);border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
<option value="kg">Por peso (kg)</option>
<option value="unit">Por unidad</option>
</select>
<button id="saveUnit" style="padding:8px 16px;">Aplicar</button>
</div>
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">
Se aplicará a todos los productos seleccionados
</div>
</div>
<div class="field">
<label>Agregar categoría (para todos)</label>
<div style="display:flex;gap:8px;align-items:center;">
<select id="addCategorySelect" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
<select id="addCategorySelect" style="background:var(--panel-2);color:var(--text);border:1px solid var(--border-hi);border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
<option value="">-- Seleccionar categoría --</option>
${this.getAllCategories().map(cat => `<option value="${this.escapeHtml(cat)}">${this.escapeHtml(cat)}</option>`).join("")}
</select>
<button id="addCategory" style="padding:8px 16px;">Agregar</button>
</div>
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">
Se agregará esta categoría a todos los productos seleccionados
</div>
</div>

View File

@@ -1,470 +0,0 @@
import { api } from "../lib/api.js";
import { on } from "../lib/bus.js";
import { modal } from "../lib/modal.js";
const PROMPT_LABELS = {
router: "Router (clasificador de dominio)",
greeting: "Saludos",
orders: "Pedidos",
shipping: "Envio/Retiro",
payment: "Pago",
browse: "Consultas de catalogo",
};
class PromptsCrud extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.items = [];
this.selected = null;
this.loading = false;
this.versions = [];
this.availableVariables = [];
this.availableModels = [];
this.currentSettings = {}; // Valores actuales de las variables
this.testResult = null;
this.testLoading = false;
this.shadowRoot.innerHTML = `
<style>
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:280px 1fr; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
input, select, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
textarea { resize:vertical; min-height:200px; font-family:monospace; font-size:12px; line-height:1.5; }
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:#1a5fd0; }
button:disabled { opacity:.5; cursor:not-allowed; }
button.secondary { background:#253245; }
button.secondary:hover { background:#2d3e52; }
button.danger { background:#e74c3c; }
button.danger:hover { background:#c0392b; }
button.small { padding:4px 8px; font-size:11px; }
.list { flex:1; overflow-y:auto; }
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
.item:hover { border-color:#1f6feb; }
.item.active { border-color:#1f6feb; background:#111b2a; }
.item-name { font-weight:600; color:#e7eef7; margin-bottom:4px; font-size:14px; }
.item-meta { font-size:11px; color:#8aa0b5; }
.item-meta .default { color:#2ecc71; }
.item-meta .custom { color:#f39c12; }
.form { flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:16px; }
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
.field { }
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.field-hint { font-size:11px; color:#8aa0b5; margin-top:4px; }
.actions { display:flex; gap:8px; flex-wrap:wrap; }
.loading { text-align:center; padding:40px; color:#8aa0b5; }
.variables-list { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.var-item { display:inline-flex; align-items:center; gap:4px; background:#0f1520; border:1px solid #253245; border-radius:4px; padding:2px 4px 2px 2px; }
.var-btn { background:#253245; border:none; color:#8aa0b5; padding:4px 8px; border-radius:3px; font-size:11px; cursor:pointer; font-family:monospace; }
.var-btn:hover { background:#1f6feb; color:#fff; }
.var-value { font-size:10px; color:#6c7a89; max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.versions-list { max-height:150px; overflow-y:auto; margin-top:8px; }
.version-item { display:flex; justify-content:space-between; align-items:center; padding:6px 8px; background:#0f1520; border-radius:4px; margin-bottom:4px; font-size:12px; }
.version-item.active { border-left:3px solid #2ecc71; }
.version-item .ver { color:#e7eef7; }
.version-item .date { color:#8aa0b5; font-size:10px; }
.test-section { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-top:16px; }
.test-section h4 { margin:0 0 12px; font-size:13px; color:#8aa0b5; }
.test-result { background:#0a0e14; border:1px solid #1e2a3a; border-radius:6px; padding:12px; margin-top:12px; font-family:monospace; font-size:11px; white-space:pre-wrap; max-height:200px; overflow-y:auto; }
.test-result.error { border-color:#e74c3c; color:#e74c3c; }
.test-result.success { border-color:#2ecc71; }
.test-meta { font-size:10px; color:#8aa0b5; margin-top:8px; }
.row { display:flex; gap:12px; align-items:flex-end; }
.row .field { flex:1; }
</style>
<div class="container">
<div class="panel">
<div class="panel-title">Prompts del Sistema</div>
<div class="list" id="list">
<div class="loading">Cargando...</div>
</div>
</div>
<div class="panel">
<div class="panel-title" id="formTitle">Editor de Prompt</div>
<div class="form" id="form">
<div class="form-empty">Selecciona un prompt para editarlo</div>
</div>
</div>
</div>
`;
}
connectedCallback() {
this.load();
// Refrescar settings cuando se vuelve a esta vista (por si cambiaron en Config)
this._unsubRouter = on("router:viewChanged", ({ view }) => {
if (view === "prompts") {
this.refreshSettings();
}
});
}
disconnectedCallback() {
this._unsubRouter?.();
}
async refreshSettings() {
try {
const settings = await api.getSettings();
this.currentSettings = {
store_name: settings.store_name || "",
store_hours: this.formatStoreHours(settings),
store_address: settings.store_address || "",
store_phone: settings.store_phone || "",
bot_name: settings.bot_name || "",
current_date: new Date().toLocaleDateString("es-AR"),
customer_name: "(nombre del cliente)",
state: "(estado actual)",
};
// Re-renderizar el form si hay uno seleccionado
if (this.selected) {
this.renderForm();
}
} catch (e) {
console.debug("Error refreshing settings:", e);
}
}
async load() {
this.loading = true;
this.renderList();
try {
// Cargar prompts y settings en paralelo
const [data, settings] = await Promise.all([
api.prompts(),
api.getSettings().catch(() => ({})),
]);
this.items = data.items || [];
this.availableVariables = data.available_variables || [];
this.availableModels = data.available_models || [];
// Mapear settings a variables
this.currentSettings = {
store_name: settings.store_name || "",
store_hours: this.formatStoreHours(settings),
store_address: settings.store_address || "",
store_phone: settings.store_phone || "",
bot_name: settings.bot_name || "",
current_date: new Date().toLocaleDateString("es-AR"),
customer_name: "(nombre del cliente)",
state: "(estado actual)",
};
this.loading = false;
this.renderList();
} catch (e) {
console.error("Error loading prompts:", e);
this.items = [];
this.loading = false;
this.renderList();
}
}
formatStoreHours(settings) {
if (!settings.pickup_days) return "";
// Mapeo de días cortos a nombres legibles
const dayNames = {
lun: "Lun", mar: "Mar", mie: "Mié", jue: "Jue",
vie: "Vie", sab: "Sáb", dom: "Dom"
};
const days = settings.pickup_days.split(",").map(d => dayNames[d.trim()] || d).join(", ");
const start = (settings.pickup_hours_start || "08:00").slice(0, 5);
const end = (settings.pickup_hours_end || "20:00").slice(0, 5);
return `${days} de ${start} a ${end}`;
}
renderList() {
const list = this.shadowRoot.getElementById("list");
if (this.loading) {
list.innerHTML = `<div class="loading">Cargando...</div>`;
return;
}
if (!this.items.length) {
list.innerHTML = `<div class="loading">No se encontraron prompts</div>`;
return;
}
list.innerHTML = "";
for (const item of this.items) {
const el = document.createElement("div");
el.className = "item" + (this.selected?.prompt_key === item.prompt_key ? " active" : "");
const label = PROMPT_LABELS[item.prompt_key] || item.prompt_key;
const statusClass = item.is_default ? "default" : "custom";
const statusText = item.is_default ? "Default" : `v${item.version}`;
el.innerHTML = `
<div class="item-name">${label}</div>
<div class="item-meta">
<span class="${statusClass}">${statusText}</span>
${item.model ? ` | ${item.model}` : ""}
</div>
`;
el.onclick = () => this.selectPrompt(item);
list.appendChild(el);
}
}
async selectPrompt(item) {
this.selected = item;
this.testResult = null;
this.renderList();
// Cargar detalles con versiones
try {
const details = await api.getPrompt(item.prompt_key);
this.selected = { ...item, ...details.current };
this.versions = details.versions || [];
this.availableVariables = details.available_variables || this.availableVariables;
this.availableModels = details.available_models || this.availableModels;
this.renderForm();
} catch (e) {
console.error("Error loading prompt details:", e);
this.renderForm();
}
}
renderForm() {
const form = this.shadowRoot.getElementById("form");
const title = this.shadowRoot.getElementById("formTitle");
if (!this.selected) {
title.textContent = "Editor de Prompt";
form.innerHTML = `<div class="form-empty">Selecciona un prompt para editarlo</div>`;
return;
}
const label = PROMPT_LABELS[this.selected.prompt_key] || this.selected.prompt_key;
title.textContent = `Editar: ${label}`;
const content = this.selected.content || "";
const model = this.selected.model || "gpt-4-turbo";
form.innerHTML = `
<div class="row">
<div class="field">
<label>Modelo LLM</label>
<select id="modelSelect">
${this.availableModels.map(m => `<option value="${m}" ${m === model ? "selected" : ""}>${m}</option>`).join("")}
</select>
</div>
<div class="field" style="flex:0">
<label>&nbsp;</label>
<button id="resetBtn" class="secondary">Reset a Default</button>
</div>
</div>
<div class="field" style="flex:1; display:flex; flex-direction:column;">
<label>Contenido del Prompt</label>
<textarea id="contentInput" style="flex:1; min-height:250px;">${this.escapeHtml(content)}</textarea>
<div class="field-hint">
Variables disponibles (click para insertar):
<div class="variables-list" id="variablesList">
${this.availableVariables.map(v => {
const key = typeof v === 'string' ? v : v.key;
const desc = typeof v === 'string' ? '' : (v.description || '');
const value = this.currentSettings[key] || '';
const displayValue = value ? `= ${value}` : '(vacío)';
return `<span class="var-item">
<button class="var-btn" data-var="${key}" title="${desc}">{{${key}}}</button>
<span class="var-value" title="${this.escapeHtml(value)}">${this.escapeHtml(displayValue)}</span>
</span>`;
}).join("")}
</div>
</div>
</div>
${this.versions.length > 0 ? `
<div class="field">
<label>Historial de Versiones</label>
<div class="versions-list" id="versionsList">
${this.versions.map(v => `
<div class="version-item ${v.is_active ? "active" : ""}">
<span class="ver">v${v.version} ${v.is_active ? "(activa)" : ""}</span>
<span class="date">${this.formatDate(v.created_at)}</span>
${!v.is_active ? `<button class="small secondary" data-version="${v.version}">Restaurar</button>` : ""}
</div>
`).join("")}
</div>
</div>
` : ""}
<div class="actions">
<button id="saveBtn">Guardar Cambios</button>
<button id="testBtn" class="secondary">Probar Prompt</button>
</div>
<div class="test-section" id="testSection" style="display:none;">
<h4>Probar Prompt</h4>
<div class="field">
<label>Mensaje de prueba</label>
<input type="text" id="testMessage" placeholder="Ej: Hola, quiero 2kg de asado" />
</div>
<button id="runTestBtn" style="margin-top:8px;">Ejecutar Prueba</button>
<div id="testResultContainer"></div>
</div>
`;
// Event listeners
this.shadowRoot.getElementById("saveBtn").onclick = () => this.save();
this.shadowRoot.getElementById("resetBtn").onclick = () => this.reset();
this.shadowRoot.getElementById("testBtn").onclick = () => this.toggleTestSection();
this.shadowRoot.getElementById("runTestBtn").onclick = () => this.runTest();
// Variable buttons
this.shadowRoot.querySelectorAll(".var-btn").forEach(btn => {
btn.onclick = () => this.insertVariable(btn.dataset.var);
});
// Version restore buttons
this.shadowRoot.querySelectorAll(".versions-list button").forEach(btn => {
btn.onclick = () => this.rollback(parseInt(btn.dataset.version, 10));
});
}
escapeHtml(str) {
return (str || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
formatDate(dateStr) {
if (!dateStr) return "";
const d = new Date(dateStr);
return d.toLocaleDateString("es-AR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
}
insertVariable(varName) {
const textarea = this.shadowRoot.getElementById("contentInput");
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const insertion = `{{${varName}}}`;
textarea.value = text.slice(0, start) + insertion + text.slice(end);
textarea.selectionStart = textarea.selectionEnd = start + insertion.length;
textarea.focus();
}
toggleTestSection() {
const section = this.shadowRoot.getElementById("testSection");
section.style.display = section.style.display === "none" ? "block" : "none";
}
async save() {
const content = this.shadowRoot.getElementById("contentInput").value;
const model = this.shadowRoot.getElementById("modelSelect").value;
if (!content.trim()) {
modal.warn("El contenido no puede estar vacío");
return;
}
try {
await api.savePrompt(this.selected.prompt_key, { content, model });
modal.success("Prompt guardado correctamente");
await this.load();
// Re-seleccionar el prompt actual
const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key);
if (updated) this.selectPrompt(updated);
} catch (e) {
console.error("Error saving prompt:", e);
modal.error("Error guardando: " + (e.message || e));
}
}
async reset() {
const confirmed = await modal.confirm("Esto desactivará todas las versiones custom y volverá al prompt por defecto. ¿Continuar?");
if (!confirmed) return;
try {
await api.resetPrompt(this.selected.prompt_key);
modal.success("Prompt reseteado a default");
await this.load();
const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key);
if (updated) this.selectPrompt(updated);
} catch (e) {
console.error("Error resetting prompt:", e);
modal.error("Error: " + (e.message || e));
}
}
async rollback(version) {
const confirmed = await modal.confirm(`¿Restaurar versión ${version}? Se creará una nueva versión con ese contenido.`);
if (!confirmed) return;
try {
await api.rollbackPrompt(this.selected.prompt_key, version);
modal.success("Versión restaurada");
await this.load();
const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key);
if (updated) this.selectPrompt(updated);
} catch (e) {
console.error("Error rolling back:", e);
modal.error("Error: " + (e.message || e));
}
}
async runTest() {
const testMessage = this.shadowRoot.getElementById("testMessage").value;
if (!testMessage.trim()) {
modal.warn("Ingresa un mensaje de prueba");
return;
}
const content = this.shadowRoot.getElementById("contentInput").value;
const container = this.shadowRoot.getElementById("testResultContainer");
container.innerHTML = `<div class="test-result">Ejecutando prueba...</div>`;
try {
const result = await api.testPrompt(this.selected.prompt_key, {
content,
test_message: testMessage,
store_config: { store_name: "Carniceria Demo", bot_name: "Piaf" },
});
if (result.ok) {
let parsed = result.response;
try {
parsed = JSON.stringify(JSON.parse(result.response), null, 2);
} catch (e) { /* no es JSON */ }
container.innerHTML = `
<div class="test-result success">${this.escapeHtml(parsed)}</div>
<div class="test-meta">
Modelo: ${result.model} | Latencia: ${result.latency_ms}ms |
Tokens: ${result.usage?.total_tokens || "?"}
</div>
`;
} else {
container.innerHTML = `<div class="test-result error">Error: ${result.error || "Unknown"}</div>`;
}
} catch (e) {
console.error("Error testing prompt:", e);
container.innerHTML = `<div class="test-result error">Error: ${e.message || e}</div>`;
}
}
}
customElements.define("prompts-crud", PromptsCrud);

View File

@@ -28,47 +28,47 @@ class QuantitiesCrud extends HTMLElement {
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
input, select { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
input:focus, select:focus { outline:none; border-color:#1f6feb; }
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:#1a5fd0; }
input, select { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
input:focus, select:focus { outline:none; border-color:var(--accent); }
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:var(--accent-hover); }
button:disabled { opacity:.5; cursor:not-allowed; }
button.secondary { background:#253245; }
button.secondary:hover { background:#2d3e52; }
button.secondary { background:var(--border-hi); }
button.secondary:hover { background:var(--border-hi); }
.list { flex:1; overflow-y:auto; }
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; display:flex; justify-content:space-between; align-items:center; }
.item:hover { border-color:#1f6feb; }
.item.active { border-color:#1f6feb; background:#111b2a; }
.item-name { font-weight:500; color:#e7eef7; }
.item-price { font-size:12px; color:#8aa0b5; }
.badge { display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 6px; border-radius:999px; font-size:11px; font-weight:600; background:#1f6feb; color:#fff; }
.badge.empty { background:#253245; color:#8aa0b5; }
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; display:flex; justify-content:space-between; align-items:center; }
.item:hover { border-color:var(--accent); }
.item.active { border-color:var(--accent); background:var(--accent-soft); }
.item-name { font-weight:500; color:var(--text); }
.item-price { font-size:12px; color:var(--text-muted); }
.badge { display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 6px; border-radius:999px; font-size:11px; font-weight:600; background:var(--accent); color:#fff; }
.badge.empty { background:var(--border-hi); color:var(--text-muted); }
.form { flex:1; overflow-y:auto; }
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
.form-empty { color:var(--text-muted); text-align:center; padding:40px; }
.product-header { margin-bottom:16px; }
.product-name { font-size:18px; font-weight:600; color:#e7eef7; margin-bottom:4px; }
.product-price { font-size:14px; color:#8aa0b5; }
.product-name { font-size:18px; font-weight:600; color:var(--text); margin-bottom:4px; }
.product-price { font-size:14px; color:var(--text-muted); }
.qty-grid { width:100%; border-collapse:collapse; }
.qty-grid th { text-align:left; font-size:12px; color:#8aa0b5; padding:10px 8px; border-bottom:1px solid #253245; }
.qty-grid td { padding:8px; border-bottom:1px solid #1e2a3a; }
.qty-grid .event-label { font-size:13px; color:#e7eef7; font-weight:500; }
.qty-grid th { text-align:left; font-size:12px; color:var(--text-muted); padding:10px 8px; border-bottom:1px solid var(--border-hi); }
.qty-grid td { padding:8px; border-bottom:1px solid var(--border); }
.qty-grid .event-label { font-size:13px; color:var(--text); font-weight:500; }
.qty-grid input { width:70px; padding:6px 8px; font-size:12px; text-align:center; }
.qty-grid select { width:70px; padding:6px 4px; font-size:11px; }
.cell-group { display:flex; gap:4px; align-items:center; }
.actions { display:flex; gap:8px; margin-top:16px; }
.loading { text-align:center; padding:40px; color:#8aa0b5; }
.loading { text-align:center; padding:40px; color:var(--text-muted); }
.status { font-size:12px; color:#2ecc71; margin-left:auto; }
.status.error { color:#e74c3c; }
.status { font-size:12px; color:var(--ok); margin-left:auto; }
.status.error { color:var(--err); }
</style>
<div class="container">

View File

@@ -40,45 +40,45 @@ class RecommendationsCrud extends HTMLElement {
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
input, select, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
input, select, textarea { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
input:focus, select:focus, textarea:focus { outline:none; border-color:var(--accent); }
textarea { min-height:60px; resize:vertical; font-size:13px; }
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:#1a5fd0; }
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:var(--accent-hover); }
button:disabled { opacity:.5; cursor:not-allowed; }
button.secondary { background:#253245; }
button.secondary:hover { background:#2d3e52; }
button.danger { background:#e74c3c; }
button.danger:hover { background:#c0392b; }
button.secondary { background:var(--border-hi); }
button.secondary:hover { background:var(--border-hi); }
button.danger { background:var(--err); }
button.danger:hover { background:var(--err); }
button.small { padding:4px 8px; font-size:11px; }
.list { flex:1; overflow-y:auto; }
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
.item:hover { border-color:#1f6feb; }
.item.active { border-color:#1f6feb; background:#111b2a; }
.item-key { font-weight:600; color:#e7eef7; margin-bottom:4px; display:flex; align-items:center; gap:8px; }
.item-trigger { font-size:12px; color:#8aa0b5; margin-bottom:4px; }
.item-queries { font-size:11px; color:#2ecc71; }
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
.item:hover { border-color:var(--accent); }
.item.active { border-color:var(--accent); background:var(--accent-soft); }
.item-key { font-weight:600; color:var(--text); margin-bottom:4px; display:flex; align-items:center; gap:8px; }
.item-trigger { font-size:12px; color:var(--text-muted); margin-bottom:4px; }
.item-queries { font-size:11px; color:var(--ok); }
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:10px; }
.badge.active { background:#0f2a1a; color:#2ecc71; }
.badge.inactive { background:#241214; color:#e74c3c; }
.badge.priority { background:#253245; color:#8aa0b5; }
.badge.type { background:#1a2a4a; color:#5dade2; }
.badge.active { background:var(--ok-soft); color:var(--ok); }
.badge.inactive { background:var(--err-soft); color:var(--err); }
.badge.priority { background:var(--border-hi); color:var(--text-muted); }
.badge.type { background:var(--accent-soft); color:var(--accent-hover); }
.form { flex:1; overflow-y:auto; }
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
.form-empty { color:var(--text-muted); text-align:center; padding:40px; }
.field { margin-bottom:16px; }
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.field-hint { font-size:11px; color:#8aa0b5; margin-top:4px; }
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.field-hint { font-size:11px; color:var(--text-muted); margin-top:4px; }
.field-row { display:flex; gap:12px; }
.field-row .field { flex:1; }
.actions { display:flex; gap:8px; margin-top:16px; }
.loading { text-align:center; padding:40px; color:#8aa0b5; }
.loading { text-align:center; padding:40px; color:var(--text-muted); }
.toggle { display:flex; align-items:center; gap:8px; cursor:pointer; }
.toggle input { width:auto; }
@@ -88,42 +88,42 @@ class RecommendationsCrud extends HTMLElement {
.product-search { margin-bottom:8px; }
.product-dropdown {
position:absolute; top:100%; left:0; right:0; z-index:100;
background:#0f1520; border:1px solid #253245; border-radius:8px;
background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px;
max-height:200px; overflow-y:auto; display:none;
}
.product-dropdown.open { display:block; }
.product-option {
padding:8px 12px; cursor:pointer; font-size:13px; color:#e7eef7;
padding:8px 12px; cursor:pointer; font-size:13px; color:var(--text);
display:flex; justify-content:space-between; align-items:center;
}
.product-option:hover { background:#1a2535; }
.product-option.selected { background:#1a3a5c; }
.product-option .price { font-size:11px; color:#8aa0b5; }
.product-option:hover { background:var(--panel-2); }
.product-option.selected { background:var(--accent-soft); }
.product-option .price { font-size:11px; color:var(--text-muted); }
.selected-products { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; min-height:30px; }
.product-chip {
display:inline-flex; align-items:center; gap:4px;
background:#253245; color:#e7eef7; padding:4px 8px 4px 12px;
background:var(--border-hi); color:var(--text); padding:4px 8px 4px 12px;
border-radius:999px; font-size:12px;
}
.product-chip .remove {
cursor:pointer; width:16px; height:16px; border-radius:50%;
background:#e74c3c; color:#fff; font-size:10px;
background:var(--err); color:#fff; font-size:10px;
display:flex; align-items:center; justify-content:center;
}
.product-chip .remove:hover { background:#c0392b; }
.product-chip .remove:hover { background:var(--err); }
.empty-hint { color:#8aa0b5; font-size:12px; font-style:italic; }
.empty-hint { color:var(--text-muted); font-size:12px; font-style:italic; }
/* Items table styles */
.items-table { width:100%; border-collapse:collapse; margin-top:8px; }
.items-table th { text-align:left; font-size:11px; color:#8aa0b5; padding:8px 4px; border-bottom:1px solid #253245; }
.items-table td { padding:6px 4px; border-bottom:1px solid #1e2a3a; vertical-align:middle; }
.items-table th { text-align:left; font-size:11px; color:var(--text-muted); padding:8px 4px; border-bottom:1px solid var(--border-hi); }
.items-table td { padding:6px 4px; border-bottom:1px solid var(--border); vertical-align:middle; }
.items-table input { padding:6px 8px; font-size:12px; }
.items-table input[type="number"] { width:70px; }
.items-table select { padding:6px 8px; font-size:12px; width:80px; }
.items-table .product-name { font-size:13px; color:#e7eef7; }
.items-table .btn-remove { background:#e74c3c; padding:4px 8px; font-size:11px; }
.items-table .product-name { font-size:13px; color:var(--text); }
.items-table .btn-remove { background:var(--err); padding:4px 8px; font-size:11px; }
.add-item-row { display:flex; gap:8px; margin-top:12px; align-items:flex-end; }
.add-item-row .field { margin-bottom:0; }
@@ -131,12 +131,12 @@ class RecommendationsCrud extends HTMLElement {
/* Rule type selector */
.rule-type-selector { display:flex; gap:8px; margin-bottom:16px; }
.rule-type-btn {
flex:1; padding:12px; border:2px solid #253245; border-radius:8px;
background:#0f1520; color:#8aa0b5; cursor:pointer; text-align:center;
flex:1; padding:12px; border:2px solid var(--border-hi); border-radius:8px;
background:var(--panel-2); color:var(--text-muted); cursor:pointer; text-align:center;
transition:all .15s;
}
.rule-type-btn:hover { border-color:#1f6feb; }
.rule-type-btn.active { border-color:#1f6feb; background:#111b2a; color:#e7eef7; }
.rule-type-btn:hover { border-color:var(--accent); }
.rule-type-btn.active { border-color:var(--accent); background:var(--accent-soft); color:var(--text); }
.rule-type-btn .type-title { font-weight:600; margin-bottom:4px; }
.rule-type-btn .type-desc { font-size:11px; }
</style>

View File

@@ -7,32 +7,74 @@ class RunTimeline extends HTMLElement {
this.attachShadow({ mode: "open" });
this.chatId = null;
this.items = [];
// Track if user scrolled away from the bottom — pause auto-scroll to not chase them
this._userScrolledUp = false;
this._scrollRaf = null;
this.shadowRoot.innerHTML = `
<style>
:host { display:block; padding:12px; height:100%; overflow:hidden; }
.box { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; height:100%; display:flex; flex-direction:column; min-height:0; box-sizing:border-box; }
.row { display:flex; gap:8px; align-items:center; }
.muted { color:#8aa0b5; font-size:12px; }
.title { font-weight:800; }
.chatlog { display:flex; flex-direction:column; gap:0; overflow-y:auto; padding-right:6px; margin-top:8px; flex:1; min-height:0; }
/* WhatsApp-ish dark theme bubbles */
.bubble { max-width:90%; margin-bottom:12px; padding:8px 10px; border-radius:14px; border:1px solid #253245; font-size:13px; line-height:1.35; white-space:pre-wrap; word-break:break-word; box-shadow: 0 1px 0 rgba(0,0,0,.35); box-sizing:border-box; }
.bubble.user { align-self:flex-end; background:#0f2a1a; border-color:#1f6f43; color:#e7eef7; }
.bubble.bot { align-self:flex-start; background:#111b2a; border-color:#2a3a55; color:#e7eef7; }
.bubble.err { align-self:flex-start; background:#241214; border-color:#e74c3c; color:#ffe9ea; cursor:pointer; }
.bubble.active { outline:2px solid #1f6feb; box-shadow: 0 0 0 2px rgba(31,111,235,.25); }
.name { display:block; font-size:12px; font-weight:800; margin-bottom:4px; opacity:.95; }
.bubble.user .name { color:#cdebd8; text-align:right; }
.bubble.bot .name { color:#c7d8ee; }
.bubble.err .name { color:#ffd0d4; }
.bubble .meta { display:block; margin-top:6px; font-size:11px; color:#8aa0b5; }
.bubble.user .meta { color:#b9d9c6; opacity:.85; }
.bubble.bot .meta { color:#a9bed6; opacity:.85; }
.bubble.err .meta { color:#ffd0d4; opacity:.85; }
.toolbar { display:flex; gap:8px; margin-top:8px; align-items:center; }
button { cursor:pointer; background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px; font-size:13px; }
pre { white-space:pre-wrap; word-break:break-word; background:#0f1520; border:1px solid #253245; border-radius:10px; padding:10px; margin:0; font-size:12px; color:#d7e2ef; }
:host { display:block; padding: var(--space-4); height:100%; overflow:hidden; font-family: var(--font-sans); }
.box {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: var(--space-5);
height:100%; display:flex; flex-direction:column; min-height:0; box-sizing:border-box;
box-shadow: var(--shadow-sm);
}
.row { display:flex; gap: var(--space-2); align-items:center; }
.muted { color: var(--text-muted); font-size: var(--fs-sm); }
.title { font-weight: var(--fw-semibold); font-size: var(--fs-md); color: var(--text); }
.chatlog { display:flex; flex-direction:column; gap:0; overflow-y:auto; padding-right:8px; margin-top: var(--space-3); flex:1; min-height:0; }
/* WhatsApp-ish light pastel bubbles */
.bubble {
max-width: 88%; min-width:0;
margin-bottom: var(--space-3);
padding: 10px 14px;
border-radius: var(--r-xl);
border: 1px solid;
font-size: var(--fs-base); line-height: var(--lh-base);
white-space:pre-wrap; word-break:break-word; overflow-wrap:anywhere;
box-shadow: var(--shadow-bubble);
box-sizing:border-box;
}
.bubble.user { align-self:flex-end; background: var(--user-bubble); border-color: var(--user-border); color: var(--user-text); }
.bubble.bot { align-self:flex-start; background: var(--bot-bubble); border-color: var(--bot-border); color: var(--bot-text); }
.bubble.err { align-self:flex-start; background: var(--err-bubble); border-color: var(--err-border); color: var(--err-text); cursor:pointer; }
.bubble.active { outline: 2px solid var(--accent); outline-offset: 1px; }
.name { display:block; font-size: var(--fs-xs); font-weight: var(--fw-semibold); margin-bottom: 4px; letter-spacing: 0.02em; }
.bubble.user .name { color: var(--user-name); text-align:right; }
.bubble.bot .name { color: var(--bot-name); }
.bubble.err .name { color: var(--err-name); }
.bubble .meta { display:block; margin-top: 6px; font-size: var(--fs-xs); color: var(--text-muted); }
.bubble.user .meta { color: var(--user-meta); }
.bubble.bot .meta { color: var(--bot-meta); }
.bubble.err .meta { color: var(--err-meta); }
.toolbar { display:flex; gap: var(--space-2); margin-top: var(--space-3); align-items:center; }
button {
cursor:pointer;
background: var(--panel); color: var(--text);
border: 1px solid var(--border-hi);
border-radius: var(--r-md);
padding: 8px 14px;
font: var(--fw-medium) var(--fs-sm)/1 var(--font-sans);
transition: border-color .15s, background .15s;
}
button:hover { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-hover); }
button:focus-visible { outline:none; box-shadow: var(--focus-ring); }
pre {
white-space:pre-wrap; word-break:break-word; overflow-wrap:anywhere; overflow-x:auto; max-width:100%;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 10px 12px;
margin:0;
font: 400 12px/1.5 var(--font-mono);
color: var(--text-dim);
}
</style>
<div class="box">
@@ -197,14 +239,14 @@ class RunTimeline extends HTMLElement {
addedOptimistic = true;
}
// auto-scroll al final cuando hay mensajes nuevos
// Solo si el usuario estaba cerca del final (dentro de 150px) o si había optimistas
const wasNearBottom = this._lastScrollPosition === undefined ||
(log.scrollHeight - this._lastScrollPosition - log.clientHeight) < 150;
if (addedOptimistic || wasNearBottom) {
// Auto-scroll al final cuando hay mensajes nuevos.
// Si el usuario scrolleó arriba (>150px del fondo), respetamos su posición
// a menos que él mismo haya disparado un optimistic bubble.
if (addedOptimistic || !this._userScrolledUp) {
log.scrollTop = log.scrollHeight;
// Una vez forzado al final, considerarlo "abajo" hasta que vuelva a scrollear arriba.
this._userScrolledUp = false;
}
this._lastScrollPosition = log.scrollTop;
requestAnimationFrame(() => this.emitLayout());
this.bindScroll(log);
@@ -215,8 +257,14 @@ class RunTimeline extends HTMLElement {
if (this._scrollBound) return;
this._scrollBound = true;
log.addEventListener("scroll", () => {
this._lastScrollPosition = log.scrollTop;
emit("ui:chatScroll", { chat_id: this.chatId, scrollTop: log.scrollTop });
// Throttle con rAF: el handler real corre 1x por frame.
if (this._scrollRaf) return;
this._scrollRaf = requestAnimationFrame(() => {
this._scrollRaf = null;
const distFromBottom = log.scrollHeight - log.scrollTop - log.clientHeight;
this._userScrolledUp = distFromBottom > 150;
emit("ui:chatScroll", { chat_id: this.chatId, scrollTop: log.scrollTop, userScrolledUp: this._userScrolledUp });
});
});
}
@@ -294,11 +342,10 @@ class RunTimeline extends HTMLElement {
log.appendChild(bubble);
// Solo hacer scroll si el usuario ya estaba cerca del final (dentro de 100px)
const wasNearBottom = (log.scrollHeight - log.scrollTop - log.clientHeight) < 100;
if (wasNearBottom) {
// El usuario acaba de mandar un mensaje: forzamos al final y reseteamos
// el flag "scrolled-up" así los próximos mensajes del bot también auto-scrollean.
log.scrollTop = log.scrollHeight;
}
this._userScrolledUp = false;
// Emit layout update
requestAnimationFrame(() => this.emitLayout());

View File

@@ -1,15 +1,22 @@
import { api } from "../lib/api.js";
const DAYS = [
{ id: "lun", label: "Lunes", short: "Lun" },
{ id: "mar", label: "Martes", short: "Mar" },
{ id: "mie", label: "Miércoles", short: "Mié" },
{ id: "jue", label: "Jueves", short: "Jue" },
{ id: "vie", label: "Viernes", short: "Vie" },
{ id: "sab", label: "Sábado", short: "Sáb" },
{ id: "dom", label: "Domingo", short: "Dom" },
{ id: "lun", label: "Lunes", short: "L" },
{ id: "mar", label: "Martes", short: "M" },
{ id: "mie", label: "Miércoles", short: "X" },
{ id: "jue", label: "Jueves", short: "J" },
{ id: "vie", label: "Viernes", short: "V" },
{ id: "sab", label: "Sábado", short: "S" },
{ id: "dom", label: "Domingo", short: "D" },
];
function makeZoneId(name) {
return String(name || "").toLowerCase()
.normalize("NFD").replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
.slice(0, 40) || `zona-${Date.now().toString(36)}`;
}
class SettingsCrud extends HTMLElement {
constructor() {
super();
@@ -17,53 +24,79 @@ class SettingsCrud extends HTMLElement {
this.settings = null;
this.loading = false;
this.saving = false;
this.zones = [];
this.selectedZoneId = null;
this._mapEditor = null;
this.shadowRoot.innerHTML = `
<style>
:host { display:block; height:100%; padding:16px; overflow:auto; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { max-width:800px; margin:0 auto; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:20px; margin-bottom:16px; }
.panel-title { font-size:16px; font-weight:700; color:#e7eef7; margin-bottom:16px; display:flex; align-items:center; gap:8px; }
.panel-title svg { width:20px; height:20px; fill:#1f6feb; }
:host { display:block; height:100%; padding:16px 24px 24px; overflow:auto; }
* { box-sizing:border-box; font-family:var(--font-sans, system-ui); }
.container { max-width:1600px; margin:0 auto; }
.settings-grid {
display:grid;
grid-template-columns:minmax(320px, 360px) minmax(0, 1fr);
gap:16px;
align-items:start;
}
@media (max-width: 960px) {
.settings-grid { grid-template-columns:1fr; }
}
.col { display:flex; flex-direction:column; gap:16px; }
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:20px; }
.panel-zones { padding:16px 16px 20px; display:flex; flex-direction:column; min-height:560px; }
.toolbar {
display:flex; align-items:center; justify-content:space-between;
gap:16px; padding:12px 4px 16px;
position:sticky; top:0; z-index:5; background:var(--bg, #f7fafc);
border-bottom:1px solid var(--border);
margin-bottom:16px;
}
.toolbar-title { margin:0; font-size:18px; font-weight:600; color:var(--text); }
.toolbar-actions { display:flex; gap:8px; }
.toolbar-actions button { padding:8px 16px; font-size:13px; }
.zones-map-wrap { display:flex; min-height:520px; min-width:0; }
.zones-map-wrap zone-map-editor { flex:1; height:auto; min-height:520px; }
.panel-title { font-size:16px; font-weight:700; color:var(--text); margin-bottom:16px; display:flex; align-items:center; gap:8px; }
.panel-title svg { width:20px; height:20px; fill:var(--accent); }
.form-row { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; }
.form-row.full { grid-template-columns:1fr; }
.field { }
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:6px; text-transform:uppercase; letter-spacing:.4px; }
.field-hint { font-size:11px; color:#6c7a89; margin-top:4px; }
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:6px; text-transform:uppercase; letter-spacing:.4px; }
.field-hint { font-size:11px; color:var(--text-muted); margin-top:4px; }
input, select, textarea {
background:#0f1520; color:#e7eef7; border:1px solid #253245;
background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi);
border-radius:8px; padding:10px 14px; font-size:14px; width:100%;
}
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
input:focus, select:focus, textarea:focus { outline:none; border-color:var(--accent); }
input:disabled { opacity:.6; cursor:not-allowed; }
button {
cursor:pointer; background:#1f6feb; color:#fff; border:none;
cursor:pointer; background:var(--accent); color:#fff; border:none;
border-radius:8px; padding:10px 20px; font-size:14px; font-weight:600;
}
button:hover { background:#1a5fd0; }
button:hover { background:var(--accent-hover); }
button:disabled { opacity:.5; cursor:not-allowed; }
button.secondary { background:#253245; }
button.secondary:hover { background:#2d3e52; }
button.secondary { background:var(--border-hi); }
button.secondary:hover { background:var(--border-hi); }
.toggle-row { display:flex; align-items:center; gap:12px; margin-bottom:16px; }
.toggle {
position:relative; width:48px; height:26px;
background:#253245; border-radius:13px; cursor:pointer;
background:var(--border-hi); border-radius:13px; cursor:pointer;
transition:background .2s; flex-shrink:0;
}
.toggle.active { background:#1f6feb; }
.toggle.active { background:var(--accent); }
.toggle::after {
content:''; position:absolute; top:3px; left:3px;
width:20px; height:20px; background:#fff; border-radius:50%;
transition:transform .2s;
}
.toggle.active::after { transform:translateX(22px); }
.toggle-label { font-size:14px; color:#e7eef7; }
.toggle-label { font-size:14px; color:var(--text); }
/* Schedule grid */
.schedule-grid { display:flex; flex-direction:column; gap:8px; }
@@ -73,17 +106,17 @@ class SettingsCrud extends HTMLElement {
gap:12px;
align-items:center;
padding:8px 12px;
background:#0f1520;
background:var(--panel-2);
border-radius:8px;
border:1px solid #1e2a3a;
border:1px solid var(--border);
}
.schedule-row.disabled { opacity:0.4; }
.day-label { font-size:13px; color:#e7eef7; font-weight:500; }
.day-label { font-size:13px; color:var(--text); font-weight:500; }
.day-toggle {
width:32px; height:18px; background:#253245; border-radius:9px;
width:32px; height:18px; background:var(--border-hi); border-radius:9px;
cursor:pointer; position:relative; transition:background .2s;
}
.day-toggle.active { background:#2ecc71; }
.day-toggle.active { background:var(--ok); }
.day-toggle::after {
content:''; position:absolute; top:2px; left:2px;
width:14px; height:14px; background:#fff; border-radius:50%;
@@ -95,24 +128,61 @@ class SettingsCrud extends HTMLElement {
width:70px; text-align:center; font-family:monospace;
font-size:13px; padding:6px 8px; letter-spacing:1px;
}
.hours-inputs span { color:#6c7a89; font-size:12px; }
.hours-inputs span { color:var(--text-muted); font-size:12px; }
.hours-inputs.disabled input { opacity:0.4; pointer-events:none; }
.actions { display:flex; gap:12px; margin-top:24px; }
.loading { text-align:center; padding:60px; color:#8aa0b5; }
.loading { text-align:center; padding:60px; color:var(--text-muted); }
.success-msg {
background:#2ecc7130; border:1px solid #2ecc71;
color:#2ecc71; padding:12px 16px; border-radius:8px;
background:var(--ok)30; border:1px solid var(--ok);
color:var(--ok); padding:12px 16px; border-radius:8px;
margin-bottom:16px; font-size:14px;
}
.error-msg {
background:#e74c3c30; border:1px solid #e74c3c;
color:#e74c3c; padding:12px 16px; border-radius:8px;
background:var(--err)30; border:1px solid var(--err);
color:var(--err); padding:12px 16px; border-radius:8px;
margin-bottom:16px; font-size:14px;
}
.min-order-field { margin-top:16px; padding-top:16px; border-top:1px solid #1e2a3a; }
.min-order-field { margin-top:16px; padding-top:16px; border-top:1px solid var(--border); }
/* Zonas de entrega — editor con mapa */
.zones-layout { display:grid; grid-template-columns:300px minmax(0,1fr); gap:16px; height:calc(100vh - 220px); min-height:520px; }
@media (max-width: 1100px) { .zones-layout { grid-template-columns:1fr; height:auto; } }
.zones-side { display:flex; flex-direction:column; gap:8px; min-width:0; min-height:0; }
.zones-side-header { display:flex; align-items:center; justify-content:space-between; }
.zones-side-header h4 { margin:0; font-size:13px; color:var(--text); }
.zones-side-header button { padding:6px 10px; font-size:12px; }
.zones-list { display:flex; flex-direction:column; gap:6px; flex:1; min-height:120px; max-height:50%; overflow-y:auto; padding-right:4px; }
.zone-row {
display:flex; align-items:center; gap:10px;
padding:10px 12px; border-radius:var(--r-md, 10px);
background:var(--panel-2); border:1px solid var(--border);
cursor:pointer; transition:border-color .15s, background .15s;
}
.zone-row:hover { border-color:var(--border-hi); }
.zone-row.active { border-color:var(--accent); background:var(--accent-soft); }
.zone-row.disabled { opacity:.55; }
.zone-swatch { width:14px; height:14px; border-radius:4px; flex-shrink:0; }
.zone-row-main { flex:1; min-width:0; display:flex; flex-direction:column; gap:2px; }
.zone-row-name { font-size:13px; font-weight:500; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.zone-row-meta { font-size:11px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.zones-empty { padding:24px; text-align:center; color:var(--text-muted); font-size:13px; }
.zone-form { padding:14px; background:var(--panel-2); border:1px solid var(--border); border-radius:var(--r-md, 10px); display:flex; flex-direction:column; gap:12px; }
.zone-form .row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.zone-form .row.three { grid-template-columns:2fr 1fr 1fr; }
.zone-form label { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; }
.zone-form input { padding:8px 10px; font-size:13px; }
.zone-days-pick { display:flex; gap:4px; flex-wrap:wrap; }
.zone-day-pick { padding:4px 8px; border-radius:6px; font-size:11px; font-weight:600; cursor:pointer;
background:var(--border); color:var(--text-muted); border:1px solid transparent; }
.zone-day-pick.active { background:var(--accent); color:var(--text-on-acc, #fff); }
.zone-row-actions { display:flex; gap:8px; justify-content:flex-end; }
.zone-row-actions button { padding:6px 10px; font-size:12px; }
.zone-row-actions .danger { background:var(--err); }
.zones-summary { margin-top:8px; padding:10px 12px; background:var(--panel-2); border-radius:var(--r-md, 10px); font-size:12px; color:var(--text-muted); }
.zones-summary strong { color:var(--text); }
</style>
<div class="container">
@@ -134,10 +204,12 @@ class SettingsCrud extends HTMLElement {
try {
this.settings = await api.getSettings();
// Asegurar que schedule existe
if (!this.settings.schedule) {
this.settings.schedule = { delivery: {}, pickup: {} };
}
const dz = this.settings.delivery_zones || {};
this.zones = Array.isArray(dz.zones) ? dz.zones.map((z) => ({ ...z })) : [];
this.selectedZoneId = this.zones[0]?.id || null;
this.loading = false;
this.render();
} catch (e) {
@@ -201,6 +273,117 @@ class SettingsCrud extends HTMLElement {
}).join("");
}
ZONE_PALETTE_VARS() {
return ["--chart-blue", "--chart-green", "--chart-purple", "--chart-orange", "--chart-pink", "--chart-gray"];
}
zoneSwatchColor(idx) {
const palette = this.ZONE_PALETTE_VARS();
return `var(${palette[idx % palette.length]})`;
}
formatDaysShort(days) {
if (!Array.isArray(days) || !days.length) return "\u2014";
const order = ["lun","mar","mie","jue","vie","sab","dom"];
const idx = days.map((d) => order.indexOf(d)).filter((i) => i >= 0).sort((a, b) => a - b);
if (idx.length >= 3 && idx.every((v, i, arr) => i === 0 || v === arr[i - 1] + 1)) {
return `${order[idx[0]]}-${order[idx[idx.length - 1]]}`;
}
return idx.map((i) => order[i]).join("/");
}
renderZonesList() {
if (!this.zones.length) {
return `<div class="zones-empty">No hay zonas dibujadas todavía. Tocá <strong>Crear zona</strong> y dibujá un polígono en el mapa.</div>`;
}
return this.zones.map((z, i) => {
const cost = z.delivery_cost != null ? `$${Number(z.delivery_cost).toLocaleString("es-AR")}` : "";
const days = this.formatDaysShort(z.delivery_days);
const start = z.delivery_hours?.start?.slice(0, 5) || "";
const end = z.delivery_hours?.end?.slice(0, 5) || "";
const hours = start && end ? `${start}-${end}` : "";
const meta = [cost, days, hours].filter(Boolean).join(" · ");
const active = z.id === this.selectedZoneId ? "active" : "";
const disabled = z.enabled === false ? "disabled" : "";
return `
<div class="zone-row ${active} ${disabled}" data-zone-id="${z.id}">
<div class="zone-swatch" style="background:${this.zoneSwatchColor(i)}"></div>
<div class="zone-row-main">
<div class="zone-row-name">${this.escapeHtml(z.name || z.id)}</div>
<div class="zone-row-meta">${this.escapeHtml(meta || "sin configurar")}</div>
</div>
</div>
`;
}).join("");
}
renderZoneForm() {
const z = this.zones.find((x) => x.id === this.selectedZoneId);
if (!z) {
return `<div class="zones-empty">Seleccioná una zona en la lista o dibujá una nueva en el mapa para configurarla.</div>`;
}
const days = z.delivery_days || [];
const start = z.delivery_hours?.start?.slice(0, 5) || "10:00";
const end = z.delivery_hours?.end?.slice(0, 5) || "20:00";
return `
<div class="zone-form" data-zone-id="${z.id}">
<div class="row three">
<div class="field">
<label>Nombre</label>
<input type="text" id="zoneName" value="${this.escapeHtml(z.name || "")}" maxlength="60" />
</div>
<div class="field">
<label>Costo de envío ($)</label>
<input type="number" id="zoneCost" value="${z.delivery_cost ?? 0}" min="0" step="100" />
</div>
<div class="field">
<label>Mín. pedido ($)</label>
<input type="number" id="zoneMin" value="${z.min_order_amount ?? 0}" min="0" step="100" />
</div>
</div>
<div class="field">
<label>Días de entrega</label>
<div class="zone-days-pick">
${DAYS.map((d) => `
<span class="zone-day-pick ${days.includes(d.id) ? "active" : ""}" data-day="${d.id}" title="${d.label}">${d.short}</span>
`).join("")}
</div>
</div>
<div class="row">
<div class="field">
<label>Hora de inicio</label>
<input type="time" id="zoneStart" value="${start}" />
</div>
<div class="field">
<label>Hora de fin</label>
<input type="time" id="zoneEnd" value="${end}" />
</div>
</div>
<div class="row">
<div class="field">
<label>Estado</label>
<select id="zoneEnabled">
<option value="true" ${z.enabled !== false ? "selected" : ""}>Habilitada</option>
<option value="false" ${z.enabled === false ? "selected" : ""}>Deshabilitada (no recibe pedidos)</option>
</select>
</div>
<div class="zone-row-actions" style="align-self:end;">
<button class="secondary" id="zoneFitBtn">Centrar en mapa</button>
<button class="danger" id="zoneDeleteBtn">Eliminar zona</button>
</div>
</div>
</div>
`;
}
renderZonesSummary() {
const enabled = this.zones.filter((z) => z.enabled !== false);
if (!this.zones.length) {
return `<div class="zones-summary">Dibujá al menos una zona en el mapa para que el bot pueda confirmar envíos. Sin zonas, todas las direcciones quedan sin validar.</div>`;
}
return `<div class="zones-summary"><strong>${enabled.length}</strong> de <strong>${this.zones.length}</strong> zona${this.zones.length > 1 ? "s" : ""} habilitada${enabled.length === 1 ? "" : "s"}.</div>`;
}
render() {
const content = this.shadowRoot.getElementById("content");
@@ -217,82 +400,89 @@ class SettingsCrud extends HTMLElement {
const s = this.settings;
content.innerHTML = `
<!-- Info del Negocio -->
<div class="toolbar">
<h2 class="toolbar-title">Configuración</h2>
<div class="toolbar-actions">
<button id="resetBtn" class="secondary" type="button">Restaurar</button>
<button id="saveBtn" type="button" ${this.saving ? "disabled" : ""}>
${this.saving ? "Guardando..." : "Guardar"}
</button>
</div>
</div>
<div class="settings-grid">
<div class="col">
<div class="panel">
<div class="panel-title">
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
Información del Negocio
</div>
<div class="form-row">
<div class="field">
<div class="field" style="margin-bottom:12px;">
<label>Nombre del negocio</label>
<input type="text" id="storeName" value="${this.escapeHtml(s.store_name || "")}" placeholder="Ej: Carnicería Don Pedro" />
<div class="field-hint">Se usa en los mensajes del bot</div>
</div>
<div class="field">
<div class="field" style="margin-bottom:12px;">
<label>Nombre del bot</label>
<input type="text" id="botName" value="${this.escapeHtml(s.bot_name || "")}" placeholder="Ej: Piaf" />
<div class="field-hint">El asistente virtual</div>
</div>
</div>
<div class="form-row">
<div class="field">
<div class="field" style="margin-bottom:12px;">
<label>Dirección</label>
<input type="text" id="storeAddress" value="${this.escapeHtml(s.store_address || "")}" placeholder="Ej: Av. Corrientes 1234, CABA" />
<input type="text" id="storeAddress" value="${this.escapeHtml(s.store_address || "")}" placeholder="Av. Corrientes 1234, CABA" />
</div>
<div class="field">
<label>Teléfono</label>
<input type="text" id="storePhone" value="${this.escapeHtml(s.store_phone || "")}" placeholder="Ej: +54 11 1234-5678" />
</div>
<input type="text" id="storePhone" value="${this.escapeHtml(s.store_phone || "")}" placeholder="+54 11 1234-5678" />
</div>
</div>
<!-- Delivery -->
<div class="panel">
<div class="panel-title">
<svg viewBox="0 0 24 24"><path d="M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z"/></svg>
Delivery (Envío a domicilio)
</div>
<div class="toggle-row">
<div class="toggle ${s.delivery_enabled ? "active" : ""}" id="deliveryToggle"></div>
<span class="toggle-label">Delivery habilitado</span>
</div>
<div class="schedule-grid" id="deliverySchedule">
${this.renderScheduleGrid("delivery", s.delivery_enabled)}
</div>
<div class="min-order-field">
<div class="field">
<label>Pedido mínimo para delivery ($)</label>
<input type="number" id="deliveryMinOrder" value="${s.delivery_min_order || 0}" min="0" step="100" style="width:150px;" />
</div>
</div>
</div>
<!-- Retiro en tienda -->
<div class="panel">
<div class="panel-title">
<svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14l-5-5 1.41-1.41L12 14.17l4.59-4.58L18 11l-6 6z"/></svg>
Retiro en Tienda
</div>
<div class="toggle-row">
<div class="toggle ${s.pickup_enabled ? "active" : ""}" id="pickupToggle"></div>
<span class="toggle-label">Retiro en tienda habilitado</span>
<span class="toggle-label">Retiro habilitado</span>
</div>
<div class="schedule-grid" id="pickupSchedule">
${this.renderScheduleGrid("pickup", s.pickup_enabled)}
</div>
</div>
</div>
<div class="actions">
<button id="saveBtn" ${this.saving ? "disabled" : ""}>${this.saving ? "Guardando..." : "Guardar Configuración"}</button>
<button id="resetBtn" class="secondary">Restaurar</button>
<div class="col">
<div class="panel panel-zones">
<div class="panel-title" style="margin-bottom:6px;">
<svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
Zonas de Entrega
</div>
<div class="field-hint" style="margin-bottom:12px;">
Dibujá los polígonos en el mapa. Cada zona tiene su costo, días y rango horario.
El bot matchea con la ubicación que el cliente comparta por WhatsApp.
</div>
<div class="zones-layout">
<div class="zones-side">
<div class="zones-side-header">
<h4>Zonas</h4>
<button id="zoneCreateBtn" type="button">+ Crear zona</button>
</div>
<div class="zones-list" id="zonesList">
${this.renderZonesList()}
</div>
<div id="zoneFormSlot">
${this.renderZoneForm()}
</div>
${this.renderZonesSummary()}
</div>
<div class="zones-map-wrap">
<zone-map-editor id="zoneMapEditor"></zone-map-editor>
</div>
</div>
</div>
</div>
</div>
`;
@@ -300,13 +490,6 @@ class SettingsCrud extends HTMLElement {
}
setupEventListeners() {
// Toggle delivery
const deliveryToggle = this.shadowRoot.getElementById("deliveryToggle");
deliveryToggle?.addEventListener("click", () => {
this.settings.delivery_enabled = !this.settings.delivery_enabled;
this.render();
});
// Toggle pickup
const pickupToggle = this.shadowRoot.getElementById("pickupToggle");
pickupToggle?.addEventListener("click", () => {
@@ -359,26 +542,143 @@ class SettingsCrud extends HTMLElement {
// Reset button
this.shadowRoot.getElementById("resetBtn")?.addEventListener("click", () => this.load());
this.setupZoneEditor();
}
collectScheduleFromInputs() {
const schedule = { delivery: {}, pickup: {} };
setupZoneEditor() {
const editor = this.shadowRoot.getElementById("zoneMapEditor");
if (!editor) return;
this._mapEditor = editor;
for (const type of ["delivery", "pickup"]) {
this.shadowRoot.querySelectorAll(`.hour-start[data-type="${type}"]`).forEach(input => {
const day = input.dataset.day;
const endInput = this.shadowRoot.querySelector(`.hour-end[data-type="${type}"][data-day="${day}"]`);
const toggle = this.shadowRoot.querySelector(`.day-toggle[data-type="${type}"][data-day="${day}"]`);
editor.zones = this.zones;
if (this.selectedZoneId) editor.selectedId = this.selectedZoneId;
if (toggle?.classList.contains("active")) {
schedule[type][day] = {
start: input.value.trim() || (type === "delivery" ? "09:00" : "08:00"),
end: endInput?.value.trim() || (type === "delivery" ? "18:00" : "20:00"),
};
editor.addEventListener("change", (e) => {
const next = e.detail.zones || [];
const merged = [];
for (const z of next) {
const existing = this.zones.find((x) => x.id === z.id);
if (existing) merged.push({ ...existing, polygon: z.polygon });
else merged.push(z);
}
this.zones = merged;
this.refreshZonesPanel();
});
editor.addEventListener("select", (e) => {
this.selectedZoneId = e.detail.id || null;
this.refreshZonesPanel();
});
this.shadowRoot.getElementById("zoneCreateBtn")?.addEventListener("click", () => {
this._mapEditor?.startDrawing();
});
this.attachZoneSideListeners();
}
attachZoneSideListeners() {
this.shadowRoot.querySelectorAll(".zone-row[data-zone-id]").forEach((row) => {
row.addEventListener("click", () => {
const id = row.dataset.zoneId;
this.selectedZoneId = id;
if (this._mapEditor) this._mapEditor.selectedId = id;
this.refreshZonesPanel();
});
});
const z = this.zones.find((x) => x.id === this.selectedZoneId);
if (!z) return;
const onChange = () => this.refreshZonesList();
const nameEl = this.shadowRoot.getElementById("zoneName");
nameEl?.addEventListener("input", () => { z.name = nameEl.value; onChange(); });
const costEl = this.shadowRoot.getElementById("zoneCost");
costEl?.addEventListener("change", () => { z.delivery_cost = Number(costEl.value) || 0; onChange(); });
const minEl = this.shadowRoot.getElementById("zoneMin");
minEl?.addEventListener("change", () => { z.min_order_amount = Number(minEl.value) || 0; });
const startEl = this.shadowRoot.getElementById("zoneStart");
startEl?.addEventListener("change", () => {
z.delivery_hours = { ...(z.delivery_hours || {}), start: startEl.value };
onChange();
});
const endEl = this.shadowRoot.getElementById("zoneEnd");
endEl?.addEventListener("change", () => {
z.delivery_hours = { ...(z.delivery_hours || {}), end: endEl.value };
onChange();
});
const enabledEl = this.shadowRoot.getElementById("zoneEnabled");
enabledEl?.addEventListener("change", () => {
z.enabled = enabledEl.value === "true";
onChange();
});
this.shadowRoot.querySelectorAll(".zone-day-pick").forEach((btn) => {
btn.addEventListener("click", () => {
const day = btn.dataset.day;
const days = z.delivery_days || [];
const idx = days.indexOf(day);
if (idx >= 0) days.splice(idx, 1);
else days.push(day);
z.delivery_days = days;
btn.classList.toggle("active", days.includes(day));
onChange();
});
});
this.shadowRoot.getElementById("zoneFitBtn")?.addEventListener("click", () => {
if (this._mapEditor) this._mapEditor.selectedId = z.id;
});
this.shadowRoot.getElementById("zoneDeleteBtn")?.addEventListener("click", () => {
if (this._mapEditor) this._mapEditor.removeZone(z.id);
this.zones = this.zones.filter((x) => x.id !== z.id);
if (this.selectedZoneId === z.id) this.selectedZoneId = this.zones[0]?.id || null;
this.refreshZonesPanel();
});
}
refreshZonesPanel() {
const list = this.shadowRoot.getElementById("zonesList");
const formSlot = this.shadowRoot.getElementById("zoneFormSlot");
if (list) list.innerHTML = this.renderZonesList();
if (formSlot) formSlot.innerHTML = this.renderZoneForm();
this.attachZoneSideListeners();
}
refreshZonesList() {
const list = this.shadowRoot.getElementById("zonesList");
if (list) list.innerHTML = this.renderZonesList();
this.shadowRoot.querySelectorAll(".zone-row[data-zone-id]").forEach((row) => {
row.addEventListener("click", () => {
const id = row.dataset.zoneId;
this.selectedZoneId = id;
if (this._mapEditor) this._mapEditor.selectedId = id;
this.refreshZonesPanel();
});
});
}
collectScheduleFromInputs() {
// Sólo pickup: el horario de delivery vive ahora dentro de cada zona.
const schedule = { pickup: {} };
this.shadowRoot.querySelectorAll(`.hour-start[data-type="pickup"]`).forEach((input) => {
const day = input.dataset.day;
const endInput = this.shadowRoot.querySelector(`.hour-end[data-type="pickup"][data-day="${day}"]`);
const toggle = this.shadowRoot.querySelector(`.day-toggle[data-type="pickup"][data-day="${day}"]`);
if (toggle?.classList.contains("active")) {
schedule.pickup[day] = {
start: input.value.trim() || "08:00",
end: endInput?.value.trim() || "20:00",
};
}
});
return schedule;
}
@@ -386,15 +686,39 @@ class SettingsCrud extends HTMLElement {
// Collect schedule from inputs
const schedule = this.collectScheduleFromInputs();
// Antes de serializar, refrescar polígonos desde el editor por si el
// usuario editó vértices y no llegó a disparar otro evento change.
if (this._mapEditor) {
const live = this._mapEditor.zones;
this.zones = this.zones.map((z) => {
const fromMap = live.find((x) => x.id === z.id);
return fromMap ? { ...z, polygon: fromMap.polygon } : z;
});
}
const cleanZones = this.zones
.filter((z) => z.polygon && Array.isArray(z.polygon.coordinates))
.map((z) => ({
id: z.id,
name: z.name || z.id,
polygon: z.polygon,
delivery_cost: Number(z.delivery_cost) || 0,
delivery_days: Array.isArray(z.delivery_days) ? z.delivery_days : [],
delivery_hours: z.delivery_hours || { start: "10:00", end: "20:00" },
min_order_amount: Number(z.min_order_amount) || 0,
enabled: z.enabled !== false,
}));
const delivery_zones = { zones: cleanZones };
const data = {
store_name: this.shadowRoot.getElementById("storeName")?.value || "",
bot_name: this.shadowRoot.getElementById("botName")?.value || "",
store_address: this.shadowRoot.getElementById("storeAddress")?.value || "",
store_phone: this.shadowRoot.getElementById("storePhone")?.value || "",
delivery_enabled: this.settings.delivery_enabled,
pickup_enabled: this.settings.pickup_enabled,
delivery_min_order: parseFloat(this.shadowRoot.getElementById("deliveryMinOrder")?.value) || 0,
schedule,
delivery_zones,
};
// Update settings with form values

View File

@@ -18,87 +18,87 @@ class TakeoversCrud extends HTMLElement {
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:350px 1fr; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; display:flex; align-items:center; gap:8px; }
.panel-title .badge { background:#e74c3c; color:#fff; padding:2px 8px; border-radius:10px; font-size:11px; }
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; display:flex; align-items:center; gap:8px; }
.panel-title .badge { background:var(--err); color:#fff; padding:2px 8px; border-radius:10px; font-size:11px; }
input, select, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
input, select, textarea { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
input:focus, select:focus, textarea:focus { outline:none; border-color:var(--accent); }
textarea { resize:vertical; min-height:100px; }
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:#1a5fd0; }
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:var(--accent-hover); }
button:disabled { opacity:.5; cursor:not-allowed; }
button.secondary { background:#253245; }
button.secondary:hover { background:#2d3e52; }
button.danger { background:#e74c3c; }
button.danger:hover { background:#c0392b; }
button.secondary { background:var(--border-hi); }
button.secondary:hover { background:var(--border-hi); }
button.danger { background:var(--err); }
button.danger:hover { background:var(--err); }
.list { flex:1; overflow-y:auto; }
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
.item:hover { border-color:#1f6feb; }
.item.active { border-color:#1f6feb; background:#111b2a; }
.item-query { font-weight:600; color:#f39c12; margin-bottom:4px; font-size:14px; }
.item-reason { font-size:12px; color:#8aa0b5; margin-bottom:4px; }
.item-time { font-size:11px; color:#6c7a89; }
.item-chat { font-size:11px; color:#1f6feb; }
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
.item:hover { border-color:var(--accent); }
.item.active { border-color:var(--accent); background:var(--accent-soft); }
.item-query { font-weight:600; color:var(--warn); margin-bottom:4px; font-size:14px; }
.item-reason { font-size:12px; color:var(--text-muted); margin-bottom:4px; }
.item-time { font-size:11px; color:var(--text-muted); }
.item-chat { font-size:11px; color:var(--accent); }
.form { flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:16px; }
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
.form-empty { color:var(--text-muted); text-align:center; padding:40px; }
.field { }
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.actions { display:flex; gap:8px; margin-top:16px; }
.loading { text-align:center; padding:40px; color:#8aa0b5; }
.loading { text-align:center; padding:40px; color:var(--text-muted); }
.conversation-history { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; max-height:200px; overflow-y:auto; }
.conversation-history { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; max-height:200px; overflow-y:auto; }
.msg { margin-bottom:8px; padding:8px; border-radius:6px; font-size:12px; }
.msg.user { background:#1a2a3a; border-left:3px solid #1f6feb; }
.msg.assistant { background:#1a2535; border-left:3px solid #2ecc71; }
.msg-role { font-size:10px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; }
.msg-content { color:#e7eef7; white-space:pre-wrap; }
.msg.user { background:var(--accent-soft); border-left:3px solid var(--accent); }
.msg.assistant { background:var(--panel-2); border-left:3px solid var(--ok); }
.msg-role { font-size:10px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; }
.msg-content { color:var(--text); white-space:pre-wrap; }
.query-highlight { background:#f39c1230; border:1px solid #f39c12; border-radius:8px; padding:12px; margin-bottom:16px; }
.query-highlight label { color:#f39c12; }
.query-highlight .query { font-size:16px; font-weight:600; color:#f39c12; margin-top:4px; }
.query-highlight { background:var(--warn)30; border:1px solid var(--warn); border-radius:8px; padding:12px; margin-bottom:16px; }
.query-highlight label { color:var(--warn); }
.query-highlight .query { font-size:16px; font-weight:600; color:var(--warn); margin-top:4px; }
.alias-section { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-top:12px; }
.alias-section h4 { margin:0 0 12px; font-size:13px; color:#8aa0b5; }
.alias-section { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-top:12px; }
.alias-section h4 { margin:0 0 12px; font-size:13px; color:var(--text-muted); }
.checkbox-row { display:flex; align-items:center; gap:8px; margin-bottom:12px; }
.checkbox-row input[type="checkbox"] { width:auto; }
.checkbox-row label { font-size:13px; color:#e7eef7; text-transform:none; }
.checkbox-row label { font-size:13px; color:var(--text); text-transform:none; }
.product-selector { position:relative; }
.product-dropdown {
position:absolute; top:100%; left:0; right:0; z-index:100;
background:#0f1520; border:1px solid #253245; border-radius:8px;
background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px;
max-height:200px; overflow-y:auto; display:none;
}
.product-dropdown.open { display:block; }
.product-option {
padding:8px 12px; cursor:pointer; font-size:13px; color:#e7eef7;
padding:8px 12px; cursor:pointer; font-size:13px; color:var(--text);
display:flex; justify-content:space-between; align-items:center;
}
.product-option:hover { background:#1a2535; }
.product-option .price { font-size:11px; color:#8aa0b5; }
.product-option:hover { background:var(--panel-2); }
.product-option .price { font-size:11px; color:var(--text-muted); }
.cart-section { background:#0d2818; border:1px solid #2ecc71; border-radius:8px; padding:12px; margin-bottom:12px; }
.cart-section h4 { margin:0 0 12px; font-size:13px; color:#2ecc71; display:flex; align-items:center; gap:8px; }
.cart-section h4 svg { width:16px; height:16px; fill:#2ecc71; }
.cart-section { background:var(--ok-soft); border:1px solid var(--ok); border-radius:8px; padding:12px; margin-bottom:12px; }
.cart-section h4 { margin:0 0 12px; font-size:13px; color:var(--ok); display:flex; align-items:center; gap:8px; }
.cart-section h4 svg { width:16px; height:16px; fill:var(--ok); }
.cart-items-list { margin-bottom:12px; }
.cart-item-row { display:flex; align-items:center; gap:8px; padding:8px; background:#0f1520; border-radius:6px; margin-bottom:6px; }
.cart-item-row .name { flex:1; font-size:13px; color:#e7eef7; }
.cart-item-row { display:flex; align-items:center; gap:8px; padding:8px; background:var(--panel-2); border-radius:6px; margin-bottom:6px; }
.cart-item-row .name { flex:1; font-size:13px; color:var(--text); }
.cart-item-row .qty { width:60px; text-align:center; }
.cart-item-row .unit-select { width:80px; }
.cart-item-row .remove-btn { background:#e74c3c; color:#fff; border:none; border-radius:4px; padding:4px 8px; cursor:pointer; font-size:11px; }
.cart-item-row .remove-btn:hover { background:#c0392b; }
.cart-item-row .remove-btn { background:var(--err); color:#fff; border:none; border-radius:4px; padding:4px 8px; cursor:pointer; font-size:11px; }
.cart-item-row .remove-btn:hover { background:var(--err); }
.add-cart-row { display:flex; gap:8px; align-items:flex-end; }
.add-cart-row .product-selector { flex:1; }
.add-cart-row .qty-input { width:70px; }
.add-cart-row .unit-select { width:80px; }
.add-cart-row button { white-space:nowrap; }
.no-pending { text-align:center; padding:60px 20px; color:#2ecc71; }
.no-pending svg { width:48px; height:48px; fill:#2ecc71; margin-bottom:16px; }
.no-pending { text-align:center; padding:60px 20px; color:var(--ok); }
.no-pending svg { width:48px; height:48px; fill:var(--ok); margin-bottom:16px; }
</style>
<div class="container">
@@ -349,7 +349,7 @@ class TakeoversCrud extends HTMLElement {
if (!container) return;
if (this.cartItemsToAdd.length === 0) {
container.innerHTML = `<div style="font-size:12px;color:#8aa0b5;padding:8px;">Sin items agregados</div>`;
container.innerHTML = `<div style="font-size:12px;color:var(--text-muted);padding:8px;">Sin items agregados</div>`;
return;
}

View File

@@ -1,534 +0,0 @@
import { api } from "../lib/api.js";
import { modal } from "../lib/modal.js";
// Datos aleatorios para generar usuarios de prueba
const NOMBRES = ["Juan", "María", "Carlos", "Ana", "Pedro", "Laura", "Diego", "Sofía"];
const APELLIDOS = ["García", "Rodríguez", "Martínez", "López", "González", "Fernández", "Pérez"];
const CALLES = ["Av. Corrientes", "Av. Santa Fe", "Calle Florida", "Av. Rivadavia", "Av. Cabildo", "Av. Libertador"];
function randomItem(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function generateTestUser() {
const randomPhone = `549${randomInt(1000000000, 9999999999)}`;
const wa_chat_id = `${randomPhone}@s.whatsapp.net`;
const nombre = randomItem(NOMBRES);
const apellido = randomItem(APELLIDOS);
return {
wa_chat_id,
phone: randomPhone,
name: `${nombre} ${apellido}`,
address: {
first_name: nombre,
last_name: apellido,
address_1: `${randomItem(CALLES)} ${randomInt(100, 9000)}`,
city: "CABA",
state: "Buenos Aires",
postcode: `${randomInt(1000, 1999)}`,
country: "AR",
phone: randomPhone,
email: `${randomPhone}@no-email.local`,
},
};
}
class TestPanel extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.products = [];
this.selectedProducts = [];
this.testUser = null;
this.lastOrder = null;
this.lastPaymentLink = null;
this.loading = false;
this.shadowRoot.innerHTML = `
<style>
:host {
--bg: #0b0f14;
--panel: #121823;
--muted: #8aa0b5;
--text: #e7eef7;
--line: #1e2a3a;
--blue: #1f6feb;
--green: #238636;
--red: #da3633;
}
* { box-sizing: border-box; font-family: system-ui, Segoe UI, Roboto, Arial; }
.container {
height: 100%;
background: var(--bg);
color: var(--text);
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
padding: 16px;
overflow: auto;
}
.panel {
background: var(--panel);
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--line);
padding-bottom: 8px;
}
.section {
display: flex;
flex-direction: column;
gap: 8px;
}
.section-title {
font-size: 12px;
font-weight: 600;
color: var(--blue);
}
button {
background: var(--blue);
border: none;
color: white;
padding: 8px 16px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
transition: opacity 0.15s;
}
button:hover { opacity: 0.9; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.secondary {
background: transparent;
border: 1px solid var(--line);
color: var(--muted);
}
button.secondary:hover { border-color: var(--blue); color: var(--text); }
button.success { background: var(--green); }
input, select {
background: var(--bg);
border: 1px solid var(--line);
color: var(--text);
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
}
input:focus, select:focus {
outline: none;
border-color: var(--blue);
}
.product-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--line);
border-radius: 6px;
}
.product-item {
display: grid;
grid-template-columns: 1fr 80px 60px 30px;
gap: 8px;
padding: 8px;
border-bottom: 1px solid var(--line);
align-items: center;
font-size: 12px;
}
.product-item:last-child { border-bottom: none; }
.product-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.product-qty { text-align: right; }
.product-unit { color: var(--muted); }
.remove-btn {
background: var(--red);
padding: 4px 8px;
font-size: 10px;
}
.user-info {
background: var(--bg);
border: 1px solid var(--line);
border-radius: 6px;
padding: 12px;
font-size: 12px;
}
.user-info div { margin-bottom: 4px; }
.user-info span { color: var(--muted); }
.result {
background: var(--bg);
border: 1px solid var(--line);
border-radius: 6px;
padding: 12px;
font-size: 12px;
}
.result.success { border-color: var(--green); }
.result.error { border-color: var(--red); }
.result-label { color: var(--muted); font-size: 10px; text-transform: uppercase; }
.result-value { font-weight: 600; margin-top: 4px; }
.result-value.big { font-size: 18px; }
.result-link {
color: var(--blue);
text-decoration: underline;
cursor: pointer;
word-break: break-all;
}
.row { display: flex; gap: 8px; align-items: center; }
.flex-1 { flex: 1; }
.loading { opacity: 0.5; pointer-events: none; }
.empty { color: var(--muted); font-size: 12px; text-align: center; padding: 20px; }
</style>
<div class="container">
<div class="panel">
<div class="panel-title">1. Generar Orden de Prueba</div>
<div class="section">
<div class="row">
<button id="btnGenerate">Generar Orden Aleatoria</button>
<button id="btnClear" class="secondary">Limpiar</button>
</div>
</div>
<div class="section">
<div class="section-title">Productos seleccionados</div>
<div class="product-list" id="productList">
<div class="empty">Click "Generar Orden Aleatoria" para comenzar</div>
</div>
</div>
<div class="section">
<div class="section-title">Datos del usuario</div>
<div class="user-info" id="userInfo">
<div class="empty">Se generarán automáticamente</div>
</div>
</div>
<div class="section">
<button id="btnCreateOrder" class="success" disabled>Crear Orden en WooCommerce</button>
</div>
<div class="section" id="orderResult" style="display:none;">
<div class="result success">
<div class="result-label">Orden creada</div>
<div class="result-value big" id="orderIdValue">—</div>
<div style="margin-top:8px;">
<span class="result-label">Total:</span>
<span id="orderTotalValue">—</span>
</div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-title">2. Link de Pago (MercadoPago)</div>
<div class="section">
<div class="section-title">Monto</div>
<div class="row">
<input type="number" id="inputAmount" placeholder="Monto en ARS" class="flex-1" />
<button id="btnPaymentLink" disabled>Generar Link de Pago</button>
</div>
</div>
<div class="section" id="paymentResult" style="display:none;">
<div class="result success">
<div class="result-label">Link de pago</div>
<a class="result-link" id="paymentLinkValue" href="#" target="_blank">—</a>
<div style="margin-top:8px;">
<span class="result-label">Preference ID:</span>
<span id="preferenceIdValue">—</span>
</div>
</div>
</div>
<div class="panel-title" style="margin-top:24px;">3. Simular Pago Exitoso</div>
<div class="section">
<p style="font-size:12px;color:var(--muted);margin:0;">
Simula el webhook de MercadoPago con status "approved".
Esto actualiza la orden en WooCommerce a "processing".
</p>
<button id="btnSimulateWebhook" disabled>Simular Pago Exitoso</button>
</div>
<div class="section" id="webhookResult" style="display:none;">
<div class="result success">
<div class="result-label">Pago simulado</div>
<div class="result-value" id="webhookStatusValue">—</div>
<div style="margin-top:8px;">
<span class="result-label">Orden status:</span>
<span id="webhookOrderStatusValue">—</span>
</div>
</div>
</div>
</div>
</div>
`;
}
connectedCallback() {
this.shadowRoot.getElementById("btnGenerate").onclick = () => this.generateRandomOrder();
this.shadowRoot.getElementById("btnClear").onclick = () => this.clearAll();
this.shadowRoot.getElementById("btnCreateOrder").onclick = () => this.createOrder();
this.shadowRoot.getElementById("btnPaymentLink").onclick = () => this.createPaymentLink();
this.shadowRoot.getElementById("btnSimulateWebhook").onclick = () => this.simulateWebhook();
this.loadProducts();
}
async loadProducts() {
try {
const result = await api.getProductsWithStock();
this.products = result.items || [];
console.log(`[test-panel] Loaded ${this.products.length} products with stock`);
} catch (e) {
console.error("[test-panel] Error loading products:", e);
}
}
async generateRandomOrder() {
if (this.products.length === 0) {
await this.loadProducts();
}
if (this.products.length === 0) {
modal.warn("No hay productos con stock disponible");
return;
}
// Generar usuario aleatorio
this.testUser = generateTestUser();
// Seleccionar 1-3 productos aleatorios
const numProducts = randomInt(1, Math.min(3, this.products.length));
const shuffled = [...this.products].sort(() => Math.random() - 0.5);
this.selectedProducts = [];
for (let i = 0; i < numProducts; i++) {
const product = shuffled[i];
const isKg = product.sell_unit === "kg";
const quantity = isKg ? randomInt(200, 2000) : randomInt(1, 5);
this.selectedProducts.push({
product_id: product.woo_product_id,
name: product.name,
quantity,
unit: isKg ? "kg" : "unit",
price: product.price,
});
}
this.renderProductList();
this.renderUserInfo();
this.updateButtonStates();
}
renderProductList() {
const container = this.shadowRoot.getElementById("productList");
if (this.selectedProducts.length === 0) {
container.innerHTML = `<div class="empty">Click "Generar Orden Aleatoria" para comenzar</div>`;
return;
}
container.innerHTML = this.selectedProducts.map((p, i) => `
<div class="product-item">
<div class="product-name" title="${p.name}">${p.name}</div>
<div class="product-qty">${p.unit === "kg" ? `${p.quantity}g` : `${p.quantity}u`}</div>
<div class="product-unit">$${Number(p.price || 0).toFixed(0)}</div>
<button class="remove-btn" data-index="${i}">X</button>
</div>
`).join("");
container.querySelectorAll(".remove-btn").forEach(btn => {
btn.onclick = (e) => {
const idx = parseInt(e.target.dataset.index);
this.selectedProducts.splice(idx, 1);
this.renderProductList();
this.updateButtonStates();
};
});
}
renderUserInfo() {
const container = this.shadowRoot.getElementById("userInfo");
if (!this.testUser) {
container.innerHTML = `<div class="empty">Se generarán automáticamente</div>`;
return;
}
const addr = this.testUser.address;
container.innerHTML = `
<div><span>Nombre:</span> ${addr.first_name} ${addr.last_name}</div>
<div><span>Dirección:</span> ${addr.address_1}</div>
<div><span>Ciudad:</span> ${addr.city}, ${addr.state}</div>
<div><span>Teléfono:</span> ${addr.phone}</div>
<div><span>Email:</span> ${addr.email}</div>
`;
}
updateButtonStates() {
const hasProducts = this.selectedProducts.length > 0;
const hasOrder = this.lastOrder?.woo_order_id;
const hasPaymentLink = this.lastPaymentLink?.init_point;
this.shadowRoot.getElementById("btnCreateOrder").disabled = !hasProducts;
this.shadowRoot.getElementById("btnPaymentLink").disabled = !hasOrder;
this.shadowRoot.getElementById("btnSimulateWebhook").disabled = !hasOrder;
if (hasOrder) {
this.shadowRoot.getElementById("inputAmount").value = this.lastOrder.total || "";
}
}
async createOrder() {
if (this.loading) return;
this.loading = true;
const btn = this.shadowRoot.getElementById("btnCreateOrder");
btn.disabled = true;
btn.textContent = "Creando...";
try {
const basket = {
items: this.selectedProducts.map(p => ({
product_id: p.product_id,
quantity: p.quantity,
unit: p.unit,
})),
};
const result = await api.createTestOrder({
basket,
address: this.testUser?.address || null,
wa_chat_id: this.testUser?.wa_chat_id || null,
});
if (result.ok) {
this.lastOrder = result;
this.shadowRoot.getElementById("orderIdValue").textContent = `#${result.woo_order_id}`;
this.shadowRoot.getElementById("orderTotalValue").textContent = `$${Number(result.total || 0).toFixed(2)}`;
this.shadowRoot.getElementById("orderResult").style.display = "block";
this.shadowRoot.getElementById("inputAmount").value = result.total || "";
} else {
modal.error("Error: " + (result.error || "Error desconocido"));
}
} catch (e) {
console.error("[test-panel] createOrder error:", e);
modal.error("Error creando orden: " + e.message);
} finally {
this.loading = false;
btn.textContent = "Crear Orden en WooCommerce";
this.updateButtonStates();
}
}
async createPaymentLink() {
if (this.loading) return;
if (!this.lastOrder?.woo_order_id) {
modal.warn("Primero creá una orden");
return;
}
const amount = parseFloat(this.shadowRoot.getElementById("inputAmount").value);
if (!amount || amount <= 0) {
modal.warn("Ingresá un monto válido");
return;
}
this.loading = true;
const btn = this.shadowRoot.getElementById("btnPaymentLink");
btn.disabled = true;
btn.textContent = "Generando...";
try {
const result = await api.createPaymentLink({
woo_order_id: this.lastOrder.woo_order_id,
amount,
});
if (result.ok) {
this.lastPaymentLink = result;
const linkEl = this.shadowRoot.getElementById("paymentLinkValue");
linkEl.href = result.init_point || result.sandbox_init_point || "#";
linkEl.textContent = result.init_point || result.sandbox_init_point || "—";
this.shadowRoot.getElementById("preferenceIdValue").textContent = result.preference_id || "—";
this.shadowRoot.getElementById("paymentResult").style.display = "block";
} else {
modal.error("Error: " + (result.error || "Error desconocido"));
}
} catch (e) {
console.error("[test-panel] createPaymentLink error:", e);
modal.error("Error generando link: " + e.message);
} finally {
this.loading = false;
btn.textContent = "Generar Link de Pago";
this.updateButtonStates();
}
}
async simulateWebhook() {
if (this.loading) return;
if (!this.lastOrder?.woo_order_id) {
modal.warn("Primero creá una orden");
return;
}
this.loading = true;
const btn = this.shadowRoot.getElementById("btnSimulateWebhook");
btn.disabled = true;
btn.textContent = "Simulando...";
try {
const amount = parseFloat(this.shadowRoot.getElementById("inputAmount").value) || this.lastOrder.total || 0;
const result = await api.simulateMpWebhook({
woo_order_id: this.lastOrder.woo_order_id,
amount,
});
if (result.ok) {
this.shadowRoot.getElementById("webhookStatusValue").textContent = `Payment ${result.payment_id} - ${result.status}`;
this.shadowRoot.getElementById("webhookOrderStatusValue").textContent = result.order_status || "processing";
this.shadowRoot.getElementById("webhookResult").style.display = "block";
} else {
modal.error("Error: " + (result.error || "Error desconocido"));
}
} catch (e) {
console.error("[test-panel] simulateWebhook error:", e);
modal.error("Error simulando webhook: " + e.message);
} finally {
this.loading = false;
btn.textContent = "Simular Pago Exitoso";
this.updateButtonStates();
}
}
clearAll() {
this.selectedProducts = [];
this.testUser = null;
this.lastOrder = null;
this.lastPaymentLink = null;
this.renderProductList();
this.renderUserInfo();
this.updateButtonStates();
this.shadowRoot.getElementById("orderResult").style.display = "none";
this.shadowRoot.getElementById("paymentResult").style.display = "none";
this.shadowRoot.getElementById("webhookResult").style.display = "none";
this.shadowRoot.getElementById("inputAmount").value = "";
}
}
customElements.define("test-panel", TestPanel);

View File

@@ -18,45 +18,45 @@ class UsersCrud extends HTMLElement {
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
input, select { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; }
input:focus, select:focus { outline:none; border-color:#1f6feb; }
input, select { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; }
input:focus, select:focus { outline:none; border-color:var(--accent); }
input { flex:1; }
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:#1a5fd0; }
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:var(--accent-hover); }
button:disabled { opacity:.5; cursor:not-allowed; }
button.secondary { background:#253245; }
button.secondary:hover { background:#2d3e52; }
button.danger { background:#e74c3c; }
button.danger:hover { background:#c0392b; }
button.secondary { background:var(--border-hi); }
button.secondary:hover { background:var(--border-hi); }
button.danger { background:var(--err); }
button.danger:hover { background:var(--err); }
.list { flex:1; overflow-y:auto; }
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
.item:hover { border-color:#1f6feb; }
.item.active { border-color:#1f6feb; background:#111b2a; }
.item-name { font-weight:600; color:#e7eef7; margin-bottom:4px; }
.item-meta { font-size:12px; color:#8aa0b5; }
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:#253245; color:#8aa0b5; margin-left:8px; }
.badge.woo { background:#0f2a1a; color:#2ecc71; }
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
.item:hover { border-color:var(--accent); }
.item.active { border-color:var(--accent); background:var(--accent-soft); }
.item-name { font-weight:600; color:var(--text); margin-bottom:4px; }
.item-meta { font-size:12px; color:var(--text-muted); }
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:var(--border-hi); color:var(--text-muted); margin-left:8px; }
.badge.woo { background:var(--ok-soft); color:var(--ok); }
.detail { flex:1; overflow-y:auto; }
.detail-empty { color:#8aa0b5; text-align:center; padding:40px; }
.detail-empty { color:var(--text-muted); text-align:center; padding:40px; }
.field { margin-bottom:16px; }
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.field-value { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:10px 12px; color:#e7eef7; font-size:13px; }
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.field-value { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:10px 12px; color:var(--text); font-size:13px; }
.actions { display:flex; gap:8px; margin-top:16px; flex-wrap:wrap; }
.loading { text-align:center; padding:40px; color:#8aa0b5; }
.loading { text-align:center; padding:40px; color:var(--text-muted); }
.stats { display:flex; gap:16px; margin-bottom:16px; }
.stat { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; flex:1; text-align:center; cursor:pointer; transition:all .15s; }
.stat:hover { border-color:#1f6feb; }
.stat.active { border-color:#1f6feb; background:#111b2a; }
.stat-value { font-size:24px; font-weight:700; color:#1f6feb; }
.stat-label { font-size:11px; color:#8aa0b5; text-transform:uppercase; margin-top:4px; }
.stat { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; flex:1; text-align:center; cursor:pointer; transition:all .15s; }
.stat:hover { border-color:var(--accent); }
.stat.active { border-color:var(--accent); background:var(--accent-soft); }
.stat-value { font-size:24px; font-weight:700; color:var(--accent); }
.stat-label { font-size:11px; color:var(--text-muted); text-transform:uppercase; margin-top:4px; }
</style>
<div class="container">

View File

@@ -0,0 +1,393 @@
/**
* <zone-map-editor> — editor de zonas de delivery sobre un mapa.
*
* Light DOM (sin shadow) para que Leaflet (que asume un DOM normal con CSS
* global) funcione bien dentro de paneles que sí usan shadow DOM (settings-crud).
*
* Carga Leaflet 1.9 + leaflet-geoman desde CDN al montar. Las primeras
* instancias inyectan los <script>/<link>; las siguientes esperan a que
* window.L y window.L.PM estén disponibles.
*
* API pública:
* set zones(arr) // [{id, name, polygon, delivery_cost, delivery_days, ...}]
* get zones() // serializado actual con coordenadas GeoJSON
* set selectedId(id)
* on("change", e => ...) // dispara cuando se crea/edita/borra/renombra
* on("select", e => ...) // dispara cuando se hace click en un polígono
*
* Uso:
* const ed = document.createElement("zone-map-editor");
* ed.zones = [...];
* ed.addEventListener("change", e => console.log(e.detail.zones));
*/
const LEAFLET_VERSION = "1.9.4";
const GEOMAN_VERSION = "2.18.3";
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VERSION}/dist/leaflet.css`;
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VERSION}/dist/leaflet.js`;
const GEOMAN_CSS = `https://unpkg.com/@geoman-io/leaflet-geoman-free@${GEOMAN_VERSION}/dist/leaflet-geoman.css`;
const GEOMAN_JS = `https://unpkg.com/@geoman-io/leaflet-geoman-free@${GEOMAN_VERSION}/dist/leaflet-geoman.min.js`;
const DEFAULT_CENTER = [-34.6037, -58.3816]; // Obelisco (lat, lng — Leaflet usa [lat,lng])
const DEFAULT_ZOOM = 12;
const ZONE_PALETTE = [
"--chart-blue", "--chart-green", "--chart-purple",
"--chart-orange", "--chart-pink", "--chart-gray",
];
let _scriptsPromise = null;
function ensureLeafletScripts() {
if (window.L && window.L.PM) return Promise.resolve();
if (_scriptsPromise) return _scriptsPromise;
_scriptsPromise = (async () => {
if (!window.L) await loadScript(LEAFLET_JS);
if (!window.L.PM) await loadScript(GEOMAN_JS);
})();
return _scriptsPromise;
}
function loadScript(src) {
return new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`);
if (existing) {
if (existing.dataset.loaded === "1") { resolve(); return; }
existing.addEventListener("load", () => resolve());
existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)));
return;
}
const s = document.createElement("script");
s.src = src;
s.async = true;
s.onload = () => { s.dataset.loaded = "1"; resolve(); };
s.onerror = () => reject(new Error(`Failed to load ${src}`));
document.head.appendChild(s);
});
}
function waitForCSS(linkEl, timeoutMs = 4000) {
if (linkEl.sheet) return Promise.resolve();
return new Promise((resolve) => {
const timer = setTimeout(resolve, timeoutMs);
linkEl.addEventListener("load", () => { clearTimeout(timer); resolve(); }, { once: true });
linkEl.addEventListener("error", () => { clearTimeout(timer); resolve(); }, { once: true });
});
}
function cssVar(name, fallback = "#0ea5e9") {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
function slugify(name) {
return String(name || "")
.toLowerCase()
.normalize("NFD").replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40) || `zona-${Date.now().toString(36)}`;
}
class ZoneMapEditor extends HTMLElement {
constructor() {
super();
this._zones = []; // estado interno (con polygon GeoJSON)
this._layersById = new Map(); // id -> leaflet layer
this._selectedId = null;
this._map = null;
this._mapDiv = null;
this._ready = false;
}
connectedCallback() {
this.style.display = "block";
this.style.position = "relative";
this.style.width = "100%";
this.style.height = this.getAttribute("height") || "480px";
// El web component vive en light DOM dentro del shadow root del padre.
// Los <link> en document.head NO cruzan shadow boundaries, así que los
// anclamos como hijos del propio elemento (sí cruzan, porque slot
// composition trae nuestros children al árbol del padre con sus assets).
const linkLeaflet = document.createElement("link");
linkLeaflet.rel = "stylesheet";
linkLeaflet.href = LEAFLET_CSS;
this.appendChild(linkLeaflet);
const linkGeoman = document.createElement("link");
linkGeoman.rel = "stylesheet";
linkGeoman.href = GEOMAN_CSS;
this.appendChild(linkGeoman);
this._mapDiv = document.createElement("div");
this._mapDiv.style.width = "100%";
this._mapDiv.style.height = "100%";
this._mapDiv.style.borderRadius = "var(--r-md, 10px)";
this._mapDiv.style.overflow = "hidden";
this._mapDiv.style.border = "1px solid var(--border, #e2e8f0)";
this._mapDiv.style.background = "#e2e8f0";
this.appendChild(this._mapDiv);
ensureLeafletScripts()
.then(() => waitForCSS(linkLeaflet))
.then(() => this._initMap())
.catch((err) => {
this._mapDiv.innerHTML = `<div style="padding:16px;color:var(--err);font-family:var(--font-sans);">No se pudo cargar el mapa: ${err.message}</div>`;
});
// Si el host estaba oculto al montar (router/visibility), Leaflet calcula
// 0×0 y los tiles no se piden. Observamos el resize para recalcular.
if (typeof ResizeObserver !== "undefined") {
this._ro = new ResizeObserver(() => {
if (this._map) this._map.invalidateSize();
});
this._ro.observe(this);
}
}
disconnectedCallback() {
if (this._ro) { this._ro.disconnect(); this._ro = null; }
if (this._map) {
this._map.remove();
this._map = null;
}
this._layersById.clear();
this._ready = false;
}
// ───────────── API pública ─────────────
get zones() {
// Devolver el estado interno con polígonos sincronizados desde los layers.
return this._zones.map((z) => ({ ...z, polygon: this._serializePolygon(z.id) || z.polygon || null }));
}
set zones(arr) {
const list = Array.isArray(arr) ? arr : [];
this._zones = list.map((z) => ({
id: z.id || slugify(z.name || ""),
name: z.name || "",
polygon: z.polygon || null,
delivery_cost: z.delivery_cost ?? 0,
delivery_days: Array.isArray(z.delivery_days) ? z.delivery_days : ["lun","mar","mie","jue","vie","sab"],
delivery_hours: z.delivery_hours || { start: "10:00", end: "20:00" },
min_order_amount: z.min_order_amount ?? 0,
enabled: z.enabled !== false,
}));
if (this._ready) this._renderLayers();
}
get selectedId() { return this._selectedId; }
set selectedId(id) {
this._selectedId = id || null;
if (!this._ready) return;
for (const [zid, layer] of this._layersById) {
this._applyLayerStyle(zid, layer, zid === this._selectedId);
}
if (id && this._layersById.has(id)) {
const layer = this._layersById.get(id);
try { this._map.fitBounds(layer.getBounds().pad(0.25)); } catch {}
}
}
upsertZone(zone) {
const idx = this._zones.findIndex((z) => z.id === zone.id);
if (idx >= 0) this._zones[idx] = { ...this._zones[idx], ...zone };
else this._zones.push(zone);
this._renderLayers();
this._emit("change");
}
removeZone(id) {
this._zones = this._zones.filter((z) => z.id !== id);
if (this._layersById.has(id)) {
this._map.removeLayer(this._layersById.get(id));
this._layersById.delete(id);
}
if (this._selectedId === id) this._selectedId = null;
this._emit("change");
}
startDrawing() {
if (!this._ready) return;
this._map.pm.enableDraw("Polygon", {
snappable: true,
templineStyle: { color: cssVar("--chart-blue") },
hintlineStyle: { color: cssVar("--chart-blue"), dashArray: [5, 5] },
pathOptions: this._defaultPathOptions(),
});
}
// ───────────── Internals ─────────────
_initMap() {
const L = window.L;
const center = DEFAULT_CENTER;
this._map = L.map(this._mapDiv, {
center, zoom: DEFAULT_ZOOM, zoomControl: true, attributionControl: true,
});
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(this._map);
// Geoman: solo botón "draw polygon" + edit/delete contextual desde la UI nuestra.
this._map.pm.addControls({
position: "topright",
drawCircle: false, drawCircleMarker: false, drawMarker: false,
drawPolyline: false, drawRectangle: false, drawText: false,
cutPolygon: false, rotateMode: false,
drawPolygon: true, editMode: true, dragMode: false, removalMode: true,
});
this._map.pm.setLang("es");
this._map.pm.setGlobalOptions({
pathOptions: this._defaultPathOptions(),
snappable: true,
});
// Listeners globales de geoman.
this._map.on("pm:create", (e) => this._handleCreate(e));
this._map.on("pm:remove", (e) => this._handleRemove(e));
this._ready = true;
this._renderLayers();
// Forzar un invalidateSize después del primer paint por si el contenedor
// recién obtuvo tamaño (panel oculto inicial / tabs / etc).
requestAnimationFrame(() => this._map && this._map.invalidateSize());
setTimeout(() => this._map && this._map.invalidateSize(), 250);
}
_renderLayers() {
if (!this._ready) return;
const L = window.L;
// Borrar layers que ya no existen.
for (const [id, layer] of this._layersById) {
if (!this._zones.some((z) => z.id === id)) {
this._map.removeLayer(layer);
this._layersById.delete(id);
}
}
// Crear/actualizar.
this._zones.forEach((z, i) => {
if (!z.polygon || !Array.isArray(z.polygon.coordinates)) return;
const latlngs = z.polygon.coordinates[0].map(([lng, lat]) => [lat, lng]);
const existing = this._layersById.get(z.id);
if (existing) {
existing.setLatLngs(latlngs);
this._applyLayerStyle(z.id, existing, z.id === this._selectedId);
return;
}
const color = cssVar(ZONE_PALETTE[i % ZONE_PALETTE.length]);
const layer = L.polygon(latlngs, this._pathOptions(color, z.id === this._selectedId)).addTo(this._map);
layer.bindTooltip(z.name || z.id, { sticky: true });
layer.on("click", () => this._select(z.id));
layer.on("pm:edit", () => this._handleEdit(z.id, layer));
this._layersById.set(z.id, layer);
});
}
_select(id) {
this._selectedId = id;
for (const [zid, layer] of this._layersById) {
this._applyLayerStyle(zid, layer, zid === id);
}
this._emit("select", { id });
}
_handleCreate(e) {
const layer = e.layer;
const id = `zona-${Date.now().toString(36)}`;
const i = this._zones.length;
const color = cssVar(ZONE_PALETTE[i % ZONE_PALETTE.length]);
layer.setStyle(this._pathOptions(color, true));
const polygon = this._toGeoJSONPolygon(layer.getLatLngs());
const zone = {
id,
name: `Zona ${i + 1}`,
polygon,
delivery_cost: 0,
delivery_days: ["lun","mar","mie","jue","vie","sab"],
delivery_hours: { start: "10:00", end: "20:00" },
min_order_amount: 0,
enabled: true,
};
this._zones.push(zone);
this._layersById.set(id, layer);
layer.bindTooltip(zone.name, { sticky: true });
layer.on("click", () => this._select(id));
layer.on("pm:edit", () => this._handleEdit(id, layer));
this._select(id);
this._emit("change");
this._emit("create", { id, zone });
}
_handleEdit(id, layer) {
const z = this._zones.find((x) => x.id === id);
if (!z) return;
z.polygon = this._toGeoJSONPolygon(layer.getLatLngs());
this._emit("change");
}
_handleRemove(e) {
const layer = e.layer;
let removedId = null;
for (const [id, l] of this._layersById) {
if (l === layer) { removedId = id; break; }
}
if (!removedId) return;
this._zones = this._zones.filter((z) => z.id !== removedId);
this._layersById.delete(removedId);
if (this._selectedId === removedId) this._selectedId = null;
this._emit("change");
}
_serializePolygon(id) {
const layer = this._layersById.get(id);
if (!layer) return null;
return this._toGeoJSONPolygon(layer.getLatLngs());
}
_toGeoJSONPolygon(latlngs) {
// Leaflet pasa anidado: [[{lat,lng}...]] para polígonos simples.
const ring = Array.isArray(latlngs[0]) ? latlngs[0] : latlngs;
const coords = ring.map((p) => [p.lng, p.lat]);
// Cerrar el anillo si no está cerrado (GeoJSON lo requiere).
const first = coords[0];
const last = coords[coords.length - 1];
if (!first || !last || first[0] !== last[0] || first[1] !== last[1]) {
if (first) coords.push([first[0], first[1]]);
}
return { type: "Polygon", coordinates: [coords] };
}
_defaultPathOptions() {
return this._pathOptions(cssVar("--chart-blue"), false);
}
_pathOptions(color, selected) {
return {
color,
weight: selected ? 3 : 2,
opacity: 0.95,
fillColor: color,
fillOpacity: selected ? 0.28 : 0.18,
};
}
_applyLayerStyle(id, layer, selected) {
const i = this._zones.findIndex((z) => z.id === id);
const color = cssVar(ZONE_PALETTE[(i < 0 ? 0 : i) % ZONE_PALETTE.length]);
layer.setStyle(this._pathOptions(color, selected));
}
_emit(name, detail = {}) {
detail = { zones: this.zones, ...detail };
this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
}
}
if (!customElements.get("zone-map-editor")) {
customElements.define("zone-map-editor", ZoneMapEditor);
}

View File

@@ -3,10 +3,12 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Bot Ops Console</title>
<title>Piaf Console</title>
<link rel="stylesheet" href="/styles/theme.css" />
</head>
<body>
<ops-shell></ops-shell>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script type="module" src="/app.js"></script>
</body>
</html>

View File

@@ -1,3 +1,35 @@
import { toast } from "./toast.js";
/**
* fetch wrapper que dispara toast en error de red o respuesta no-OK.
* Devuelve la respuesta parseada como JSON. Si la respuesta tiene
* `{ ok: false, error }`, también dispara toast.
*/
export async function safeFetch(url, opts = {}, { silent = false, label = null } = {}) {
let res;
try {
res = await fetch(url, opts);
} catch (err) {
if (!silent) toast({ kind: "error", text: `${label || "Red"}: ${err?.message || "sin conexión"}` });
throw err;
}
if (!res.ok) {
let body = null;
try { body = await res.json(); } catch (_) {}
const msg = body?.error || body?.message || `${res.status} ${res.statusText}`;
if (!silent) toast({ kind: "error", text: `${label || "Error"}: ${msg}` });
const err = new Error(msg);
err.status = res.status;
err.body = body;
throw err;
}
try {
return await res.json();
} catch (_) {
return {};
}
}
export const api = {
async conversations({ q = "", status = "", state = "" } = {}) {
const u = new URL("/conversations", location.origin);
@@ -46,16 +78,16 @@ export const api = {
},
async simEvolution(payload) {
return fetch("/webhook/evolution", {
return safeFetch("/webhook/evolution", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).then(r => r.json());
}, { label: "Sim Evolution" });
},
async retryLast(chat_id) {
if (!chat_id) throw new Error("chat_id_required");
return fetch(`/conversations/${encodeURIComponent(chat_id)}/retry-last`, { method: "POST" }).then(r => r.json());
return safeFetch(`/conversations/${encodeURIComponent(chat_id)}/retry-last`, { method: "POST" }, { label: "Retry" });
},
// Products CRUD
@@ -185,39 +217,21 @@ export const api = {
return fetch("/products/sync-from-woo", { method: "POST" }).then(r => r.json());
},
// --- Testing ---
async listRecentOrders({ limit = 20 } = {}) {
const u = new URL("/test/orders", location.origin);
// --- Orders & Stats ---
async listOrders({ page = 1, limit = 50 } = {}) {
const u = new URL("/api/orders", location.origin);
u.searchParams.set("page", String(page));
u.searchParams.set("limit", String(limit));
return fetch(u).then(r => r.json());
},
async getProductsWithStock() {
return fetch("/test/products-with-stock").then(r => r.json());
async getOrderStats() {
return fetch("/api/stats/orders").then(r => r.json());
},
async createTestOrder({ basket, address, wa_chat_id }) {
return fetch("/test/order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ basket, address, wa_chat_id }),
}).then(r => r.json());
},
async createPaymentLink({ woo_order_id, amount }) {
return fetch("/test/payment-link", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ woo_order_id, amount }),
}).then(r => r.json());
},
async simulateMpWebhook({ woo_order_id, amount }) {
return fetch("/test/simulate-webhook", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ woo_order_id, amount }),
}).then(r => r.json());
// Alias para compatibilidad
async listRecentOrders({ limit = 20 } = {}) {
return this.listOrders({ page: 1, limit });
},
// --- Prompts CRUD ---

View File

@@ -11,114 +11,81 @@
const STYLES = `
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(2px);
display: flex; align-items: center; justify-content: center;
z-index: 10000;
animation: fadeIn 0.15s ease-out;
font-family: var(--font-sans, system-ui);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideIn {
from { transform: translateY(-20px); opacity: 0; }
from { transform: translateY(-12px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal-box {
background: #1e1e1e;
border-radius: 8px;
background: var(--panel, #ffffff);
border-radius: var(--r-lg, 12px);
padding: 24px;
min-width: 320px;
max-width: 480px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
box-shadow: var(--shadow-lg, 0 12px 28px rgba(15,23,42,.10));
border: 1px solid var(--border, #e2e8f0);
animation: slideIn 0.2s ease-out;
border: 1px solid #333;
}
.modal-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
display: flex; align-items: center; gap: 12px;
margin-bottom: 14px;
}
.modal-icon {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
width: 32px; height: 32px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 16px; font-weight: 700;
flex-shrink: 0;
}
.modal-icon.success { background: #22c55e20; color: #22c55e; }
.modal-icon.error { background: #ef444420; color: #ef4444; }
.modal-icon.warn { background: #f59e0b20; color: #f59e0b; }
.modal-icon.info { background: #3b82f620; color: #3b82f6; }
.modal-icon.confirm { background: #8b5cf620; color: #8b5cf6; }
.modal-icon.success { background: var(--ok-soft, #d1fae5); color: var(--ok, #10b981); }
.modal-icon.error { background: var(--err-soft, #fee2e2); color: var(--err, #ef4444); }
.modal-icon.warn { background: var(--warn-soft, #fef3c7); color: var(--warn, #f59e0b); }
.modal-icon.info { background: var(--accent-soft, #e0f2fe); color: var(--accent, #0ea5e9); }
.modal-icon.confirm { background: var(--accent-soft, #e0f2fe); color: var(--accent-hover, #0284c7); }
.modal-title {
font-size: 16px;
font-weight: 600;
color: #fff;
font-size: 16px; font-weight: 600;
color: var(--text, #0f172a);
margin: 0;
letter-spacing: -0.01em;
}
.modal-message {
color: #ccc;
font-size: 14px;
line-height: 1.5;
color: var(--text-dim, #475569);
font-size: 14px; line-height: 1.5;
margin-bottom: 20px;
word-break: break-word;
}
.modal-buttons {
display: flex;
gap: 12px;
justify-content: flex-end;
display: flex; gap: 8px; justify-content: flex-end;
}
.modal-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: none;
border-radius: var(--r-md, 10px);
font-size: 13px; font-weight: 500;
cursor: pointer; border: 1px solid transparent;
transition: all 0.15s;
font-family: inherit;
}
.modal-btn:hover {
filter: brightness(1.1);
}
.modal-btn:focus-visible { outline: none; box-shadow: var(--focus-ring, 0 0 0 3px rgba(14,165,233,.3)); }
.modal-btn.primary {
background: #3b82f6;
color: white;
background: var(--accent, #0ea5e9); color: var(--text-on-acc, #fff);
}
.modal-btn.primary:hover { background: var(--accent-hover, #0284c7); }
.modal-btn.secondary {
background: #333;
color: #ccc;
border: 1px solid #444;
background: var(--panel, #fff); color: var(--text, #0f172a);
border-color: var(--border-hi, #cbd5e1);
}
.modal-btn.secondary:hover { border-color: var(--accent, #0ea5e9); color: var(--accent-hover, #0284c7); }
.modal-btn.danger {
background: #ef4444;
color: white;
background: var(--err, #ef4444); color: #fff;
}
.modal-btn.danger:hover { filter: brightness(0.95); }
`;
// Inyectar estilos una sola vez

View File

@@ -2,7 +2,8 @@ import { emit } from "./bus.js";
// Mapeo de rutas a vistas
const ROUTES = [
{ pattern: /^\/$/, view: "chat", params: [] },
{ pattern: /^\/$/, view: "home", params: [] },
{ pattern: /^\/home$/, view: "home", params: [] },
{ pattern: /^\/chat$/, view: "chat", params: [] },
{ pattern: /^\/conversaciones$/, view: "conversations", params: [] },
{ pattern: /^\/usuarios$/, view: "users", params: [] },
@@ -15,7 +16,6 @@ const ROUTES = [
{ pattern: /^\/cantidades$/, view: "quantities", params: [] },
{ pattern: /^\/pedidos$/, view: "orders", params: [] },
{ pattern: /^\/pedidos\/([^/]+)$/, view: "orders", params: ["id"] },
{ pattern: /^\/test$/, view: "test", params: [] },
{ pattern: /^\/config-prompts$/, view: "prompts", params: [] },
{ pattern: /^\/atencion-humana$/, view: "takeovers", params: [] },
{ pattern: /^\/configuracion$/, view: "settings", params: [] },
@@ -23,6 +23,7 @@ const ROUTES = [
// Mapeo de vistas a rutas base (para navegación sin parámetros)
const VIEW_TO_PATH = {
home: "/home",
chat: "/chat",
conversations: "/conversaciones",
users: "/usuarios",
@@ -31,7 +32,6 @@ const VIEW_TO_PATH = {
crosssell: "/crosssell",
quantities: "/cantidades",
orders: "/pedidos",
test: "/test",
prompts: "/config-prompts",
takeovers: "/atencion-humana",
settings: "/configuracion",
@@ -54,8 +54,8 @@ export function parseRoute(pathname) {
}
}
// Fallback a chat si no matchea ninguna ruta
return { view: "chat", params: {} };
// Fallback a home si no matchea ninguna ruta
return { view: "home", params: {} };
}
/**

View File

@@ -1,15 +1,71 @@
import { emit } from "./bus.js";
export function connectSSE() {
const es = new EventSource("/stream");
/**
* SSE client con reconnect exponencial y try-catch en parseo.
* Si el server reinicia o un evento viene malformado, no rompe la app.
*/
es.addEventListener("hello", () => emit("sse:status", { ok: true }));
es.addEventListener("conversation.upsert", (e) => emit("conversation:upsert", JSON.parse(e.data)));
es.addEventListener("run.created", (e) => emit("run:created", JSON.parse(e.data)));
es.addEventListener("takeover.created", (e) => emit("takeover:created", JSON.parse(e.data)));
es.addEventListener("order.created", (e) => emit("order:created", JSON.parse(e.data)));
let _es = null;
let _retryDelay = 1000;
let _retryTimer = null;
const MAX_RETRY = 30_000;
es.onerror = () => emit("sse:status", { ok: false });
const EVENTS = [
["conversation.upsert", "conversation:upsert"],
["run.created", "run:created"],
["takeover.created", "takeover:created"],
["order.created", "order:created"],
];
return es;
function safeParse(rawData, evName) {
try {
return JSON.parse(rawData);
} catch (err) {
console.error(`[sse] bad payload for ${evName}:`, err?.message || err);
return null;
}
}
function attach(es) {
es.addEventListener("hello", () => {
_retryDelay = 1000; // reset on success
emit("sse:status", { ok: true });
});
for (const [serverName, busName] of EVENTS) {
es.addEventListener(serverName, (e) => {
const data = safeParse(e.data, serverName);
if (data !== null) emit(busName, data);
});
}
es.onerror = () => {
emit("sse:status", { ok: false });
try { es.close(); } catch (_) {}
if (_es === es) _es = null;
scheduleReconnect();
};
}
function scheduleReconnect() {
if (_retryTimer) return;
const delay = _retryDelay;
_retryTimer = setTimeout(() => {
_retryTimer = null;
connectSSE();
}, delay);
_retryDelay = Math.min(_retryDelay * 2, MAX_RETRY);
}
export function connectSSE() {
if (_retryTimer) {
clearTimeout(_retryTimer);
_retryTimer = null;
}
if (_es) {
try { _es.close(); } catch (_) {}
}
_es = new EventSource("/stream");
attach(_es);
return _es;
}

95
public/lib/toast.js Normal file
View File

@@ -0,0 +1,95 @@
/**
* Toast service global. Sin dependencias.
* Inyecta un container en <body> y empuja toasts apilados.
*
* Uso:
* import { toast } from "./toast.js";
* toast({ kind: "error", text: "No se pudo guardar" });
* toast({ kind: "ok", text: "Listo", ms: 2000 });
*/
// Lee var del :root con fallback. Permite que la paleta de toasts se adapte
// al tema sin que el archivo conozca los hex.
function v(name, fallback) {
try {
const c = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return c || fallback;
} catch { return fallback; }
}
function kindColors() {
return {
error: { bg: v("--err-soft", "#fee2e2"), border: v("--err", "#ef4444"), text: v("--err-text", "#7f1d1d") },
ok: { bg: v("--ok-soft", "#d1fae5"), border: v("--ok", "#10b981"), text: v("--user-text", "#064e3b") },
warn: { bg: v("--warn-soft", "#fef3c7"), border: v("--warn", "#f59e0b"), text: v("--text", "#0f172a") },
info: { bg: v("--accent-soft","#e0f2fe"),border: v("--accent","#0ea5e9"),text: v("--bot-text", "#1e3a8a") },
};
}
let _container = null;
function ensureContainer() {
if (_container) return _container;
_container = document.createElement("div");
_container.id = "toast-stack";
Object.assign(_container.style, {
position: "fixed",
right: "16px",
bottom: "16px",
display: "flex",
flexDirection: "column",
gap: "8px",
zIndex: "9999",
pointerEvents: "none",
maxWidth: "420px",
});
document.body.appendChild(_container);
return _container;
}
export function toast({ kind = "error", text = "", ms = 4000 } = {}) {
if (!text) return;
const COLORS = kindColors();
const colors = COLORS[kind] || COLORS.info;
const el = document.createElement("div");
Object.assign(el.style, {
background: colors.bg,
border: `1px solid ${colors.border}`,
color: colors.text,
padding: "12px 16px",
borderRadius: "12px",
fontSize: "13px",
fontWeight: "500",
fontFamily: "var(--font-sans, system-ui)",
boxShadow: "var(--shadow-md, 0 4px 12px rgba(15,23,42,.06))",
pointerEvents: "auto",
cursor: "pointer",
transform: "translateX(120%)",
transition: "transform .25s ease, opacity .25s ease",
opacity: "0",
wordBreak: "break-word",
overflowWrap: "anywhere",
});
el.textContent = String(text);
const c = ensureContainer();
c.appendChild(el);
// Animar entrada
requestAnimationFrame(() => {
el.style.transform = "translateX(0)";
el.style.opacity = "1";
});
const dismiss = () => {
el.style.transform = "translateX(120%)";
el.style.opacity = "0";
setTimeout(() => el.remove(), 280);
};
el.addEventListener("click", dismiss);
setTimeout(dismiss, Math.max(800, ms));
}
export function toastError(text) { toast({ kind: "error", text }); }
export function toastOk(text) { toast({ kind: "ok", text, ms: 2500 }); }
export function toastWarn(text) { toast({ kind: "warn", text }); }

View File

@@ -70,7 +70,6 @@ function upsertConversation(chat_id, patch) {
* - call LLM (structured output)
* - product search (LIMITED) + resolve ids
* - create/update Woo order
* - create MercadoPago link
* - save state
*/
async function processMessage({ chat_id, from, text }) {
@@ -82,7 +81,7 @@ async function processMessage({ chat_id, from, text }) {
// Minimal simulated LLM output (replace later)
const plan = {
reply: `Recibido: "${text}". ¿Querés retiro o envío?`,
next_state: "BUILDING_ORDER",
next_state: "CART",
intent: "create_order",
missing_fields: ["delivery_or_pickup"],
order_action: "none",
@@ -93,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 },
],
};
@@ -111,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,
};

Binary file not shown.

File diff suppressed because one or more lines are too long

156
public/styles/theme.css Normal file
View File

@@ -0,0 +1,156 @@
/**
* Tema global Botino — light pastel frío (azul/verde).
*
* CSS custom properties heredan a través del shadow DOM, así que cualquier
* componente puede usar `var(--bg)` sin re-declarar nada.
*/
/* ────────────────────────────────────────────────────────────
* Tipografía: Inter + JetBrains Mono (variable, self-hosted)
* ──────────────────────────────────────────────────────────── */
@font-face {
font-family: "Inter";
src: url("/styles/fonts/Inter-Variable.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "JetBrains Mono";
src: url("/styles/fonts/JetBrainsMono-Variable.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
/* ────────────────────────────────────────────────────────────
* Tokens de diseño
* ──────────────────────────────────────────────────────────── */
:root {
/* Surfaces */
--bg: #f7fafc; /* casi blanco con tinte azul muy sutil */
--panel: #ffffff; /* tarjetas / paneles principales */
--panel-2: #f1f5f9; /* slate-100 */
--panel-3: #e2e8f0; /* slate-200 */
/* Borders */
--border: #e2e8f0; /* slate-200 */
--border-hi: #cbd5e1; /* slate-300 */
--border-active:#0ea5e9; /* sky-500 */
/* Text */
--text: #0f172a; /* slate-900 */
--text-dim: #475569; /* slate-600 */
--text-muted: #64748b; /* slate-500 */
--text-on-acc: #ffffff;
--muted: var(--text-muted);
/* Accents — azul/verde fríos como protagonistas */
--accent: #0ea5e9; /* sky-500 */
--accent-hover: #0284c7; /* sky-600 */
--accent-soft: #e0f2fe; /* sky-100 */
--ok: #10b981; /* emerald-500 */
--ok-soft: #d1fae5; /* emerald-100 */
--warn: #f59e0b; /* amber-500 */
--warn-soft: #fef3c7;
--err: #ef4444; /* red-500 */
--err-soft: #fee2e2;
/* Bubbles — light pastel */
--user-bubble: #d1fae5; /* emerald-100 (cliente) */
--user-border: #6ee7b7; /* emerald-300 */
--user-text: #064e3b; /* emerald-900 */
--user-name: #047857; /* emerald-700 */
--user-meta: #059669; /* emerald-600 */
--bot-bubble: #dbeafe; /* blue-100 (bot) */
--bot-border: #93c5fd; /* blue-300 */
--bot-text: #1e3a8a; /* blue-900 */
--bot-name: #1d4ed8; /* blue-700 */
--bot-meta: #2563eb; /* blue-600 */
--err-bubble: #fee2e2;
--err-border: #fca5a5;
--err-text: #7f1d1d;
--err-name: #b91c1c;
--err-meta: #dc2626;
/* Charts — paleta pastel coordinada (~400 saturación) */
--chart-blue: #38bdf8; /* sky-400 */
--chart-green: #34d399; /* emerald-400 */
--chart-purple: #a78bfa; /* violet-400 */
--chart-orange: #fb923c; /* orange-400 */
--chart-pink: #f472b6; /* pink-400 */
--chart-gray: #94a3b8; /* slate-400 */
--chart-blue-soft: rgba(56, 189, 248, 0.20);
--chart-green-soft: rgba(52, 211, 153, 0.20);
/* Spacing scale */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
/* Radii */
--r-sm: 8px;
--r-md: 10px;
--r-lg: 12px;
--r-xl: 16px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.06);
--shadow-lg: 0 12px 28px rgba(15, 23, 42, 0.10);
--shadow-bubble: 0 1px 2px rgba(15, 23, 42, 0.04);
/* Type scale */
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "Fira Mono", monospace;
--fs-xs: 11px;
--fs-sm: 12px;
--fs-base: 14px;
--fs-md: 15px;
--fs-lg: 18px;
--fs-xl: 24px;
--fw-regular: 400;
--fw-medium: 500;
--fw-semibold: 600;
--fw-bold: 700;
--lh-base: 1.5;
--lh-tight: 1.3;
/* Focus ring (accesibilidad) */
--focus-ring: 0 0 0 3px var(--accent-soft);
}
/* ────────────────────────────────────────────────────────────
* Reset / defaults
* ──────────────────────────────────────────────────────────── */
html, body {
background: var(--bg);
color: var(--text);
margin: 0;
font-family: var(--font-sans);
font-size: var(--fs-base);
line-height: var(--lh-base);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* { box-sizing: border-box; }
/* Scrollbars finos / pastel */
* {
scrollbar-width: thin;
scrollbar-color: var(--border-hi) transparent;
}
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-thumb { background: var(--border-hi); border-radius: 4px; }
*::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
*::-webkit-scrollbar-track { background: transparent; }
/* Selection con tinte pastel */
::selection { background: var(--accent-soft); color: var(--text); }

View File

@@ -0,0 +1,360 @@
/**
* Migración directa de pedidos WooCommerce (MySQL) a cache local (PostgreSQL)
*
* WooCommerce 8.x+ usa HPOS (High Performance Order Storage)
*
* Uso:
* node scripts/migrate-woo-orders.mjs [--tenant-id=xxx] [--batch-size=500] [--dry-run]
*
* Requiere en .env:
* WOO_MYSQL_HOST, WOO_MYSQL_PORT, WOO_MYSQL_USER, WOO_MYSQL_PASSWORD, WOO_MYSQL_DATABASE
* WOO_TABLE_PREFIX (default: wp_)
* DATABASE_URL (PostgreSQL)
*/
import mysql from "mysql2/promise";
import pg from "pg";
import "dotenv/config";
const { Pool } = pg;
// --- Configuración ---
const TENANT_ID = process.argv.find(a => a.startsWith("--tenant-id="))?.split("=")[1]
|| process.env.DEFAULT_TENANT_ID
|| "eb71b9a7-9ccf-430e-9b25-951a0c589c0f"; // tenant de piaf
const BATCH_SIZE = parseInt(process.argv.find(a => a.startsWith("--batch-size="))?.split("=")[1] || "500", 10);
const DRY_RUN = process.argv.includes("--dry-run");
const TABLE_PREFIX = process.env.WOO_TABLE_PREFIX || "wp_";
// --- Conexiones ---
let mysqlConn;
let pgPool;
async function connect() {
console.log("[migrate] Conectando a MySQL...");
mysqlConn = await mysql.createConnection({
host: process.env.WOO_MYSQL_HOST,
port: parseInt(process.env.WOO_MYSQL_PORT || "3306", 10),
user: process.env.WOO_MYSQL_USER,
password: process.env.WOO_MYSQL_PASSWORD,
database: process.env.WOO_MYSQL_DATABASE,
rowsAsArray: false,
});
console.log("[migrate] MySQL conectado");
console.log("[migrate] Conectando a PostgreSQL...");
pgPool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 5,
});
await pgPool.query("SELECT 1");
console.log("[migrate] PostgreSQL conectado");
}
async function disconnect() {
if (mysqlConn) await mysqlConn.end();
if (pgPool) await pgPool.end();
}
// --- Query principal de pedidos (HPOS) ---
function buildOrdersQuery() {
return `
SELECT
o.id as order_id,
o.status,
o.currency,
o.total_amount as total,
o.date_created_gmt as date_created,
o.date_paid_gmt as date_paid,
o.payment_method,
o.payment_method_title,
-- Billing
ba.first_name as billing_first_name,
ba.last_name as billing_last_name,
ba.address_1 as billing_address_1,
ba.city as billing_city,
ba.state as billing_state,
ba.postcode as billing_postcode,
ba.phone as billing_phone,
ba.email as billing_email,
-- Shipping
sa.first_name as shipping_first_name,
sa.last_name as shipping_last_name,
sa.address_1 as shipping_address_1,
sa.address_2 as shipping_address_2,
sa.city as shipping_city,
sa.state as shipping_state,
sa.postcode as shipping_postcode
FROM ${TABLE_PREFIX}wc_orders o
LEFT JOIN ${TABLE_PREFIX}wc_order_addresses ba
ON ba.order_id = o.id AND ba.address_type = 'billing'
LEFT JOIN ${TABLE_PREFIX}wc_order_addresses sa
ON sa.order_id = o.id AND sa.address_type = 'shipping'
WHERE o.type = 'shop_order'
ORDER BY o.id ASC
`;
}
// --- Query de items por pedido ---
async function getOrderItems(orderId) {
const [items] = await mysqlConn.query(`
SELECT
oi.order_item_id,
oi.order_item_name as product_name,
MAX(CASE WHEN oim.meta_key = '_product_id' THEN oim.meta_value END) as product_id,
MAX(CASE WHEN oim.meta_key = '_variation_id' THEN oim.meta_value END) as variation_id,
MAX(CASE WHEN oim.meta_key = '_qty' THEN oim.meta_value END) as quantity,
MAX(CASE WHEN oim.meta_key = '_line_total' THEN oim.meta_value END) as line_total,
MAX(CASE WHEN oim.meta_key = '_line_subtotal' THEN oim.meta_value END) as line_subtotal,
MAX(CASE WHEN oim.meta_key = 'unit' THEN oim.meta_value END) as unit,
MAX(CASE WHEN oim.meta_key = 'weight_g' THEN oim.meta_value END) as weight_g
FROM ${TABLE_PREFIX}woocommerce_order_items oi
LEFT JOIN ${TABLE_PREFIX}woocommerce_order_itemmeta oim ON oim.order_item_id = oi.order_item_id
WHERE oi.order_id = ? AND oi.order_item_type = 'line_item'
GROUP BY oi.order_item_id, oi.order_item_name
`, [orderId]);
return items;
}
// --- Query de metadata por pedido (source, shipping_method, etc) ---
async function getOrderMeta(orderId) {
const [rows] = await mysqlConn.query(`
SELECT meta_key, meta_value
FROM ${TABLE_PREFIX}wc_orders_meta
WHERE order_id = ?
AND meta_key IN ('source', 'shipping_method', 'payment_method_wa', 'run_id')
`, [orderId]);
const meta = {};
for (const row of rows) {
meta[row.meta_key] = row.meta_value;
}
return meta;
}
// --- Detectar source y flags ---
function detectOrderFlags(order, meta) {
// Source
const source = meta.source || "web";
// isDelivery
const shippingMethod = meta.shipping_method || "";
const isDelivery = shippingMethod === "delivery" ||
(!shippingMethod.toLowerCase().includes("retiro") &&
!shippingMethod.toLowerCase().includes("pickup") &&
!shippingMethod.toLowerCase().includes("local") &&
order.shipping_address_1); // Si tiene dirección de envío
// isCash
const metaPayment = meta.payment_method_wa || "";
const isCash = metaPayment === "cash" ||
order.payment_method === "cod" ||
(order.payment_method_title || "").toLowerCase().includes("efectivo");
return { source, isDelivery, isCash };
}
// --- Detectar sell_unit del item ---
function detectSellUnit(item) {
if (item.unit === "g" || item.unit === "kg") return "kg";
if (item.unit === "unit") return "unit";
if (item.weight_g) return "kg";
const name = (item.product_name || "").toLowerCase();
if (name.includes(" kg") || name.includes("kilo")) return "kg";
return "unit";
}
// --- Insert en PostgreSQL (batch con transacción) ---
async function insertOrderBatch(orders) {
if (DRY_RUN || orders.length === 0) return;
const client = await pgPool.connect();
try {
await client.query("BEGIN");
for (const order of orders) {
// Upsert pedido
await client.query(`
INSERT INTO woo_orders_cache (
tenant_id, woo_order_id, status, total, currency,
date_created, date_paid, source, is_delivery, is_cash,
customer_name, customer_phone, customer_email,
shipping_address_1, shipping_address_2, shipping_city,
shipping_state, shipping_postcode, shipping_country,
billing_address_1, billing_city, billing_state, billing_postcode,
raw, updated_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9, $10,
$11, $12, $13,
$14, $15, $16,
$17, $18, $19,
$20, $21, $22, $23,
$24, NOW()
)
ON CONFLICT (tenant_id, woo_order_id)
DO UPDATE SET
status = EXCLUDED.status,
total = EXCLUDED.total,
date_paid = EXCLUDED.date_paid,
source = EXCLUDED.source,
is_delivery = EXCLUDED.is_delivery,
is_cash = EXCLUDED.is_cash,
updated_at = NOW()
`, [
TENANT_ID,
order.order_id,
order.status?.replace("wc-", "") || "pending",
parseFloat(order.total) || 0,
order.currency || "ARS",
order.date_created,
order.date_paid,
order.source,
order.isDelivery,
order.isCash,
`${order.billing_first_name || ""} ${order.billing_last_name || ""}`.trim(),
order.billing_phone,
order.billing_email,
order.shipping_address_1,
order.shipping_address_2,
order.shipping_city,
order.shipping_state,
order.shipping_postcode,
"AR",
order.billing_address_1,
order.billing_city,
order.billing_state,
order.billing_postcode,
JSON.stringify({}), // raw simplificado para ahorrar espacio
]);
// Delete + insert items
await client.query(
`DELETE FROM woo_order_items WHERE tenant_id = $1 AND woo_order_id = $2`,
[TENANT_ID, order.order_id]
);
if (order.items && order.items.length > 0) {
const itemValues = order.items.map(it => [
TENANT_ID,
order.order_id,
it.product_id || it.variation_id,
it.product_name,
null, // sku
parseFloat(it.quantity) || 0,
it.line_subtotal ? parseFloat(it.line_subtotal) / (parseFloat(it.quantity) || 1) : null,
parseFloat(it.line_total) || 0,
detectSellUnit(it),
]);
for (const vals of itemValues) {
await client.query(`
INSERT INTO woo_order_items (
tenant_id, woo_order_id, woo_product_id,
product_name, sku, quantity, unit_price, line_total, sell_unit
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`, vals);
}
}
}
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
// --- Main ---
async function main() {
console.log("=".repeat(60));
console.log("[migrate] Migración WooCommerce (MySQL) -> PostgreSQL");
console.log(`[migrate] Tenant: ${TENANT_ID}`);
console.log(`[migrate] Batch size: ${BATCH_SIZE}`);
console.log(`[migrate] Table prefix: ${TABLE_PREFIX}`);
console.log(`[migrate] Dry run: ${DRY_RUN}`);
console.log("=".repeat(60));
await connect();
// Contar total de pedidos
const [[{ total }]] = await mysqlConn.query(`
SELECT COUNT(*) as total
FROM ${TABLE_PREFIX}wc_orders
WHERE type = 'shop_order'
`);
console.log(`[migrate] Total pedidos en WooCommerce: ${total}`);
// Limpiar cache existente si no es dry run
if (!DRY_RUN) {
console.log("[migrate] Limpiando cache existente...");
await pgPool.query(`DELETE FROM woo_order_items WHERE tenant_id = $1`, [TENANT_ID]);
await pgPool.query(`DELETE FROM woo_orders_cache WHERE tenant_id = $1`, [TENANT_ID]);
console.log("[migrate] Cache limpiado");
}
// Query de pedidos
console.log("[migrate] Iniciando migración...");
const [ordersRows] = await mysqlConn.query(buildOrdersQuery());
let count = 0;
let batch = [];
const startTime = Date.now();
for (const row of ordersRows) {
// Obtener items y metadata
const [items, meta] = await Promise.all([
getOrderItems(row.order_id),
getOrderMeta(row.order_id),
]);
const flags = detectOrderFlags(row, meta);
batch.push({
...row,
...flags,
items,
});
count++;
// Insert batch
if (batch.length >= BATCH_SIZE) {
await insertOrderBatch(batch);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
const rate = (count / elapsed).toFixed(0);
const pct = ((count / total) * 100).toFixed(1);
console.log(`[migrate] Progreso: ${count}/${total} (${pct}%) - ${rate} pedidos/s`);
batch = [];
}
}
// Último batch
if (batch.length > 0) {
await insertOrderBatch(batch);
}
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
console.log("=".repeat(60));
console.log(`[migrate] COMPLETADO`);
console.log(`[migrate] Pedidos migrados: ${count}`);
console.log(`[migrate] Tiempo total: ${totalTime}s`);
console.log(`[migrate] Velocidad promedio: ${(count / totalTime).toFixed(0)} pedidos/s`);
console.log("=".repeat(60));
await disconnect();
}
main().catch(err => {
console.error("[migrate] ERROR:", err);
disconnect().finally(() => process.exit(1));
});

93
scripts/seed-tenant.mjs Normal file
View File

@@ -0,0 +1,93 @@
#!/usr/bin/env node
/**
* Seed script para configurar tenant con credenciales de WooCommerce.
* Lee las credenciales de variables de entorno (no hardcodeadas).
*
* Variables requeridas:
* - DATABASE_URL: conexión a PostgreSQL
* - APP_ENCRYPTION_KEY: clave para encriptar credenciales
* - WOO_CONSUMER_KEY: consumer key de WooCommerce
* - WOO_CONSUMER_SECRET: consumer secret de WooCommerce
* - WOO_BASE_URL: URL base de WooCommerce (opcional, default: https://piaf.floda.dev/wp-json/wc/v3)
*/
import pg from "pg";
const TENANT_ID = "eb71b9a7-9ccf-430e-9b25-951a0c589c0f";
async function seed() {
const {
DATABASE_URL,
APP_ENCRYPTION_KEY,
WOO_CONSUMER_KEY,
WOO_CONSUMER_SECRET,
WOO_BASE_URL = "https://piaf.floda.dev/wp-json/wc/v3",
} = process.env;
// Validar variables requeridas
if (!DATABASE_URL) {
console.log("[seed] DATABASE_URL no configurada, saltando seed");
return;
}
if (!APP_ENCRYPTION_KEY || !WOO_CONSUMER_KEY || !WOO_CONSUMER_SECRET) {
console.log("[seed] Variables de WooCommerce no configuradas, saltando seed de ecommerce config");
console.log("[seed] Para configurar, definir: APP_ENCRYPTION_KEY, WOO_CONSUMER_KEY, WOO_CONSUMER_SECRET");
return;
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
try {
// Verificar si ya existe la config
const check = await pool.query(
"SELECT 1 FROM tenant_ecommerce_config WHERE tenant_id = $1",
[TENANT_ID]
);
if (check.rows.length > 0) {
console.log("[seed] tenant_ecommerce_config ya existe, saltando");
return;
}
// Configurar encryption key para la sesión
await pool.query("SELECT set_config('app.encryption_key', $1, false)", [
APP_ENCRYPTION_KEY,
]);
// Insertar config de WooCommerce
await pool.query(
`INSERT INTO tenant_ecommerce_config (
tenant_id,
provider,
base_url,
credential_ref,
api_version,
timeout_ms,
enabled,
enc_consumer_key,
enc_consumer_secret
) VALUES (
$1::uuid,
'woo',
$2,
'secret://woo/piaf',
'wc/v3',
8000,
true,
pgp_sym_encrypt($3, current_setting('app.encryption_key')),
pgp_sym_encrypt($4, current_setting('app.encryption_key'))
)`,
[TENANT_ID, WOO_BASE_URL, WOO_CONSUMER_KEY, WOO_CONSUMER_SECRET]
);
console.log("[seed] tenant_ecommerce_config creada exitosamente");
} catch (err) {
console.error("[seed] Error:", err.message);
// No fallar el startup si el seed falla (puede ser que las tablas no existan aún)
} finally {
await pool.end();
}
}
seed();

View File

@@ -5,7 +5,6 @@ import { fileURLToPath } from "url";
import { createSimulatorRouter } from "./modules/1-intake/routes/simulator.js";
import { createEvolutionRouter } from "./modules/1-intake/routes/evolution.js";
import { createMercadoPagoRouter } from "./modules/6-mercadopago/routes/mercadoPago.js";
import { createWooWebhooksRouter } from "./modules/2-identity/routes/wooWebhooks.js";
export function createApp({ tenantId }) {
@@ -23,7 +22,6 @@ export function createApp({ tenantId }) {
// --- Integraciones / UI ---
app.use(createSimulatorRouter({ tenantId }));
app.use(createEvolutionRouter());
app.use("/payments/meli", createMercadoPagoRouter());
app.use(createWooWebhooksRouter());
// Home (UI)

View File

@@ -1,158 +0,0 @@
import {
handleListPrompts,
handleGetPrompt,
handleSavePrompt,
handleRollbackPrompt,
handleResetPrompt,
handleGetPromptVersion,
handleTestPrompt,
} from "../handlers/prompts.js";
/**
* GET /prompts - Lista todos los prompts del tenant
*/
export const makeListPrompts = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const result = await handleListPrompts({ tenantId });
res.json(result);
} catch (err) {
console.error("[prompts] List error:", err);
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};
/**
* GET /prompts/:key - Obtiene un prompt específico con versiones
*/
export const makeGetPrompt = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const promptKey = req.params.key;
const result = await handleGetPrompt({ tenantId, promptKey });
res.json(result);
} catch (err) {
console.error("[prompts] Get error:", err);
if (err.message.includes("Invalid prompt_key")) {
return res.status(400).json({ ok: false, error: "invalid_prompt_key" });
}
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};
/**
* POST /prompts/:key - Crea/actualiza un prompt (nueva versión)
*/
export const makeSavePrompt = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const promptKey = req.params.key;
const { content, model, created_by } = req.body || {};
if (!content) {
return res.status(400).json({ ok: false, error: "content_required" });
}
const result = await handleSavePrompt({
tenantId,
promptKey,
content,
model,
createdBy: created_by || null,
});
res.json(result);
} catch (err) {
console.error("[prompts] Save error:", err);
if (err.message.includes("Invalid prompt_key")) {
return res.status(400).json({ ok: false, error: "invalid_prompt_key" });
}
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};
/**
* POST /prompts/:key/rollback/:version - Restaura una versión anterior
*/
export const makeRollbackPrompt = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const { key, version } = req.params;
const { created_by } = req.body || {};
const result = await handleRollbackPrompt({
tenantId,
promptKey: key,
toVersion: version,
createdBy: created_by || null,
});
res.json(result);
} catch (err) {
console.error("[prompts] Rollback error:", err);
if (err.message.includes("not found")) {
return res.status(404).json({ ok: false, error: "version_not_found" });
}
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};
/**
* POST /prompts/:key/reset - Resetea al default
*/
export const makeResetPrompt = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const promptKey = req.params.key;
const result = await handleResetPrompt({ tenantId, promptKey });
res.json(result);
} catch (err) {
console.error("[prompts] Reset error:", err);
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};
/**
* GET /prompts/:key/versions/:version - Obtiene contenido de una versión específica
*/
export const makeGetPromptVersion = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const { key, version } = req.params;
const result = await handleGetPromptVersion({ tenantId, promptKey: key, version });
res.json(result);
} catch (err) {
console.error("[prompts] GetVersion error:", err);
if (err.message.includes("not found")) {
return res.status(404).json({ ok: false, error: "version_not_found" });
}
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};
/**
* POST /prompts/:key/test - Prueba un prompt con un mensaje
*/
export const makeTestPrompt = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const promptKey = req.params.key;
const { content, test_message, store_config } = req.body || {};
if (!test_message) {
return res.status(400).json({ ok: false, error: "test_message_required" });
}
const result = await handleTestPrompt({
tenantId,
promptKey,
content,
testMessage: test_message,
storeConfig: store_config || {},
});
res.json(result);
} catch (err) {
console.error("[prompts] Test error:", err);
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
}
};

View File

@@ -1,92 +1,27 @@
import {
handleListRecentOrders,
handleGetProductsWithStock,
handleCreateTestOrder,
handleCreatePaymentLink,
handleSimulateMpWebhook,
} from "../handlers/testing.js";
import { handleListOrders } from "../handlers/testing.js";
import { handleGetOrderStats } from "../handlers/stats.js";
export const makeListRecentOrders = (tenantIdOrFn) => async (req, res) => {
export const makeListOrders = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const limit = parseInt(req.query.limit) || 20;
const result = await handleListRecentOrders({ tenantId, limit });
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
const result = await handleListOrders({ tenantId, page, limit });
res.json(result);
} catch (err) {
console.error("[testing] listRecentOrders error:", err);
console.error("[testing] listOrders error:", err);
res.status(500).json({ ok: false, error: err.message || "internal_error" });
}
};
export const makeGetProductsWithStock = (tenantIdOrFn) => async (req, res) => {
export const makeGetOrderStats = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const result = await handleGetProductsWithStock({ tenantId });
const result = await handleGetOrderStats({ tenantId });
res.json(result);
} catch (err) {
console.error("[testing] getProductsWithStock error:", err);
console.error("[stats] getOrderStats error:", err);
res.status(500).json({ ok: false, error: err.message || "internal_error" });
}
};
export const makeCreateTestOrder = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const { basket, address, wa_chat_id } = req.body || {};
if (!basket?.items?.length) {
return res.status(400).json({ ok: false, error: "basket_required" });
}
const result = await handleCreateTestOrder({ tenantId, basket, address, wa_chat_id });
res.json(result);
} catch (err) {
console.error("[testing] createTestOrder error:", err);
res.status(500).json({ ok: false, error: err.message || "internal_error" });
}
};
export const makeCreatePaymentLink = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const { woo_order_id, amount } = req.body || {};
if (!woo_order_id) {
return res.status(400).json({ ok: false, error: "woo_order_id_required" });
}
if (!amount || Number(amount) <= 0) {
return res.status(400).json({ ok: false, error: "amount_required" });
}
const result = await handleCreatePaymentLink({
tenantId,
wooOrderId: woo_order_id,
amount: Number(amount)
});
res.json(result);
} catch (err) {
console.error("[testing] createPaymentLink error:", err);
res.status(500).json({ ok: false, error: err.message || "internal_error" });
}
};
export const makeSimulateMpWebhook = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const { woo_order_id, amount } = req.body || {};
if (!woo_order_id) {
return res.status(400).json({ ok: false, error: "woo_order_id_required" });
}
const result = await handleSimulateMpWebhook({
tenantId,
wooOrderId: woo_order_id,
amount: Number(amount) || 0
});
res.json(result);
} catch (err) {
console.error("[testing] simulateMpWebhook error:", err);
res.status(500).json({ ok: false, error: err.message || "internal_error" });
}
};

View File

@@ -1,183 +0,0 @@
import { pool } from "../../shared/db/pool.js";
// ─────────────────────────────────────────────────────────────
// Prompt Templates - CRUD con versionado
// ─────────────────────────────────────────────────────────────
// Prompt keys válidos
export const PROMPT_KEYS = ["router", "greeting", "orders", "shipping", "payment", "browse"];
// Modelos por defecto para cada prompt
export const DEFAULT_MODELS = {
router: "gpt-4o-mini",
greeting: "gpt-4-turbo",
orders: "gpt-4-turbo",
shipping: "gpt-4o-mini",
payment: "gpt-4o-mini",
browse: "gpt-4-turbo",
};
/**
* Obtiene el prompt activo para un tenant y key
* @returns {Object|null} { id, prompt_key, content, model, version, is_active, created_at, created_by }
*/
export async function getActivePrompt({ tenantId, promptKey }) {
const sql = `
SELECT id, prompt_key, content, model, version, is_active, created_at, created_by
FROM prompt_templates
WHERE tenant_id = $1 AND prompt_key = $2 AND is_active = true
LIMIT 1
`;
const { rows } = await pool.query(sql, [tenantId, promptKey]);
return rows[0] || null;
}
/**
* Lista todos los prompts activos de un tenant
*/
export async function listActivePrompts({ tenantId }) {
const sql = `
SELECT id, prompt_key, content, model, version, is_active, created_at, created_by
FROM prompt_templates
WHERE tenant_id = $1 AND is_active = true
ORDER BY prompt_key
`;
const { rows } = await pool.query(sql, [tenantId]);
return rows;
}
/**
* Obtiene todas las versiones de un prompt
*/
export async function getPromptVersions({ tenantId, promptKey, limit = 20 }) {
const sql = `
SELECT id, prompt_key, content, model, version, is_active, created_at, created_by
FROM prompt_templates
WHERE tenant_id = $1 AND prompt_key = $2
ORDER BY version DESC
LIMIT $3
`;
const { rows } = await pool.query(sql, [tenantId, promptKey, limit]);
return rows;
}
/**
* Obtiene una versión específica de un prompt
*/
export async function getPromptVersion({ tenantId, promptKey, version }) {
const sql = `
SELECT id, prompt_key, content, model, version, is_active, created_at, created_by
FROM prompt_templates
WHERE tenant_id = $1 AND prompt_key = $2 AND version = $3
LIMIT 1
`;
const { rows } = await pool.query(sql, [tenantId, promptKey, version]);
return rows[0] || null;
}
/**
* Desactiva el prompt activo actual (para crear nueva versión)
*/
export async function deactivatePrompt({ tenantId, promptKey }) {
const sql = `
UPDATE prompt_templates
SET is_active = false
WHERE tenant_id = $1 AND prompt_key = $2 AND is_active = true
`;
await pool.query(sql, [tenantId, promptKey]);
}
/**
* Crea una nueva versión del prompt (automáticamente desactiva la anterior)
* @returns {Object} El prompt creado con su versión
*/
export async function createPrompt({ tenantId, promptKey, content, model, createdBy = null }) {
// Validar prompt_key
if (!PROMPT_KEYS.includes(promptKey)) {
throw new Error(`Invalid prompt_key: ${promptKey}. Valid keys: ${PROMPT_KEYS.join(", ")}`);
}
// Desactivar versión anterior
await deactivatePrompt({ tenantId, promptKey });
// Insertar nueva versión (el trigger calcula la versión automáticamente)
const sql = `
INSERT INTO prompt_templates (tenant_id, prompt_key, content, model, is_active, created_by)
VALUES ($1, $2, $3, $4, true, $5)
RETURNING id, prompt_key, content, model, version, is_active, created_at, created_by
`;
const { rows } = await pool.query(sql, [
tenantId,
promptKey,
content,
model || DEFAULT_MODELS[promptKey] || "gpt-4-turbo",
createdBy,
]);
return rows[0];
}
/**
* Restaura una versión anterior del prompt (crea nueva versión con el contenido antiguo)
*/
export async function rollbackPrompt({ tenantId, promptKey, toVersion, createdBy = null }) {
// Obtener la versión a restaurar
const oldVersion = await getPromptVersion({ tenantId, promptKey, version: toVersion });
if (!oldVersion) {
throw new Error(`Version ${toVersion} not found for prompt ${promptKey}`);
}
// Crear nueva versión con el contenido antiguo
return createPrompt({
tenantId,
promptKey,
content: oldVersion.content,
model: oldVersion.model,
createdBy,
});
}
/**
* Resetea un prompt a su default (desactiva todas las versiones custom)
*/
export async function resetPromptToDefault({ tenantId, promptKey }) {
const sql = `
UPDATE prompt_templates
SET is_active = false
WHERE tenant_id = $1 AND prompt_key = $2
`;
await pool.query(sql, [tenantId, promptKey]);
return { success: true, message: `Prompt ${promptKey} reset to default` };
}
/**
* Elimina todas las versiones de un prompt (usar con cuidado)
*/
export async function deleteAllPromptVersions({ tenantId, promptKey }) {
const sql = `
DELETE FROM prompt_templates
WHERE tenant_id = $1 AND prompt_key = $2
RETURNING id
`;
const { rows } = await pool.query(sql, [tenantId, promptKey]);
return { deleted: rows.length };
}
/**
* Obtiene estadísticas de prompts de un tenant
*/
export async function getPromptStats({ tenantId }) {
const sql = `
SELECT
prompt_key,
COUNT(*) as total_versions,
MAX(version) as latest_version,
MAX(CASE WHEN is_active THEN version END) as active_version,
MAX(created_at) as last_updated
FROM prompt_templates
WHERE tenant_id = $1
GROUP BY prompt_key
ORDER BY prompt_key
`;
const { rows } = await pool.query(sql, [tenantId]);
return rows;
}

View File

@@ -3,23 +3,25 @@ import { pool } from "../../shared/db/pool.js";
// ─────────────────────────────────────────────────────────────
// Tenant Settings - CRUD
// ─────────────────────────────────────────────────────────────
//
// Modelo actual:
// - Datos del comercio: store_name, bot_name, store_address, store_phone.
// - Pickup (retiro en tienda): pickup_enabled + pickup_days CSV +
// pickup_hours_start/end TIME, y `schedule.pickup` JSONB para horario por día.
// - Delivery: TODO vive en `delivery_zones.zones[]` (polígonos GeoJSON con
// costo, días y rango horario por zona). El bot valida zona usando la
// ubicación que el cliente comparte por WhatsApp.
/**
* Obtiene la configuración del tenant
*/
export async function getSettings({ tenantId }) {
const sql = `
SELECT
id, tenant_id,
store_name, bot_name, store_address, store_phone,
delivery_enabled, delivery_days,
delivery_hours_start::text as delivery_hours_start,
delivery_hours_end::text as delivery_hours_end,
delivery_min_order,
pickup_enabled, pickup_days,
pickup_hours_start::text as pickup_hours_start,
pickup_hours_end::text as pickup_hours_end,
schedule,
delivery_zones,
created_at, updated_at
FROM tenant_settings
WHERE tenant_id = $1
@@ -29,62 +31,47 @@ export async function getSettings({ tenantId }) {
return rows[0] || null;
}
/**
* Crea o actualiza la configuración del tenant (upsert)
*/
export async function upsertSettings({ tenantId, settings }) {
const {
store_name,
bot_name,
store_address,
store_phone,
delivery_enabled,
delivery_days,
delivery_hours_start,
delivery_hours_end,
delivery_min_order,
pickup_enabled,
pickup_days,
pickup_hours_start,
pickup_hours_end,
schedule,
delivery_zones,
} = settings;
const sql = `
INSERT INTO tenant_settings (
tenant_id, store_name, bot_name, store_address, store_phone,
delivery_enabled, delivery_days, delivery_hours_start, delivery_hours_end, delivery_min_order,
pickup_enabled, pickup_days, pickup_hours_start, pickup_hours_end,
schedule
schedule, delivery_zones
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (tenant_id) DO UPDATE SET
store_name = COALESCE(EXCLUDED.store_name, tenant_settings.store_name),
bot_name = COALESCE(EXCLUDED.bot_name, tenant_settings.bot_name),
store_address = COALESCE(EXCLUDED.store_address, tenant_settings.store_address),
store_phone = COALESCE(EXCLUDED.store_phone, tenant_settings.store_phone),
delivery_enabled = COALESCE(EXCLUDED.delivery_enabled, tenant_settings.delivery_enabled),
delivery_days = COALESCE(EXCLUDED.delivery_days, tenant_settings.delivery_days),
delivery_hours_start = COALESCE(EXCLUDED.delivery_hours_start, tenant_settings.delivery_hours_start),
delivery_hours_end = COALESCE(EXCLUDED.delivery_hours_end, tenant_settings.delivery_hours_end),
delivery_min_order = COALESCE(EXCLUDED.delivery_min_order, tenant_settings.delivery_min_order),
pickup_enabled = COALESCE(EXCLUDED.pickup_enabled, tenant_settings.pickup_enabled),
pickup_days = COALESCE(EXCLUDED.pickup_days, tenant_settings.pickup_days),
pickup_hours_start = COALESCE(EXCLUDED.pickup_hours_start, tenant_settings.pickup_hours_start),
pickup_hours_end = COALESCE(EXCLUDED.pickup_hours_end, tenant_settings.pickup_hours_end),
schedule = COALESCE(EXCLUDED.schedule, tenant_settings.schedule),
delivery_zones = COALESCE(EXCLUDED.delivery_zones, tenant_settings.delivery_zones),
updated_at = NOW()
RETURNING
id, tenant_id,
store_name, bot_name, store_address, store_phone,
delivery_enabled, delivery_days,
delivery_hours_start::text as delivery_hours_start,
delivery_hours_end::text as delivery_hours_end,
delivery_min_order,
pickup_enabled, pickup_days,
pickup_hours_start::text as pickup_hours_start,
pickup_hours_end::text as pickup_hours_end,
schedule,
delivery_zones,
created_at, updated_at
`;
@@ -94,164 +81,110 @@ export async function upsertSettings({ tenantId, settings }) {
bot_name || null,
store_address || null,
store_phone || null,
delivery_enabled ?? null,
delivery_days || null,
delivery_hours_start || null,
delivery_hours_end || null,
delivery_min_order ?? null,
pickup_enabled ?? null,
pickup_days || null,
pickup_hours_start || null,
pickup_hours_end || null,
schedule ? JSON.stringify(schedule) : null,
delivery_zones ? JSON.stringify(delivery_zones) : null,
];
const { rows } = await pool.query(sql, params);
return rows[0];
}
/**
* Formatea horarios desde schedule JSONB para mostrar de forma natural
* Agrupa días con mismos horarios: "Lun a Vie de 9 a 14hs, Sáb de 9 a 13hs"
* Formatea schedule.pickup ({ lun: { start, end } }) en prosa para mostrar al
* cliente. Agrupa días con mismos horarios: "Lun a Vie de 9 a 14hs".
*/
function formatScheduleHours(scheduleType, enabled) {
if (!enabled || !scheduleType || typeof scheduleType !== "object") {
function formatScheduleHours(scheduleObj, enabled) {
if (!enabled || !scheduleObj || typeof scheduleObj !== "object") {
return enabled === false ? "No disponible" : "";
}
const dayOrder = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
const dayNames = {
lun: "Lunes", mar: "Martes", mie: "Miércoles",
jue: "Jueves", vie: "Viernes", sab: "Sábado", dom: "Domingo"
jue: "Jueves", vie: "Viernes", sab: "Sábado", dom: "Domingo",
};
// Agrupar días por horario
const groups = {};
for (const day of dayOrder) {
const slot = scheduleType[day];
const slot = scheduleObj[day];
if (!slot || !slot.start || !slot.end) continue;
const key = `${slot.start}-${slot.end}`;
if (!groups[key]) {
groups[key] = { start: slot.start, end: slot.end, days: [] };
}
if (!groups[key]) groups[key] = { start: slot.start, end: slot.end, days: [] };
groups[key].days.push(day);
}
if (Object.keys(groups).length === 0) return "";
if (!Object.keys(groups).length) return "";
// Formatear cada grupo
const parts = Object.values(groups).map(g => {
const parts = Object.values(groups).map((g) => {
const days = g.days;
let dayStr;
// Detectar rangos consecutivos
if (days.length >= 3) {
const indices = days.map(d => dayOrder.indexOf(d));
const isConsecutive = indices.every((v, i, arr) => i === 0 || v === arr[i-1] + 1);
if (isConsecutive) {
dayStr = `${dayNames[days[0]]} a ${dayNames[days[days.length-1]]}`;
} else {
dayStr = days.map(d => dayNames[d]).join(", ");
}
const indices = days.map((d) => dayOrder.indexOf(d));
const isConsecutive = indices.every((v, i, arr) => i === 0 || v === arr[i - 1] + 1);
dayStr = isConsecutive
? `${dayNames[days[0]]} a ${dayNames[days[days.length - 1]]}`
: days.map((d) => dayNames[d]).join(", ");
} else if (days.length === 2) {
dayStr = `${dayNames[days[0]]} y ${dayNames[days[1]]}`;
} else {
dayStr = dayNames[days[0]];
}
const startH = g.start.slice(0, 5);
const endH = g.end.slice(0, 5);
return `${dayStr} de ${startH} a ${endH}`;
return `${dayStr} de ${g.start.slice(0, 5)} a ${g.end.slice(0, 5)}`;
});
return parts.join(", ");
}
function formatLegacyPickupHours(enabled, days, start, end) {
if (!enabled) return "No disponible";
if (!days || !start || !end) return "";
const daysFormatted = days.split(",").map((d) => d.trim()).join(", ");
return `${daysFormatted} de ${start.slice(0, 5)} a ${end.slice(0, 5)}`;
}
/**
* Obtiene la configuración formateada para usar en prompts (storeConfig)
* Forma de la storeConfig que consume el agente y su workingMemory.
*/
export async function getStoreConfig({ tenantId }) {
const settings = await getSettings({ tenantId });
if (!settings) {
// Valores por defecto si no hay configuración
return {
name: "la carnicería",
botName: "Piaf",
hours: "",
address: "",
phone: "",
deliveryHours: "",
pickupHours: "",
schedule: null,
delivery_zones: {},
};
}
const schedule = settings.schedule || {};
// Usar nuevo formato schedule si existe, sino legacy
let deliveryHours, pickupHours;
if (schedule.delivery && Object.keys(schedule.delivery).length > 0) {
deliveryHours = formatScheduleHours(schedule.delivery, settings.delivery_enabled);
} else {
// Legacy format
deliveryHours = formatLegacyHours(
settings.delivery_enabled,
settings.delivery_days,
settings.delivery_hours_start,
settings.delivery_hours_end
);
}
if (schedule.pickup && Object.keys(schedule.pickup).length > 0) {
pickupHours = formatScheduleHours(schedule.pickup, settings.pickup_enabled);
} else {
// Legacy format
pickupHours = formatLegacyHours(
const pickupHours = schedule.pickup && Object.keys(schedule.pickup).length
? formatScheduleHours(schedule.pickup, settings.pickup_enabled)
: formatLegacyPickupHours(
settings.pickup_enabled,
settings.pickup_days,
settings.pickup_hours_start,
settings.pickup_hours_end
settings.pickup_hours_end,
);
}
// Combinar horarios para store_hours (usa pickup como horario de tienda)
let storeHours = "";
if (settings.pickup_enabled) {
storeHours = pickupHours;
}
return {
name: settings.store_name || "la carnicería",
botName: settings.bot_name || "Piaf",
hours: storeHours,
hours: settings.pickup_enabled ? pickupHours : "",
address: settings.store_address || "",
phone: settings.store_phone || "",
deliveryHours,
pickupHours,
deliveryEnabled: settings.delivery_enabled,
pickupEnabled: settings.pickup_enabled,
schedule,
// Campos legacy para compatibilidad
delivery_days: settings.delivery_days,
delivery_hours_start: settings.delivery_hours_start,
delivery_hours_end: settings.delivery_hours_end,
delivery_zones: settings.delivery_zones || {},
};
}
/**
* Formatear horarios en formato legacy (días + rango único)
*/
function formatLegacyHours(enabled, days, start, end) {
if (!enabled) return "No disponible";
if (!days || !start || !end) return "";
const daysFormatted = days.split(",").map(d => d.trim()).join(", ");
const startFormatted = start?.slice(0, 5) || "";
const endFormatted = end?.slice(0, 5) || "";
return `${daysFormatted} de ${startFormatted} a ${endFormatted}`;
}

View File

@@ -1,258 +0,0 @@
import {
getActivePrompt,
listActivePrompts,
getPromptVersions,
getPromptVersion,
createPrompt,
rollbackPrompt,
resetPromptToDefault,
getPromptStats,
PROMPT_KEYS,
DEFAULT_MODELS,
} from "../db/promptsRepo.js";
import { loadDefaultPrompt, AVAILABLE_VARIABLES, invalidatePromptCache } from "../../3-turn-engine/nlu/promptLoader.js";
/**
* Lista todos los prompts del tenant (activos + defaults para los que no tienen custom)
*/
export async function handleListPrompts({ tenantId }) {
const activePrompts = await listActivePrompts({ tenantId });
const stats = await getPromptStats({ tenantId });
// Construir lista completa con defaults para los que no tienen custom
const promptsMap = new Map(activePrompts.map(p => [p.prompt_key, p]));
const items = PROMPT_KEYS.map(key => {
const custom = promptsMap.get(key);
const stat = stats.find(s => s.prompt_key === key);
if (custom) {
return {
prompt_key: key,
content: custom.content,
model: custom.model,
version: custom.version,
is_default: false,
total_versions: stat?.total_versions || 1,
last_updated: custom.created_at,
created_by: custom.created_by,
};
} else {
// Cargar default
let defaultContent = "";
try {
defaultContent = loadDefaultPrompt(key);
} catch (e) {
defaultContent = `[Error loading default: ${e.message}]`;
}
return {
prompt_key: key,
content: defaultContent,
model: DEFAULT_MODELS[key] || "gpt-4-turbo",
version: null,
is_default: true,
total_versions: stat?.total_versions || 0,
last_updated: null,
created_by: null,
};
}
});
return {
items,
available_variables: AVAILABLE_VARIABLES,
available_models: ["gpt-4-turbo", "gpt-4o-mini", "gpt-4o", "gpt-3.5-turbo"],
};
}
/**
* Obtiene un prompt específico con su historial de versiones
*/
export async function handleGetPrompt({ tenantId, promptKey }) {
if (!PROMPT_KEYS.includes(promptKey)) {
throw new Error(`Invalid prompt_key: ${promptKey}`);
}
const current = await getActivePrompt({ tenantId, promptKey });
const versions = await getPromptVersions({ tenantId, promptKey, limit: 20 });
let defaultContent = "";
try {
defaultContent = loadDefaultPrompt(promptKey);
} catch (e) {
defaultContent = `[Error: ${e.message}]`;
}
return {
prompt_key: promptKey,
current: current || {
content: defaultContent,
model: DEFAULT_MODELS[promptKey] || "gpt-4-turbo",
version: null,
is_default: true,
},
default_content: defaultContent,
default_model: DEFAULT_MODELS[promptKey] || "gpt-4-turbo",
versions: versions.map(v => ({
version: v.version,
is_active: v.is_active,
created_at: v.created_at,
created_by: v.created_by,
content_preview: v.content.slice(0, 100) + (v.content.length > 100 ? "..." : ""),
})),
available_variables: AVAILABLE_VARIABLES,
available_models: ["gpt-4-turbo", "gpt-4o-mini", "gpt-4o", "gpt-3.5-turbo"],
};
}
/**
* Crea o actualiza un prompt (crea nueva versión)
*/
export async function handleSavePrompt({ tenantId, promptKey, content, model, createdBy }) {
if (!PROMPT_KEYS.includes(promptKey)) {
throw new Error(`Invalid prompt_key: ${promptKey}`);
}
if (!content || content.trim().length === 0) {
throw new Error("Content is required");
}
const result = await createPrompt({
tenantId,
promptKey,
content: content.trim(),
model: model || DEFAULT_MODELS[promptKey] || "gpt-4-turbo",
createdBy,
});
// Invalidar cache
invalidatePromptCache(tenantId, promptKey);
return {
ok: true,
item: result,
message: `Prompt ${promptKey} saved as version ${result.version}`,
};
}
/**
* Restaura una versión anterior del prompt
*/
export async function handleRollbackPrompt({ tenantId, promptKey, toVersion, createdBy }) {
if (!PROMPT_KEYS.includes(promptKey)) {
throw new Error(`Invalid prompt_key: ${promptKey}`);
}
const result = await rollbackPrompt({
tenantId,
promptKey,
toVersion: parseInt(toVersion, 10),
createdBy,
});
// Invalidar cache
invalidatePromptCache(tenantId, promptKey);
return {
ok: true,
item: result,
message: `Prompt ${promptKey} rolled back to version ${toVersion}, new version is ${result.version}`,
};
}
/**
* Resetea un prompt al default (desactiva todas las versiones custom)
*/
export async function handleResetPrompt({ tenantId, promptKey }) {
if (!PROMPT_KEYS.includes(promptKey)) {
throw new Error(`Invalid prompt_key: ${promptKey}`);
}
await resetPromptToDefault({ tenantId, promptKey });
// Invalidar cache
invalidatePromptCache(tenantId, promptKey);
return {
ok: true,
message: `Prompt ${promptKey} reset to default`,
};
}
/**
* Obtiene el contenido de una versión específica
*/
export async function handleGetPromptVersion({ tenantId, promptKey, version }) {
if (!PROMPT_KEYS.includes(promptKey)) {
throw new Error(`Invalid prompt_key: ${promptKey}`);
}
const versionData = await getPromptVersion({
tenantId,
promptKey,
version: parseInt(version, 10)
});
if (!versionData) {
throw new Error(`Version ${version} not found for prompt ${promptKey}`);
}
return { item: versionData };
}
/**
* Prueba un prompt con un mensaje de ejemplo
*/
export async function handleTestPrompt({ tenantId, promptKey, content, testMessage, storeConfig = {} }) {
// Importar dinámicamente para evitar dependencias circulares
const { loadPrompt } = await import("../../3-turn-engine/nlu/promptLoader.js");
// Si se proporciona content, usarlo directamente
// Si no, cargar el prompt actual
let promptContent = content;
let model = DEFAULT_MODELS[promptKey] || "gpt-4-turbo";
if (!promptContent) {
const loaded = await loadPrompt({ tenantId, promptKey, variables: storeConfig });
promptContent = loaded.content;
model = loaded.model;
} else {
// Aplicar variables al content proporcionado
for (const [key, value] of Object.entries(storeConfig)) {
promptContent = promptContent.replace(new RegExp(`{{${key}}}`, "g"), value || "");
}
promptContent = promptContent.replace(/\{\{[^}]+\}\}/g, "");
}
// Importar OpenAI
const OpenAI = (await import("openai")).default;
const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY;
if (!apiKey) {
throw new Error("OPENAI_API_KEY not configured");
}
const openai = new OpenAI({ apiKey });
// Hacer la llamada de prueba
const startTime = Date.now();
const response = await openai.chat.completions.create({
model,
temperature: 0.2,
max_tokens: 500,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: promptContent },
{ role: "user", content: testMessage },
],
});
const endTime = Date.now();
return {
ok: true,
response: response?.choices?.[0]?.message?.content || "",
model,
usage: response?.usage,
latency_ms: endTime - startTime,
};
}

View File

@@ -1,234 +1,140 @@
import { getSettings, upsertSettings, getStoreConfig } from "../db/settingsRepo.js";
// Días de la semana para validación
const VALID_DAYS = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
/**
* Genera schedule por defecto con horarios uniformes
*/
function createDefaultSchedule() {
const defaultDays = ["lun", "mar", "mie", "jue", "vie", "sab"];
const delivery = {};
function defaultPickupSchedule() {
const days = ["lun", "mar", "mie", "jue", "vie", "sab"];
const pickup = {};
for (const day of defaultDays) {
delivery[day] = { start: "09:00", end: "18:00" };
pickup[day] = { start: "08:00", end: "20:00" };
}
return { delivery, pickup };
for (const d of days) pickup[d] = { start: "08:00", end: "20:00" };
return { pickup };
}
/**
* Obtiene la configuración actual del tenant
*/
export async function handleGetSettings({ tenantId }) {
const settings = await getSettings({ tenantId });
// Si no hay configuración, devolver defaults
if (!settings) {
return {
store_name: "Mi Negocio",
bot_name: "Piaf",
store_address: "",
store_phone: "",
delivery_enabled: true,
delivery_days: "lun,mar,mie,jue,vie,sab",
delivery_hours_start: "09:00",
delivery_hours_end: "18:00",
delivery_min_order: 0,
pickup_enabled: true,
pickup_days: "lun,mar,mie,jue,vie,sab",
pickup_hours_start: "08:00",
pickup_hours_end: "20:00",
schedule: createDefaultSchedule(),
schedule: defaultPickupSchedule(),
delivery_zones: {},
is_default: true,
};
}
// Si no tiene schedule, generar desde datos legacy
// Si schedule está vacío pero tenemos los campos legacy de pickup, generar
// schedule.pickup para que la UI pueda editar el grid por día.
let schedule = settings.schedule;
if (!schedule || Object.keys(schedule).length === 0) {
schedule = buildScheduleFromLegacy(settings);
if (!schedule || !schedule.pickup || !Object.keys(schedule.pickup).length) {
schedule = buildPickupScheduleFromLegacy(settings);
}
return {
...settings,
// Formatear horarios TIME a HH:MM
delivery_hours_start: settings.delivery_hours_start?.slice(0, 5) || "09:00",
delivery_hours_end: settings.delivery_hours_end?.slice(0, 5) || "18:00",
pickup_hours_start: settings.pickup_hours_start?.slice(0, 5) || "08:00",
pickup_hours_end: settings.pickup_hours_end?.slice(0, 5) || "20:00",
schedule,
delivery_zones: settings.delivery_zones || {},
is_default: false,
};
}
/**
* Construye schedule desde datos legacy
*/
function buildScheduleFromLegacy(settings) {
const schedule = { delivery: {}, pickup: {} };
// Delivery
if (settings.delivery_enabled && settings.delivery_days) {
const days = settings.delivery_days.split(",").map(d => d.trim());
const start = settings.delivery_hours_start?.slice(0, 5) || "09:00";
const end = settings.delivery_hours_end?.slice(0, 5) || "18:00";
for (const day of days) {
if (VALID_DAYS.includes(day)) {
schedule.delivery[day] = { start, end };
}
}
}
// Pickup
if (settings.pickup_enabled && settings.pickup_days) {
const days = settings.pickup_days.split(",").map(d => d.trim());
function buildPickupScheduleFromLegacy(settings) {
const out = { pickup: {} };
if (settings?.pickup_enabled && settings?.pickup_days) {
const days = settings.pickup_days.split(",").map((d) => d.trim());
const start = settings.pickup_hours_start?.slice(0, 5) || "08:00";
const end = settings.pickup_hours_end?.slice(0, 5) || "20:00";
for (const day of days) {
if (VALID_DAYS.includes(day)) {
schedule.pickup[day] = { start, end };
if (VALID_DAYS.includes(day)) out.pickup[day] = { start, end };
}
}
}
return schedule;
return out;
}
/**
* Valida la estructura del schedule
*/
function validateSchedule(schedule) {
if (!schedule || typeof schedule !== "object") return;
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
for (const type of ["delivery", "pickup"]) {
const typeSchedule = schedule[type];
if (!typeSchedule || typeof typeSchedule !== "object") continue;
for (const [day, slot] of Object.entries(typeSchedule)) {
if (!VALID_DAYS.includes(day)) {
throw new Error(`Invalid day in schedule.${type}: ${day}`);
}
if (slot === null) continue; // null = no disponible
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/;
const pickup = schedule.pickup;
if (!pickup || typeof pickup !== "object") return;
for (const [day, slot] of Object.entries(pickup)) {
if (!VALID_DAYS.includes(day)) throw new Error(`Invalid day in schedule.pickup: ${day}`);
if (slot === null) continue;
if (typeof slot !== "object" || !slot.start || !slot.end) {
throw new Error(`Invalid slot format for ${type}.${day}`);
}
if (!timeRegex.test(slot.start)) {
throw new Error(`Invalid start time for ${type}.${day}: ${slot.start}`);
}
if (!timeRegex.test(slot.end)) {
throw new Error(`Invalid end time for ${type}.${day}: ${slot.end}`);
}
throw new Error(`Invalid slot format for pickup.${day}`);
}
if (!timeRegex.test(slot.start)) throw new Error(`Invalid start time for pickup.${day}: ${slot.start}`);
if (!timeRegex.test(slot.end)) throw new Error(`Invalid end time for pickup.${day}: ${slot.end}`);
}
}
/**
* Sincroniza campos legacy desde schedule
*/
function syncLegacyFromSchedule(settings) {
const schedule = settings.schedule;
if (!schedule) return;
// Sincronizar delivery
if (schedule.delivery) {
const deliveryDays = Object.keys(schedule.delivery).filter(d => schedule.delivery[d] !== null);
if (deliveryDays.length > 0) {
// Ordenar días
deliveryDays.sort((a, b) => VALID_DAYS.indexOf(a) - VALID_DAYS.indexOf(b));
settings.delivery_days = deliveryDays.join(",");
// Usar primer horario como legacy
const firstSlot = schedule.delivery[deliveryDays[0]];
if (firstSlot) {
settings.delivery_hours_start = firstSlot.start;
settings.delivery_hours_end = firstSlot.end;
}
} else {
settings.delivery_days = "";
}
}
// Sincronizar pickup
if (schedule.pickup) {
const pickupDays = Object.keys(schedule.pickup).filter(d => schedule.pickup[d] !== null);
if (pickupDays.length > 0) {
pickupDays.sort((a, b) => VALID_DAYS.indexOf(a) - VALID_DAYS.indexOf(b));
settings.pickup_days = pickupDays.join(",");
const firstSlot = schedule.pickup[pickupDays[0]];
if (firstSlot) {
settings.pickup_hours_start = firstSlot.start;
settings.pickup_hours_end = firstSlot.end;
}
function syncPickupLegacyFromSchedule(settings) {
const pickup = settings?.schedule?.pickup;
if (!pickup) return;
const days = Object.keys(pickup).filter((d) => pickup[d] && pickup[d].start && pickup[d].end);
days.sort((a, b) => VALID_DAYS.indexOf(a) - VALID_DAYS.indexOf(b));
if (days.length) {
settings.pickup_days = days.join(",");
const first = pickup[days[0]];
settings.pickup_hours_start = first.start;
settings.pickup_hours_end = first.end;
} else {
settings.pickup_days = "";
}
}
function validateDeliveryZones(dz) {
if (!dz || typeof dz !== "object") return;
if (dz.zones && !Array.isArray(dz.zones)) {
throw new Error("delivery_zones.zones must be an array");
}
for (const z of dz.zones || []) {
if (!z?.id || !z?.name) throw new Error("Each zone needs id + name");
if (z.polygon && (z.polygon.type !== "Polygon" || !Array.isArray(z.polygon.coordinates))) {
throw new Error(`Invalid polygon GeoJSON for zone ${z.id}`);
}
if (Array.isArray(z.delivery_days)) {
for (const d of z.delivery_days) {
if (!VALID_DAYS.includes(d)) throw new Error(`Invalid delivery day in zone ${z.id}: ${d}`);
}
}
}
}
/**
* Guarda la configuración del tenant
*/
export async function handleSaveSettings({ tenantId, settings }) {
// Validaciones básicas
if (!settings.store_name?.trim()) {
throw new Error("store_name is required");
}
if (!settings.bot_name?.trim()) {
throw new Error("bot_name is required");
}
if (!settings.store_name?.trim()) throw new Error("store_name is required");
if (!settings.bot_name?.trim()) throw new Error("bot_name is required");
// Validar schedule si viene
if (settings.schedule) {
validateSchedule(settings.schedule);
// Sincronizar campos legacy desde schedule
syncLegacyFromSchedule(settings);
} else {
// Legacy: validar días individuales
if (settings.delivery_days) {
const days = settings.delivery_days.split(",").map(d => d.trim().toLowerCase());
for (const day of days) {
if (!VALID_DAYS.includes(day)) {
throw new Error(`Invalid delivery day: ${day}`);
syncPickupLegacyFromSchedule(settings);
}
}
settings.delivery_days = days.join(",");
if (settings.delivery_zones) {
validateDeliveryZones(settings.delivery_zones);
}
if (settings.pickup_days) {
const days = settings.pickup_days.split(",").map(d => d.trim().toLowerCase());
for (const day of days) {
if (!VALID_DAYS.includes(day)) {
throw new Error(`Invalid pickup day: ${day}`);
}
const days = settings.pickup_days.split(",").map((d) => d.trim().toLowerCase());
for (const d of days) {
if (!VALID_DAYS.includes(d)) throw new Error(`Invalid pickup day: ${d}`);
}
settings.pickup_days = days.join(",");
}
// Validar horarios legacy
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
if (settings.delivery_hours_start && !timeRegex.test(settings.delivery_hours_start)) {
throw new Error("Invalid delivery_hours_start format (use HH:MM)");
}
if (settings.delivery_hours_end && !timeRegex.test(settings.delivery_hours_end)) {
throw new Error("Invalid delivery_hours_end format (use HH:MM)");
}
if (settings.pickup_hours_start && !timeRegex.test(settings.pickup_hours_start)) {
throw new Error("Invalid pickup_hours_start format (use HH:MM)");
}
if (settings.pickup_hours_end && !timeRegex.test(settings.pickup_hours_end)) {
throw new Error("Invalid pickup_hours_end format (use HH:MM)");
}
}
const result = await upsertSettings({ tenantId, settings });
@@ -236,18 +142,14 @@ export async function handleSaveSettings({ tenantId, settings }) {
ok: true,
settings: {
...result,
delivery_hours_start: result.delivery_hours_start?.slice(0, 5),
delivery_hours_end: result.delivery_hours_end?.slice(0, 5),
pickup_hours_start: result.pickup_hours_start?.slice(0, 5),
pickup_hours_end: result.pickup_hours_end?.slice(0, 5),
delivery_zones: result.delivery_zones || {},
},
message: "Configuración guardada correctamente",
};
}
/**
* Obtiene el storeConfig formateado para prompts
*/
export async function handleGetStoreConfig({ tenantId }) {
return await getStoreConfig({ tenantId });
}

View File

@@ -0,0 +1,44 @@
import { syncOrdersIncremental } from "../../4-woo-orders/wooOrders.js";
import * as ordersRepo from "../../4-woo-orders/ordersRepo.js";
/**
* Obtiene estadísticas de pedidos para el dashboard
*/
export async function handleGetOrderStats({ tenantId }) {
// Sync en background — no bloqueamos el request
const syncPromise = syncOrdersIncremental({ tenantId }).catch(err =>
console.error("[stats] sync error:", err)
);
// Respondemos con lo que hay en DB mientras sincroniza
const [monthlyStats, productStats, yoyStats, totals] = await Promise.all([
ordersRepo.getMonthlyStats({ tenantId }),
ordersRepo.getProductStats({ tenantId }),
ordersRepo.getYoyStats({ tenantId }),
ordersRepo.getTotals({ tenantId }),
]);
return {
// Stats mensuales (para gráficas de barras/líneas)
months: monthlyStats.months,
totals: monthlyStats.totals,
order_counts: monthlyStats.order_counts,
by_source: monthlyStats.by_source,
by_shipping: monthlyStats.by_shipping,
// Totales agregados (para donuts)
totals_aggregated: totals,
// Stats por producto
top_products_revenue: productStats.by_revenue,
top_products_kg: productStats.by_kg,
top_products_units: productStats.by_units,
// YoY
yoy: yoyStats,
// Info de sync (sincronizando en background)
synced: 0,
total_in_cache: totals.total_orders ?? 0,
};
}

View File

@@ -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";
}

View File

@@ -1,131 +1,25 @@
import { createOrder, listRecentOrders } from "../../4-woo-orders/wooOrders.js";
import { createPreference, reconcilePayment } from "../../6-mercadopago/mercadoPago.js";
import { listProducts } from "../db/repo.js";
import { syncOrdersIncremental } from "../../4-woo-orders/wooOrders.js";
import * as ordersRepo from "../../4-woo-orders/ordersRepo.js";
/**
* Lista pedidos recientes de WooCommerce
* Lista pedidos desde cache local (con sync incremental)
*/
export async function handleListRecentOrders({ tenantId, limit = 20 }) {
const orders = await listRecentOrders({ tenantId, limit });
return { items: orders };
}
export async function handleListOrders({ tenantId, page = 1, limit = 50 }) {
// 1. Sincronizar pedidos nuevos de Woo
await syncOrdersIncremental({ tenantId });
/**
* Obtiene productos con stock para testing
*/
export async function handleGetProductsWithStock({ tenantId }) {
const allProducts = await listProducts({ tenantId, limit: 500 });
const withStock = allProducts.filter(p =>
p.stock_status === "instock" &&
p.price &&
Number(p.price) > 0
);
return { items: withStock };
}
/**
* Crea una orden de prueba en WooCommerce
*/
export async function handleCreateTestOrder({ tenantId, basket, address, wa_chat_id }) {
if (!basket?.items?.length) {
throw new Error("basket_empty");
}
const order = await createOrder({
tenantId,
wooCustomerId: null, // Sin customer de Woo para testing
basket,
address,
run_id: `test-${Date.now()}`,
});
// Calcular total desde line_items
let total = 0;
if (order?.raw?.line_items) {
for (const item of order.raw.line_items) {
total += Number(item.total) || 0;
}
} else if (order?.raw?.total) {
total = Number(order.raw.total) || 0;
}
// 2. Obtener pedidos paginados desde cache
const orders = await ordersRepo.listOrders({ tenantId, page, limit });
const total = await ordersRepo.countOrders({ tenantId });
return {
ok: true,
woo_order_id: order?.id || null,
items: orders,
pagination: {
page,
limit,
total,
line_items: order?.line_items || [],
raw: order?.raw || null,
};
}
/**
* Crea un link de pago de MercadoPago
*/
export async function handleCreatePaymentLink({ tenantId, wooOrderId, amount }) {
if (!wooOrderId) {
throw new Error("missing_woo_order_id");
}
if (!amount || Number(amount) <= 0) {
throw new Error("invalid_amount");
}
const pref = await createPreference({
tenantId,
wooOrderId,
amount: Number(amount),
});
return {
ok: true,
preference_id: pref?.preference_id || null,
init_point: pref?.init_point || null,
sandbox_init_point: pref?.sandbox_init_point || null,
};
}
/**
* Simula un webhook de MercadoPago con pago exitoso
* No pasa por el endpoint real (requiere firma HMAC)
* Crea un payment mock y llama a reconcilePayment directamente
*/
export async function handleSimulateMpWebhook({ tenantId, wooOrderId, amount }) {
if (!wooOrderId) {
throw new Error("missing_woo_order_id");
}
// Crear payment mock con status approved
const mockPaymentId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const mockPayment = {
id: mockPaymentId,
status: "approved",
status_detail: "accredited",
external_reference: `${tenantId}|${wooOrderId}`,
transaction_amount: Number(amount) || 0,
currency_id: "ARS",
date_approved: new Date().toISOString(),
date_created: new Date().toISOString(),
payment_method_id: "test",
payment_type_id: "credit_card",
payer: {
email: "test@test.com",
},
order: {
id: `pref-test-${wooOrderId}`,
pages: Math.ceil(total / limit),
},
};
// Reconciliar el pago (actualiza mp_payments y cambia status de orden a processing)
const result = await reconcilePayment({
tenantId,
payment: mockPayment,
});
return {
ok: true,
payment_id: mockPaymentId,
woo_order_id: result?.woo_order_id || wooOrderId,
status: "approved",
order_status: "processing",
reconciled: result?.payment || null,
};
}

View File

@@ -1,29 +1,25 @@
import crypto from "crypto";
import { parseEvolutionWebhook } from "../services/evolutionParser.js";
import { resolveTenantId, processMessage } from "../../2-identity/services/pipeline.js";
import { processMessage } from "../../2-identity/services/pipeline.js";
import { getTenantId } from "../../shared/tenant.js";
import { debug as dbg } from "../../shared/debug.js";
export async function handleEvolutionWebhook(body) {
const t0 = Date.now();
const parsed = parseEvolutionWebhook(body);
if (!parsed.ok) {
if (!parsed.ok) {
return { status: 200, payload: { ok: true, ignored: parsed.reason } };
}
if (dbg.perf || dbg.evolution) {
console.log("[perf] evolution.webhook.start", {
tenant_key: parsed.tenant_key || null,
chat_id: parsed.chat_id,
message_id: parsed.message_id || null,
ts: parsed.ts || null,
});
}
const tenantId = await resolveTenantId({
chat_id: parsed.chat_id,
tenant_key: parsed.tenant_key,
to_phone: null,
});
const tenantId = getTenantId();
const pm = await processMessage({
tenantId,
@@ -31,9 +27,10 @@ if (!parsed.ok) {
from: parsed.chat_id.replace("@s.whatsapp.net", ""),
displayName: parsed.from_name || null,
text: parsed.text,
inboundLocation: parsed.location || null,
provider: "evolution",
message_id: parsed.message_id || crypto.randomUUID(),
meta: { pushName: parsed.from_name, ts: parsed.ts, instance: parsed.tenant_key, source: parsed.source },
meta: { pushName: parsed.from_name, ts: parsed.ts, source: parsed.source },
});
if (dbg.perf || dbg.evolution) {
@@ -48,4 +45,3 @@ if (!parsed.ok) {
return { status: 200, payload: { ok: true } };
}

View File

@@ -1,30 +1,33 @@
import crypto from "crypto";
import { resolveTenantId } from "../../2-identity/services/pipeline.js";
import { processMessage } from "../../2-identity/services/pipeline.js";
import { getTenantId } from "../../shared/tenant.js";
export async function handleSimSend(body) {
const { chat_id, from_phone, text } = body || {};
if (!chat_id || !from_phone || !text) {
return { status: 400, payload: { ok: false, error: "chat_id, from_phone, text are required" } };
const { chat_id, from_phone, text, location } = body || {};
if (!chat_id || !from_phone || (!text && !location)) {
return { status: 400, payload: { ok: false, error: "chat_id, from_phone, and text or location are required" } };
}
// Aceptar location share desde el simulator. Mismo formato que el parser de
// Evolution: { lat, lng, label? }.
const inboundLocation =
location && typeof location.lat === "number" && typeof location.lng === "number"
? { lat: location.lat, lng: location.lng, label: location.label || null }
: null;
const provider = "sim";
const message_id = crypto.randomUUID();
const tenantId = await resolveTenantId({
chat_id,
tenant_key: body?.tenant_key,
to_phone: body?.to_phone,
});
const tenantId = getTenantId();
const result = await processMessage({
tenantId,
chat_id,
from: from_phone,
text,
text: text || "",
inboundLocation,
provider,
message_id,
});
return { status: 200, payload: { ok: true, run_id: result.run_id, reply: result.reply } };
}

View File

@@ -10,11 +10,12 @@ import { makeSearchProducts, makeListProducts, makeGetProduct, makeSyncProducts,
import { makeListAliases, makeCreateAlias, makeUpdateAlias, makeDeleteAlias } from "../../0-ui/controllers/aliases.js";
import { makeListRecommendations, makeGetRecommendation, makeCreateRecommendation, makeUpdateRecommendation, makeDeleteRecommendation } from "../../0-ui/controllers/recommendations.js";
import { makeListProductQtyRules, makeGetProductQtyRules, makeSaveProductQtyRules } from "../../0-ui/controllers/quantities.js";
import { makeListPrompts, makeGetPrompt, makeSavePrompt, makeRollbackPrompt, makeResetPrompt, makeGetPromptVersion, makeTestPrompt } from "../../0-ui/controllers/prompts.js";
// Prompts CRUD removido: el agente nuevo usa un system prompt único hardcoded.
import { makeListPendingTakeovers, makeListAllTakeovers, makeGetTakeover, makeRespondToTakeover, makeCancelTakeover, makeCheckPendingTakeover } from "../../0-ui/controllers/takeovers.js";
import { makeGetSettings, makeSaveSettings } from "../../0-ui/controllers/settings.js";
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js";
import { makeListRecentOrders, makeGetProductsWithStock, makeCreateTestOrder, makeCreatePaymentLink, makeSimulateMpWebhook } from "../../0-ui/controllers/testing.js";
import { makeListOrders, makeGetOrderStats } from "../../0-ui/controllers/testing.js";
import { getAgentMetrics } from "../../3-turn-engine/agent/runTurn.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/agent", (req, res) => res.json(getAgentMetrics()));
router.get("/conversations", makeGetConversations(getTenantId));
router.get("/conversations/state", makeGetConversationState(getTenantId));
@@ -81,13 +83,8 @@ export function createSimulatorRouter({ tenantId }) {
router.put("/quantities/:wooProductId", makeSaveProductQtyRules(getTenantId));
// --- Prompts routes ---
router.get("/prompts", makeListPrompts(getTenantId));
router.get("/prompts/:key", makeGetPrompt(getTenantId));
router.post("/prompts/:key", makeSavePrompt(getTenantId));
router.post("/prompts/:key/rollback/:version", makeRollbackPrompt(getTenantId));
router.post("/prompts/:key/reset", makeResetPrompt(getTenantId));
router.get("/prompts/:key/versions/:version", makeGetPromptVersion(getTenantId));
router.post("/prompts/:key/test", makeTestPrompt(getTenantId));
// /prompts/* removido tras flip a agente tool-calling. El system prompt
// único vive en src/modules/3-turn-engine/agent/systemPrompt.js.
// --- Human Takeovers routes ---
router.get("/takeovers", makeListPendingTakeovers(getTenantId));
@@ -107,12 +104,9 @@ export function createSimulatorRouter({ tenantId }) {
router.get("/runs", makeListRuns(getTenantId));
router.get("/runs/:run_id", makeGetRunById(getTenantId));
// --- Testing routes ---
router.get("/test/orders", makeListRecentOrders(getTenantId));
router.get("/test/products-with-stock", makeGetProductsWithStock(getTenantId));
router.post("/test/order", makeCreateTestOrder(getTenantId));
router.post("/test/payment-link", makeCreatePaymentLink(getTenantId));
router.post("/test/simulate-webhook", makeSimulateMpWebhook(getTenantId));
// --- API routes (orders) ---
router.get("/api/orders", makeListOrders(getTenantId));
router.get("/api/stats/orders", makeGetOrderStats(getTenantId));
return router;
}

View File

@@ -34,8 +34,19 @@ export function parseEvolutionWebhook(reqBody) {
(typeof msg.extendedTextMessage?.text === "string" && msg.extendedTextMessage.text) ||
"";
// extract location share (WhatsApp pin). Evolution wraps Baileys formats:
// - locationMessage: { degreesLatitude, degreesLongitude, name?, address? }
// - liveLocationMessage: { degreesLatitude, degreesLongitude }
const loc = msg.locationMessage || msg.liveLocationMessage || null;
const lat = loc?.degreesLatitude;
const lng = loc?.degreesLongitude;
const location =
typeof lat === "number" && typeof lng === "number"
? { lat, lng, label: loc?.name || loc?.address || null }
: null;
const cleanText = String(text).trim();
if (!cleanText) return { ok: false, reason: "empty_text" };
if (!cleanText && !location) return { ok: false, reason: "empty_message" };
// metadata
const pushName = data.pushName || null;
@@ -48,6 +59,7 @@ export function parseEvolutionWebhook(reqBody) {
chat_id: remoteJid,
message_id: messageId || null,
text: cleanText,
location,
from_name: pushName,
message_type: messageType || null,
ts,

View File

@@ -1,5 +1,5 @@
import { refreshProductByWooId } from "../../shared/wooSnapshot.js";
import { getTenantByKey } from "../db/repo.js";
import { getTenantId } from "../../shared/tenant.js";
import { insertAuditLog } from "../../0-ui/db/repo.js";
function unauthorized(res) {
@@ -48,11 +48,8 @@ export function makeWooProductWebhook() {
const { id, parentId, resource, action, changes } = parseWooPayload(req.body || {});
if (!id) return res.status(400).json({ ok: false, error: "missing_id" });
// Determinar tenant por query ?tenant_key=...
const tenantKey = req.query?.tenant_key || process.env.TENANT_KEY || null;
if (!tenantKey) return res.status(400).json({ ok: false, error: "missing_tenant_key" });
const tenant = await getTenantByKey(String(tenantKey).toLowerCase());
if (!tenant?.id) return res.status(404).json({ ok: false, error: "tenant_not_found" });
// Mono-tenant: el tenant es el único cargado al boot.
const tenant = { id: getTenantId() };
const parentForVariation =
resource && String(resource).includes("variation") ? parentId || null : null;

View File

@@ -461,21 +461,6 @@ export async function deleteIdentityMapByChat({ tenant_id, wa_chat_id, provider
return rowCount || 0;
}
export async function getTenantByKey(key) {
const { rows } = await pool.query(`select id, key, name from tenants where key=$1`, [key]);
return rows[0] || null;
}
export async function getTenantIdByChannel({ channel_type, channel_key }) {
const q = `
select tenant_id
from tenant_channels
where channel_type=$1 and channel_key=$2
`;
const { rows } = await pool.query(q, [channel_type, channel_key]);
return rows[0]?.tenant_id || null;
}
export async function getExternalCustomerIdByChat({ tenant_id, wa_chat_id, provider = "woo" }) {
const q = `
select external_customer_id
@@ -535,23 +520,24 @@ export async function getDecryptedTenantEcommerceConfig({
return rows[0] || null;
}
export async function searchProductAliases({ tenant_id, q = "", limit = 20 }) {
export async function searchProductAliases({ tenant_id, q = "", limit = 20, threshold = 0.3 }) {
const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 20));
const query = String(q || "").trim();
if (!query) return [];
const normalized = query.toLowerCase();
const like = `%${query}%`;
const nlike = `%${normalized}%`;
const sql = `
select tenant_id, alias, normalized_alias, woo_product_id, category_hint, boost, metadata, updated_at
select tenant_id, alias, normalized_alias, woo_product_id, category_hint, boost, metadata, updated_at,
greatest(similarity(alias, $2), similarity(normalized_alias, $3)) as sim
from product_aliases
where tenant_id=$1
and (alias ilike $2 or normalized_alias ilike $3)
order by boost desc, updated_at desc
where tenant_id = $1
and (alias % $2 or normalized_alias % $3)
order by sim desc, boost desc, updated_at desc
limit $4
`;
const { rows } = await pool.query(sql, [tenant_id, like, nlike, lim]);
return rows.map((r) => ({
const { rows } = await pool.query(sql, [tenant_id, query, normalized, lim]);
return rows
.filter((r) => Number(r.sim) >= threshold)
.map((r) => ({
tenant_id: r.tenant_id,
alias: r.alias,
normalized_alias: r.normalized_alias,
@@ -560,6 +546,30 @@ export async function searchProductAliases({ tenant_id, q = "", limit = 20 }) {
boost: r.boost,
metadata: r.metadata,
updated_at: r.updated_at,
similarity: Number(r.sim),
}));
}
export async function searchAliasProductMappings({ tenant_id, q = "", limit = 50, threshold = 0.3 }) {
const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 50));
const query = String(q || "").trim();
if (!query) return [];
const normalized = query.toLowerCase();
const sql = `
select alias, woo_product_id, score, similarity(alias, $2) as sim
from alias_product_mappings
where tenant_id = $1 and alias % $2
order by sim desc, score desc
limit $3
`;
const { rows } = await pool.query(sql, [tenant_id, normalized, lim]);
return rows
.filter((r) => Number(r.sim) >= threshold)
.map((r) => ({
alias: r.alias,
woo_product_id: Number(r.woo_product_id),
score: Number(r.score || 1),
similarity: Number(r.sim),
}));
}
@@ -733,51 +743,4 @@ export async function upsertProductEmbedding({
return rows[0] || null;
}
export async function upsertMpPayment({
tenant_id,
woo_order_id = null,
preference_id = null,
payment_id = null,
status = null,
paid_at = null,
raw = {},
}) {
if (!payment_id) throw new Error("payment_id_required");
const sql = `
insert into mp_payments
(tenant_id, woo_order_id, preference_id, payment_id, status, paid_at, raw, created_at, updated_at)
values
($1, $2, $3, $4, $5, $6::timestamptz, $7::jsonb, now(), now())
on conflict (tenant_id, payment_id)
do update set
woo_order_id = excluded.woo_order_id,
preference_id = excluded.preference_id,
status = excluded.status,
paid_at = excluded.paid_at,
raw = excluded.raw,
updated_at = now()
returning tenant_id, woo_order_id, preference_id, payment_id, status, paid_at, raw, updated_at
`;
const { rows } = await pool.query(sql, [
tenant_id,
woo_order_id,
preference_id,
payment_id,
status,
paid_at,
JSON.stringify(raw ?? {}),
]);
return rows[0] || null;
}
export async function getMpPaymentById({ tenant_id, payment_id }) {
const sql = `
select tenant_id, woo_order_id, preference_id, payment_id, status, paid_at, raw, updated_at
from mp_payments
where tenant_id=$1 and payment_id=$2
limit 1
`;
const { rows } = await pool.query(sql, [tenant_id, payment_id]);
return rows[0] || null;
}

View File

@@ -8,16 +8,14 @@ import {
getExternalCustomerIdByChat,
upsertExternalCustomerMap,
updateRunLatency,
getTenantByKey,
getTenantIdByChannel,
} from "../db/repo.js";
import { getTenantId } from "../../shared/tenant.js";
import { sseSend } from "../../shared/sse.js";
import { createWooCustomer, getWooCustomerById } from "./woo.js";
import { debug as dbg } from "../../shared/debug.js";
import { runTurnV3 } from "../../3-turn-engine/turnEngineV3.js";
import { safeNextState } from "../../3-turn-engine/fsm.js";
import { createOrder, updateOrder } from "../../4-woo-orders/wooOrders.js";
import { createPreference } from "../../6-mercadopago/mercadoPago.js";
import { handleCreateTakeover } from "../../0-ui/handlers/takeovers.js";
import { sendTextMessage, isEvolutionEnabled } from "../../1-intake/services/evolutionSender.js";
@@ -123,6 +121,7 @@ export async function processMessage({
message_id,
displayName = null,
meta = null,
inboundLocation = null,
}) {
const { started_at, mark, msBetween } = makePerf();
const prev = await touchConversationState({ tenant_id: tenantId, wa_chat_id: chat_id });
@@ -130,12 +129,15 @@ const prev = await touchConversationState({ tenant_id: tenantId, wa_chat_id: cha
mark("start");
const stageDebug = dbg.perf;
mark("after_touchConversationState");
// Detectar conversación nueva (más de 24 horas sin actividad)
const staleThresholdMs = 24 * 60 * 60 * 1000; // 24 horas
// TTL stale: 24h general, 7d si la conversación quedó PAUSED, sin TTL si AWAITING_HUMAN.
const stateNow = prev?.state || "IDLE";
let staleThresholdMs = 24 * 60 * 60 * 1000;
if (stateNow === "PAUSED") staleThresholdMs = 7 * 24 * 60 * 60 * 1000;
if (stateNow === "AWAITING_HUMAN") staleThresholdMs = Infinity;
const isStale =
prev?.state_updated_at &&
Date.now() - new Date(prev.state_updated_at).getTime() > staleThresholdMs;
const prev_state = isStale ? "IDLE" : prev?.state || "IDLE";
const prev_state = isStale ? "IDLE" : stateNow;
let externalCustomerId = await getExternalCustomerIdByChat({
tenant_id: tenantId,
wa_chat_id: chat_id,
@@ -143,7 +145,7 @@ let externalCustomerId = await getExternalCustomerIdByChat({
});
mark("after_getExternalCustomerIdByChat");
await insertMessage({
const inserted = await insertMessage({
tenant_id: tenantId,
wa_chat_id: chat_id,
provider,
@@ -155,6 +157,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,
@@ -169,17 +180,34 @@ let externalCustomerId = await getExternalCustomerIdByChat({
let reducedContext = prev?.context && typeof prev?.context === "object" ? { ...prev.context } : {};
if (isStale) {
// Conversación nueva: resetear carrito pero mantener datos del cliente
// (external_customer_id) y la "última entrega" para que el bot pueda
// ofrecer "te lo mando al mismo lugar que la otra vez?".
reducedContext = {
external_customer_id: reducedContext.external_customer_id,
// Resetear order y pending
last_delivery: reducedContext.last_delivery || null,
order: null,
order_basket: null,
pending_items: null,
// Marcar que fue reseteado
_reset_reason: "stale",
_reset_at: new Date().toISOString(),
};
}
// Si llegó una ubicación compartida (WhatsApp pin), guardarla en pending
// para que el agente la lea via working_memory y matchee zona en set_address.
if (inboundLocation && typeof inboundLocation.lat === "number" && typeof inboundLocation.lng === "number") {
const baseOrder = reducedContext.order && typeof reducedContext.order === "object" ? reducedContext.order : {};
const merged = { ...baseOrder };
if (!Array.isArray(merged.cart)) merged.cart = [];
if (!Array.isArray(merged.pending)) merged.pending = [];
merged.pending_location = {
lat: inboundLocation.lat,
lng: inboundLocation.lng,
label: inboundLocation.label || null,
received_at: new Date().toISOString(),
};
reducedContext.order = merged;
}
let decision;
let plan;
let llmMeta;
@@ -206,7 +234,6 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
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 },
],
};
@@ -258,16 +285,14 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
...baseAddress,
phone: baseAddress.phone || phoneFromWa,
};
// Obtener shipping_method y payment_method del contexto (preferir decision que es el resultado del turn)
// shipping_method del contexto (delivery|pickup). El cobro se gestiona offline.
const shippingMethod = decision?.context_patch?.shipping_method || reducedContext?.shipping_method || null;
const paymentMethod = decision?.context_patch?.payment_method || reducedContext?.payment_method || null;
const order = await createOrder({
tenantId,
wooCustomerId: externalCustomerId,
basket: basketToUse,
address: addressWithPhone,
shippingMethod,
paymentMethod,
run_id: null,
});
actionPatch.woo_order_id = order?.id || null;
@@ -313,25 +338,6 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
pending_query: act.payload?.pending_query,
});
}
} else if (act.type === "send_payment_link") {
const total = Number(actionPatch?.order_total || reducedContext?.order_total || 0) || null;
if (!total || total <= 0) {
throw new Error("order_total_missing");
}
const pref = await createPreference({
tenantId,
wooOrderId: actionPatch.woo_order_id || reducedContext?.woo_order_id || prev?.last_order_id,
amount: total || 0,
});
actionPatch.payment_link = pref?.init_point || null;
actionPatch.mp = {
preference_id: pref?.preference_id || null,
init_point: pref?.init_point || null,
};
newTools.push({ type: "send_payment_link", ok: true, preference_id: pref?.preference_id || null });
if (pref?.init_point) {
plan.reply = `Listo, acá tenés el link de pago: ${pref.init_point}\nAvisame cuando esté listo.`;
}
}
} catch (e) {
newTools.push({ type: act.type, ok: false, error: String(e?.message || e) });
@@ -411,7 +417,7 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
const orderForFsm = context?.order || context?.order_basket || {};
const signals = {
confirm_order: plan.intent === "confirm_order",
payment_selected: plan.intent === "select_payment",
shipping_completed: plan.order_action === "create_order",
};
const nextState = safeNextState(prev_state, orderForFsm, signals).next_state;
plan.next_state = nextState;
@@ -468,7 +474,6 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
invariants,
final_reply: plan.reply,
order_id: actionPatch.woo_order_id || null,
payment_link: actionPatch.payment_link || null,
latency_ms: end_to_end_ms,
});
@@ -493,28 +498,11 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
return { run_id, reply: plan.reply };
}
function parseTenantFromChatId(chat_id) {
const m = /^([a-z0-9_-]+):/.exec(chat_id);
return m?.[1]?.toLowerCase() || null;
}
export async function resolveTenantId({ chat_id, to_phone = null, tenant_key = null }) {
const explicit = (tenant_key || parseTenantFromChatId(chat_id) || "").toLowerCase();
if (explicit) {
const t = await getTenantByKey(explicit);
if (t) return t.id;
throw new Error(`tenant_not_found: ${explicit}`);
}
if (to_phone) {
const id = await getTenantIdByChannel({ channel_type: "whatsapp", channel_key: to_phone });
if (id) return id;
}
const fallbackKey = (process.env.TENANT_KEY || "piaf").toLowerCase();
const t = await getTenantByKey(fallbackKey);
if (t) return t.id;
throw new Error(`tenant_not_found: ${fallbackKey}`);
/**
* Mono-tenant: devuelve el id resuelto al boot. No hace queries por turno.
* Se mantiene como async para no romper callers existentes.
*/
export async function resolveTenantId() {
return getTenantId();
}

View File

@@ -0,0 +1,112 @@
/**
* customerProfile — perfil del cliente para "lo de siempre".
*
* Lookup por teléfono (extraído del chat_id WhatsApp) en woo_orders_cache.
* Agrupa items por woo_product_id en los últimos 6 meses, top 5 frequent_items.
*
* Cache 10 min por chat_id.
*/
import { pool } from "../../shared/db/pool.js";
const CACHE_TTL_MS = 10 * 60 * 1000;
const _cache = new Map();
function phoneFromChatId(chatId) {
if (!chatId) return null;
// chat_id típico: "5491133230322@s.whatsapp.net"
const m = /^(\d+)/.exec(String(chatId));
return m ? m[1] : null;
}
function normalizePhone(p) {
return String(p || "").replace(/[^\d]/g, "");
}
/**
* Devuelve perfil del cliente o null si no hay datos.
*/
export async function getCustomerProfile({ tenantId, chat_id }) {
if (!tenantId || !chat_id) return null;
const cacheKey = `${tenantId}:${chat_id}`;
const cached = _cache.get(cacheKey);
if (cached && Date.now() - cached.t < CACHE_TTL_MS) return cached.value;
const phone = phoneFromChatId(chat_id);
if (!phone) {
_cache.set(cacheKey, { value: null, t: Date.now() });
return null;
}
try {
const profile = await fetchProfile({ tenantId, phone });
_cache.set(cacheKey, { value: profile, t: Date.now() });
return profile;
} catch (err) {
console.error("[customerProfile] error:", err?.message || err);
return null;
}
}
async function fetchProfile({ tenantId, phone }) {
const phoneClean = normalizePhone(phone);
if (!phoneClean) return null;
// Match phones que terminen igual (los Woo a veces vienen con +54 o sin)
const phoneSuffix = phoneClean.slice(-8);
const orderSql = `
SELECT id, woo_order_id, total, date_created,
shipping_address_1, shipping_address_2, shipping_city, customer_name
FROM woo_orders_cache
WHERE tenant_id = $1
AND regexp_replace(coalesce(customer_phone,''), '\\D', '', 'g') LIKE '%' || $2
AND date_created > NOW() - INTERVAL '6 months'
ORDER BY date_created DESC
LIMIT 30
`;
const { rows: orders } = await pool.query(orderSql, [tenantId, phoneSuffix]);
if (orders.length === 0) {
return { is_returning: false, last_order_at: null, frequent_items: [], preferred_address: null };
}
const orderIds = orders.map((o) => o.woo_order_id);
const itemsSql = `
SELECT woo_product_id, product_name, sell_unit,
COUNT(*) AS times,
SUM(quantity) AS total_qty,
AVG(quantity) AS avg_qty
FROM woo_order_items
WHERE tenant_id = $1 AND woo_order_id = ANY($2::bigint[]) AND woo_product_id IS NOT NULL
GROUP BY woo_product_id, product_name, sell_unit
ORDER BY times DESC, total_qty DESC
LIMIT 5
`;
const { rows: items } = await pool.query(itemsSql, [tenantId, orderIds]);
const frequent_items = items.map((it) => ({
woo_id: Number(it.woo_product_id),
name: it.product_name,
times_ordered: Number(it.times),
avg_qty: Number(it.avg_qty),
avg_unit: it.sell_unit || null,
}));
const lastOrder = orders[0];
const preferredAddress = [lastOrder.shipping_address_1, lastOrder.shipping_address_2, lastOrder.shipping_city]
.filter(Boolean)
.join(", ");
return {
is_returning: true,
last_order_at: lastOrder.date_created,
customer_name: lastOrder.customer_name || null,
frequent_items,
preferred_address: preferredAddress || null,
total_orders_last_6m: orders.length,
};
}
export function invalidateCustomerProfileCache(chat_id) {
for (const k of _cache.keys()) if (k.endsWith(`:${chat_id}`)) _cache.delete(k);
}

View File

@@ -0,0 +1,148 @@
/**
* Quantity parser determinista (es-AR).
*
* Pre-procesa el texto del usuario para extraer cantidad+unidad ANTES del LLM.
* El resultado se pasa al agente como side-channel (`working_memory.preparsed`)
* — el agente lo ve pero puede sobreescribirlo si el contexto lo amerita.
*
* Cubre los patrones AR-es más comunes:
* - Fracciones: "1/4 kg", "1/2 kilo"
* - Frases compuestas: "media docena", "cuarto kilo", "cuarto de kilo",
* "tres cuartos", "medio kilo", "par"
* - Numerales con unidad: "300 gramos", "0.5kg", "2 botellas"
* - Numerales solos: "300", "0.5" (unit=null para que el contexto decida)
*/
const NUMBER_WORDS = {
un: 1, uno: 1, una: 1,
dos: 2, tres: 3, cuatro: 4, cinco: 5,
seis: 6, siete: 7, ocho: 8, nueve: 9, diez: 10,
once: 11, doce: 12, trece: 13, catorce: 14, quince: 15,
dieciseis: 16, diecisiete: 17, dieciocho: 18, diecinueve: 19,
veinte: 20, veintidos: 22, veinticinco: 25, treinta: 30,
};
// Frases compuestas. Orden importa: las más largas primero.
const PHRASES = [
["tres cuartos de kilo", { qty: 0.75, unit: "kg" }],
["tres cuartos kilo", { qty: 0.75, unit: "kg" }],
["tres cuartos", { qty: 0.75, unit: "kg" }],
["cuarto de kilo", { qty: 0.25, unit: "kg" }],
["cuarto kilo", { qty: 0.25, unit: "kg" }],
["un cuarto", { qty: 0.25, unit: "kg" }],
["media docena", { qty: 6, unit: "unit" }],
["medio kilo", { qty: 0.5, unit: "kg" }],
["media kilo", { qty: 0.5, unit: "kg" }],
["dos kilos", { qty: 2, unit: "kg" }],
["tres kilos", { qty: 3, unit: "kg" }],
["cinco kilos", { qty: 5, unit: "kg" }],
["un kilo", { qty: 1, unit: "kg" }],
["docena", { qty: 12, unit: "unit" }],
["par", { qty: 2, unit: "unit" }],
["pareja", { qty: 2, unit: "unit" }],
];
// Fracción: respeta separación opcional con espacios. NO depende de \b.
const FRACTION = /(\d+)\s*\/\s*(\d+)/;
// Decimal con punto o coma. Capta "2.5", "0,3", "300", "0.5"
const NUMERIC = /(\d+(?:[.,]\d+)?)/;
// Unidades: pueden venir pegadas al número ("2kg") o separadas ("2 kg").
// Lookbehind opcional para dígito o non-letter; lookahead obligatorio para
// non-letter o fin de string. Probamos kilos antes que gramos para evitar
// que "kg" matchee "g" (ambos requieren post boundary, pero kg es 2 chars).
const UNIT_KG_RE = /(?:kgs?|kilos?|kilogramos?)(?![a-z])/i;
const UNIT_G_RE = /(?:^|[^a-z])(?:g|gr|grs|gramos?)(?![a-z])/i;
const UNIT_UNIT_RE = /(?:unidad(?:es)?|botellas?|frascos?|paquetes?|atados?|piezas?)(?![a-z])|(?:^|\s)u(?:\s|$)/i;
/**
* Lower + sin diacríticos. Conserva dígitos y separadores numéricos.
*/
function lowerNoDiacritics(text) {
return String(text || "")
.toLowerCase()
.normalize("NFD").replace(/[̀-ͯ]/g, "");
}
/**
* Detecta la unidad explícita en el texto. Devuelve null si no hay.
*/
export function detectUnit(text) {
const t = lowerNoDiacritics(text);
if (!t) return null;
if (UNIT_KG_RE.test(t)) return "kg";
if (UNIT_G_RE.test(t)) return "g";
if (UNIT_UNIT_RE.test(t)) return "unit";
return null;
}
function postProcess(qty, unit) {
if (!Number.isFinite(qty) || qty <= 0) return null;
return { qty, unit };
}
/**
* Extrae cantidad+unidad del texto. Devuelve `null` si no encuentra nada confiable.
* Confidence:
* - 0.95 fracción explícita
* - 0.9 frase compuesta o numérico+unit
* - 0.85 palabra + unit
* - 0.7 numérico solo
*/
export function parseQuantity(text) {
const raw = String(text || "").trim();
if (!raw) return null;
const t = lowerNoDiacritics(raw);
// 1) Fracción explícita
const fracMatch = FRACTION.exec(t);
if (fracMatch) {
const num = Number(fracMatch[1]);
const den = Number(fracMatch[2]);
if (den > 0 && Number.isFinite(num) && num > 0) {
const value = num / den;
const unit = detectUnit(t);
// Si no hay unit explícita pero menciona "kilo"/"kg" o similar, igual cae.
// detectUnit lo cubre; si t contiene "kilo" después de "1/2", lo agarra.
const result = postProcess(value, unit);
if (result) return { ...result, confidence: 0.95, source: "fraction" };
} else {
return null; // div por cero / fracción inválida
}
}
// 2) Frases compuestas (las más largas primero)
for (const [phrase, payload] of PHRASES) {
if (t.includes(phrase)) {
const explicitUnit = detectUnit(t);
// Si hay unit explícita en el texto, gana sobre la default de la frase
const finalUnit = explicitUnit || payload.unit;
return { qty: payload.qty, unit: finalUnit, confidence: 0.9, source: "phrase", phrase };
}
}
// 3) Numérico con unit
const numMatch = NUMERIC.exec(t);
if (numMatch) {
const value = parseFloat(numMatch[1].replace(",", "."));
const unit = detectUnit(t);
if (unit) {
const result = postProcess(value, unit);
if (result) return { ...result, confidence: 0.9, source: "numeric_with_unit" };
}
// Numérico solo (sin unit)
const result = postProcess(value, null);
if (result) return { ...result, confidence: 0.7, source: "numeric_alone" };
}
// 4) Palabra (ej: "dos botellas")
for (const [word, value] of Object.entries(NUMBER_WORDS)) {
const re = new RegExp(`(?:^|\\s)${word}(?:\\s|$)`);
if (re.test(t)) {
const unit = detectUnit(t);
if (unit) return { qty: value, unit, confidence: 0.85, source: "word_with_unit" };
}
}
return null;
}

View File

@@ -0,0 +1,193 @@
import { describe, it, expect } from "vitest";
import { parseQuantity, detectUnit } from "./quantityParser.js";
describe("parseQuantity — fracciones", () => {
it("'1/4 kg' → 0.25 kg", () => {
const r = parseQuantity("1/4 kg");
expect(r).toMatchObject({ qty: 0.25, unit: "kg", source: "fraction" });
});
it("'1/2 kilo' → 0.5 kg", () => {
const r = parseQuantity("1/2 kilo");
expect(r).toMatchObject({ qty: 0.5, unit: "kg" });
});
it("'3/4 kilos de matambre' → 0.75 kg", () => {
const r = parseQuantity("3/4 kilos de matambre");
expect(r).toMatchObject({ qty: 0.75, unit: "kg" });
});
it("'1/2' sin unidad → 0.5 sin unit", () => {
const r = parseQuantity("1/2");
expect(r).toMatchObject({ qty: 0.5, unit: null });
});
it("'1/0' division por cero → null", () => {
expect(parseQuantity("1/0")).toBeNull();
});
});
describe("parseQuantity — frases compuestas", () => {
it("'media docena' → 6 unit", () => {
const r = parseQuantity("media docena");
expect(r).toMatchObject({ qty: 6, unit: "unit", source: "phrase" });
});
it("'media docena de chorizos' → 6 unit", () => {
const r = parseQuantity("media docena de chorizos");
expect(r).toMatchObject({ qty: 6, unit: "unit" });
});
it("'cuarto de kilo' → 0.25 kg", () => {
const r = parseQuantity("cuarto de kilo");
expect(r).toMatchObject({ qty: 0.25, unit: "kg" });
});
it("'cuarto kilo' → 0.25 kg", () => {
const r = parseQuantity("cuarto kilo");
expect(r).toMatchObject({ qty: 0.25, unit: "kg" });
});
it("'tres cuartos' → 0.75 kg", () => {
const r = parseQuantity("tres cuartos");
expect(r).toMatchObject({ qty: 0.75, unit: "kg" });
});
it("'tres cuartos de kilo' → 0.75 kg", () => {
const r = parseQuantity("tres cuartos de kilo");
expect(r).toMatchObject({ qty: 0.75, unit: "kg" });
});
it("'medio kilo' → 0.5 kg", () => {
const r = parseQuantity("medio kilo");
expect(r).toMatchObject({ qty: 0.5, unit: "kg" });
});
it("'media kilo' → 0.5 kg (typo común)", () => {
const r = parseQuantity("media kilo");
expect(r).toMatchObject({ qty: 0.5, unit: "kg" });
});
it("'docena' → 12 unit", () => {
expect(parseQuantity("una docena")).toMatchObject({ qty: 12, unit: "unit" });
});
it("'par' → 2 unit", () => {
expect(parseQuantity("un par de chorizos")).toMatchObject({ qty: 2, unit: "unit" });
});
});
describe("parseQuantity — numéricos con unidad", () => {
it("'300 gramos' → 300 g", () => {
expect(parseQuantity("300 gramos")).toMatchObject({ qty: 300, unit: "g" });
});
it("'500g' → 500 g", () => {
expect(parseQuantity("500g")).toMatchObject({ qty: 500, unit: "g" });
});
it("'2kg' → 2 kg", () => {
expect(parseQuantity("2kg")).toMatchObject({ qty: 2, unit: "kg" });
});
it("'2.5 kilos' → 2.5 kg", () => {
expect(parseQuantity("2.5 kilos")).toMatchObject({ qty: 2.5, unit: "kg" });
});
it("'2,5 kilos' (coma decimal) → 2.5 kg", () => {
expect(parseQuantity("2,5 kilos")).toMatchObject({ qty: 2.5, unit: "kg" });
});
it("'0.5kg' → 0.5 kg", () => {
expect(parseQuantity("0.5kg")).toMatchObject({ qty: 0.5, unit: "kg" });
});
it("'3 botellas' → 3 unit", () => {
expect(parseQuantity("3 botellas")).toMatchObject({ qty: 3, unit: "unit" });
});
it("'2 unidades' → 2 unit", () => {
expect(parseQuantity("2 unidades")).toMatchObject({ qty: 2, unit: "unit" });
});
it("'1 atado' → 1 unit", () => {
expect(parseQuantity("1 atado")).toMatchObject({ qty: 1, unit: "unit" });
});
});
describe("parseQuantity — numéricos solos", () => {
it("'300' → 300 (sin unit)", () => {
expect(parseQuantity("300")).toMatchObject({ qty: 300, unit: null });
});
it("'2.5' → 2.5 (sin unit)", () => {
expect(parseQuantity("2.5")).toMatchObject({ qty: 2.5, unit: null });
});
});
describe("parseQuantity — palabras + unidad", () => {
it("'dos botellas' → 2 unit", () => {
expect(parseQuantity("dos botellas")).toMatchObject({ qty: 2, unit: "unit" });
});
it("'tres kilos' → 3 kg (frase compuesta)", () => {
expect(parseQuantity("tres kilos")).toMatchObject({ qty: 3, unit: "kg" });
});
});
describe("parseQuantity — casos negativos", () => {
it("texto sin números retorna null", () => {
expect(parseQuantity("hola que tal")).toBeNull();
});
it("string vacío retorna null", () => {
expect(parseQuantity("")).toBeNull();
});
it("null/undefined retorna null", () => {
expect(parseQuantity(null)).toBeNull();
expect(parseQuantity(undefined)).toBeNull();
});
it("cantidad cero retorna null", () => {
expect(parseQuantity("0 kg")).toBeNull();
});
});
describe("detectUnit", () => {
it("detecta kg/kilo/kilos", () => {
expect(detectUnit("2 kg")).toBe("kg");
expect(detectUnit("dos kilos")).toBe("kg");
expect(detectUnit("medio kilogramo")).toBe("kg");
});
it("detecta g/gr/gramos", () => {
expect(detectUnit("300 g")).toBe("g");
expect(detectUnit("500 gr")).toBe("g");
expect(detectUnit("100 gramos")).toBe("g");
});
it("detecta unidades múltiples", () => {
expect(detectUnit("3 unidades")).toBe("unit");
expect(detectUnit("una botella")).toBe("unit");
expect(detectUnit("2 frascos")).toBe("unit");
expect(detectUnit("1 atado")).toBe("unit");
});
it("retorna null sin unidad explícita", () => {
expect(detectUnit("dame 3")).toBeNull();
});
});
describe("parseQuantity — confidence", () => {
it("fraction: confidence 0.95", () => {
expect(parseQuantity("1/4 kg").confidence).toBe(0.95);
});
it("phrase: confidence 0.9", () => {
expect(parseQuantity("media docena").confidence).toBe(0.9);
});
it("numeric_with_unit: confidence 0.9", () => {
expect(parseQuantity("300 gramos").confidence).toBe(0.9);
});
it("numeric_alone: confidence 0.7", () => {
expect(parseQuantity("300").confidence).toBe(0.7);
});
});
describe("parseQuantity — casos de WhatsApp real", () => {
it("'dame 1/4 de matambre' → 0.25 sin unit (el contexto resuelve)", () => {
// sin "kg"/"kilo" explícito, el parser deja unit=null. El agente
// infiere "kg" porque matambre vende por peso.
expect(parseQuantity("dame 1/4 de matambre")).toMatchObject({ qty: 0.25, unit: null });
});
it("'media docena de chorizos por favor' → 6 unit", () => {
expect(parseQuantity("media docena de chorizos por favor")).toMatchObject({ qty: 6, unit: "unit" });
});
it("'2.5kg de asado' → 2.5 kg", () => {
expect(parseQuantity("2.5kg de asado")).toMatchObject({ qty: 2.5, unit: "kg" });
});
it("'cuarto kilo de fuet' → 0.25 kg", () => {
expect(parseQuantity("cuarto kilo de fuet")).toMatchObject({ qty: 0.25, unit: "kg" });
});
it("'un kilo y medio' → ambiguo, por ahora cae a phrase 'un kilo' → 1 kg", () => {
// limitación conocida — el LLM puede sobreescribir
const r = parseQuantity("un kilo y medio");
expect(r.qty).toBe(1);
});
it("'mandame 3 chorizos' → 3 unit (vía word + unit detection)", () => {
const r = parseQuantity("mandame 3 chorizos");
// chorizos no es una unit reconocida, así que queda numeric_alone
expect(r.qty).toBe(3);
});
});

View File

@@ -0,0 +1,377 @@
/**
* runTurn — Punto de entrada del agente tool-calling.
*
* Reemplaza turnEngineV3 cuando AGENT_TURN_ENGINE=1.
* Mantiene la firma compatible con pipeline.js:
* runTurnAgent({ tenantId, chat_id, text, prev_state, prev_context, conversation_history })
* → { plan, decision }
*/
import OpenAI from "openai";
import { migrateOldContext, createEmptyOrder } from "../orderModel.js";
import { getStoreConfig } from "../../0-ui/db/settingsRepo.js";
import { ConversationState, safeNextState } from "../fsm.js";
import { buildWorkingMemory } from "./workingMemory.js";
import { SYSTEM_PROMPT } from "./systemPrompt.js";
import { TOOL_SCHEMAS } from "./tools/schemas.js";
import { executeToolCall } from "./tools/executor.js";
import { getCustomerProfile } from "./customerProfile.js";
import { debug as dbg } from "../../shared/debug.js";
const MAX_TOOL_CALLS = parseInt(process.env.AGENT_MAX_TOOL_CALLS || "10", 10);
const TURN_TIMEOUT_MS = parseInt(process.env.AGENT_TURN_TIMEOUT_MS || "20000", 10);
// Métricas in-memory: turns/calls, fallback rate, escalation rate, avg duration,
// prompt-cache hit ratio (DeepSeek devuelve prompt_cache_hit_tokens / prompt_cache_miss_tokens
// en usage; campos OpenAI-style equivalentes "prompt_tokens_details.cached_tokens").
const _metrics = {
turns: 0,
total_tool_calls: 0,
total_llm_calls: 0,
total_duration_ms: 0,
fallback_used: 0,
llm_errors: 0,
escalations: 0,
pauses: 0,
orders_confirmed: 0,
prompt_tokens_total: 0,
prompt_cache_hit_tokens: 0,
completion_tokens_total: 0,
};
export function getAgentMetrics() {
const t = _metrics.turns;
const promptTotal = _metrics.prompt_tokens_total;
return {
turns: t,
avg_tool_calls_per_turn: t ? +(_metrics.total_tool_calls / t).toFixed(2) : 0,
avg_llm_calls_per_turn: t ? +(_metrics.total_llm_calls / t).toFixed(2) : 0,
avg_duration_ms: t ? Math.round(_metrics.total_duration_ms / t) : 0,
fallback_rate: t ? +(_metrics.fallback_used / t).toFixed(3) : 0,
error_rate: t ? +(_metrics.llm_errors / t).toFixed(3) : 0,
escalations: _metrics.escalations,
pauses: _metrics.pauses,
orders_confirmed: _metrics.orders_confirmed,
prompt_tokens_total: promptTotal,
completion_tokens_total: _metrics.completion_tokens_total,
prompt_cache_hit_tokens: _metrics.prompt_cache_hit_tokens,
cache_hit_ratio: promptTotal ? +(_metrics.prompt_cache_hit_tokens / promptTotal).toFixed(3) : 0,
};
}
export function resetAgentMetrics() {
for (const k of Object.keys(_metrics)) _metrics[k] = 0;
}
let _client = null;
function getClient() {
if (_client) return _client;
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) throw new Error("OPENAI_API_KEY not set");
const baseURL = process.env.OPENAI_BASE_URL || undefined;
_client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) });
return _client;
}
function getModel() {
return process.env.OPENAI_MODEL || "deepseek-chat";
}
function withTimeout(promise, ms, label) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`${label}_timeout_${ms}ms`)), ms)
),
]);
}
/**
* Punto de entrada principal. Mismo signature que runTurnV3.
*/
export async function runTurnAgent({
tenantId,
chat_id,
text,
prev_state,
prev_context,
conversation_history,
}) {
const t0 = Date.now();
const audit = {
trace: { tenantId, chat_id, text_preview: String(text || "").slice(0, 50), prev_state, engine: "agent" },
tool_calls: [],
llm_calls: 0,
};
// Cargar order, store, last_shown_options, customer_profile
const order = migrateOldContext(prev_context);
const storeConfig = await getStoreConfig({ tenantId });
const lastShownOptions = Array.isArray(prev_context?.last_shown_options)
? prev_context.last_shown_options
: [];
const customerProfile = await getCustomerProfile({ tenantId, chat_id }).catch((err) => {
audit.customer_profile_error = String(err?.message || err);
return null;
});
// last_delivery del cliente: snapshot de la última orden confirmada
// (dirección + zona + day/time). El bot puede ofrecérsela proactivamente
// o el cliente puede pedir "lo mismo de la última vez".
const lastDelivery = (prev_context && typeof prev_context === "object" && prev_context.last_delivery) || null;
// Construir working memory
const wm = buildWorkingMemory({
text,
order,
prev_state: prev_state || "IDLE",
conversation_history,
storeConfig,
customerProfile,
lastShownOptions,
lastDelivery,
});
// Estado mutable que los tools mutan
const ctx = {
tenantId,
chat_id,
order: { ...order, last_shown_options: lastShownOptions },
pending_actions: [],
last_shown_options: [...lastShownOptions],
storeConfig,
last_delivery: lastDelivery,
say_text: null,
paused: false,
paused_until: order.paused_until ?? null,
awaiting_human: false,
awaiting_human_reason: null,
fsm_state: prev_state || "IDLE",
};
// Mensajes para el LLM. system prompt PRIMERO siempre + estático
// (clave para hit del prompt cache de DeepSeek/OpenAI).
const messages = [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: JSON.stringify({ working_memory: wm }) },
];
// Loop tool-calling
const client = getClient();
const model = getModel();
let turnDone = false;
let llmError = null;
try {
for (let i = 0; i < MAX_TOOL_CALLS && !turnDone; i++) {
audit.llm_calls++;
const elapsed = Date.now() - t0;
const remaining = Math.max(2000, TURN_TIMEOUT_MS - elapsed);
if (dbg.llm) console.log("[agent] llm.request", { model, iteration: i, remaining_ms: remaining });
const resp = await withTimeout(
client.chat.completions.create({
model,
temperature: 0.4,
max_tokens: 600,
tools: TOOL_SCHEMAS,
tool_choice: "required",
messages,
}),
remaining,
"agent_llm"
);
// Capturar usage para métricas de prompt caching.
// DeepSeek: usage.prompt_cache_hit_tokens / prompt_cache_miss_tokens.
// OpenAI compat: usage.prompt_tokens_details.cached_tokens.
const usage = resp?.usage || {};
const promptTokens = usage.prompt_tokens || 0;
const completionTokens = usage.completion_tokens || 0;
const cachedTokens =
usage.prompt_cache_hit_tokens ||
usage.prompt_tokens_details?.cached_tokens ||
0;
_metrics.prompt_tokens_total += promptTokens;
_metrics.completion_tokens_total += completionTokens;
_metrics.prompt_cache_hit_tokens += cachedTokens;
if (dbg.llm) {
console.log("[agent] llm.usage", {
prompt_tokens: promptTokens,
cached_tokens: cachedTokens,
completion_tokens: completionTokens,
cache_hit_ratio: promptTokens ? +(cachedTokens / promptTokens).toFixed(2) : 0,
});
}
const msg = resp?.choices?.[0]?.message || {};
messages.push({ role: "assistant", content: msg.content || "", tool_calls: msg.tool_calls || [] });
const calls = msg.tool_calls || [];
if (!calls.length) {
// Sin tool calls → forzar say con fallback y salir
audit.no_tool_calls = true;
ctx.say_text = ctx.say_text || msg.content || "Disculpame, no te entendí. ¿Me lo decís de otra forma?";
turnDone = true;
break;
}
for (const call of calls) {
const obs = await executeToolCall(call, ctx);
audit.tool_calls.push({
name: call.function?.name,
ok: obs.ok !== false,
error: obs.error || null,
duration_ms: obs.duration_ms || null,
});
messages.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify(obs),
});
if (obs.terminal) {
turnDone = true;
}
}
}
} catch (err) {
llmError = String(err?.message || err);
audit.llm_error = llmError;
if (dbg.llm) console.error("[agent] error", llmError);
}
// Si no hay say, fallback determinista
if (!ctx.say_text) {
ctx.say_text = pickFallbackReply(ctx, llmError);
audit.fallback_used = true;
}
audit.duration_ms = Date.now() - t0;
// Actualizar métricas
_metrics.turns++;
_metrics.total_tool_calls += audit.tool_calls.length;
_metrics.total_llm_calls += audit.llm_calls;
_metrics.total_duration_ms += audit.duration_ms;
if (audit.fallback_used) _metrics.fallback_used++;
if (audit.llm_error) _metrics.llm_errors++;
if (ctx.awaiting_human) _metrics.escalations++;
if (ctx.paused) _metrics.pauses++;
if (ctx.pending_actions.some((a) => a.type === "create_order")) _metrics.orders_confirmed++;
// Derivar nextState desde el order resultante
const signals = {
confirm_order: ctx.pending_actions.some((a) => a.type === "create_order"),
shipping_completed: ctx.pending_actions.some((a) => a.type === "create_order"),
return_to_cart: false,
};
// Si el agente pausó la conversación, mantenemos el order pero el next_state
// queda guardado en ctx.fsm_state ("PAUSED") para que pipeline lo persista.
let nextState;
if (ctx.awaiting_human) {
nextState = ConversationState.AWAITING_HUMAN;
} else if (ctx.paused) {
nextState = "PAUSED"; // estado nuevo, fsm.js lo va a permitir tras D7
} else {
nextState = safeNextState(prev_state, ctx.order, signals).next_state;
}
return {
plan: {
reply: ctx.say_text,
next_state: nextState,
intent: detectIntent(audit.tool_calls),
missing_fields: [],
order_action: ctx.pending_actions[0]?.type || "none",
basket_resolved: { items: (ctx.order.cart || []).map(toBasketItem) },
},
decision: {
actions: ctx.pending_actions,
context_patch: buildContextPatch(ctx),
audit,
},
};
}
function pickFallbackReply(ctx, err) {
if (ctx.awaiting_human) return "Te paso con un humano que pueda ayudarte.";
if (ctx.paused) return "Dale, cuando quieras seguimos.";
if (err) return "Disculpame, tuve un problema. ¿Lo intentás de nuevo?";
return "No te seguí, ¿me lo decís de otra forma?";
}
function detectIntent(toolCalls = []) {
const names = toolCalls.map((c) => c.name);
if (names.includes("confirm_order")) return "confirm_order";
if (names.includes("set_address") || names.includes("set_shipping")) return "select_shipping";
if (names.includes("escalate_to_human")) return "escalate";
if (names.includes("pause")) return "pause";
if (names.includes("add_to_cart") || names.includes("set_quantity") || names.includes("select_candidate")) return "add_to_cart";
if (names.includes("remove_from_cart")) return "remove_from_cart";
if (names.includes("search_catalog")) return "browse";
return "other";
}
function toBasketItem(item) {
return {
product_id: item.woo_id,
woo_product_id: item.woo_id,
quantity: item.qty,
unit: item.unit,
label: item.name,
name: item.name,
price: item.price,
};
}
function buildContextPatch(ctx) {
const order = ctx.order || createEmptyOrder();
// Si hay create_order encolado y la orden está completa, snapshot del
// "último envío" para reusar en próximas conversaciones.
let last_delivery = ctx.last_delivery || null;
const isClosing = ctx.pending_actions.some((a) => a.type === "create_order");
if (isClosing && (order.shipping_address || order.matched_zone || order.is_delivery === false)) {
last_delivery = {
is_delivery: !!order.is_delivery,
shipping_address: order.shipping_address || null,
matched_zone: order.matched_zone || null,
pending_location: order.pending_location || null,
delivery_window: order.delivery_window || null,
saved_at: new Date().toISOString(),
};
}
return {
// Persist the full order object so pending_location/matched_zone/delivery_window
// sobrevivan turno a turno (migrateOldContext devuelve ctx.order tal cual).
order,
last_delivery,
order_basket: { items: (order.cart || []).map(toBasketItem) },
pending_items: (order.pending || []).map((p) => ({
id: p.id,
query: p.query,
candidates: p.candidates,
resolved_product: p.selected_woo_id
? {
woo_product_id: p.selected_woo_id,
name: p.selected_name,
price: p.selected_price,
display_unit: p.selected_unit,
}
: null,
quantity: p.qty,
unit: p.unit,
status: p.status?.toLowerCase() || "needs_type",
})),
shipping_method:
order.is_delivery === true ? "delivery" : order.is_delivery === false ? "pickup" : null,
delivery_address: order.shipping_address ? { text: order.shipping_address } : null,
woo_order_id: order.woo_order_id,
last_shown_options: ctx.last_shown_options || [],
paused_until: ctx.paused_until || null,
awaiting_human: ctx.awaiting_human || false,
awaiting_human_reason: ctx.awaiting_human_reason || null,
};
}

View File

@@ -0,0 +1,117 @@
/**
* System prompt del agente conversacional.
*
* 100% estático para que DeepSeek (y cualquier proveedor con prefix-cache)
* lo cachee turn a turn. La parte dinámica — incluyendo el nombre de la
* tienda — va SIEMPRE en el primer user message vía working_memory.store.name.
*
* Reglas para mantener el cache caliente:
* - NO interpolar variables aquí. Si querés cambiar tono por tenant,
* hacelo agregando una sección al user message, no al system.
* - Cualquier cambio al texto invalida el cache para todos los turnos.
*/
export const SYSTEM_PROMPT = `Sos Botino, el empleado virtual de la carnicería que te contrata.
Hablás como vendedor argentino: cálido, breve, "vos", sin emojis, sin marketing.
TU TRABAJO ES UNO SOLO: tomar pedidos por WhatsApp.
1. Entendés lo que pide el cliente y lo anotás en el carrito.
2. Coordinás envío (delivery o retiro) y dirección si corresponde.
3. Cerrás el pedido. NO cobrás — el pago lo coordina el comercio aparte.
REGLAS DURAS:
- NUNCA inventes productos ni precios. Para CUALQUIER producto que el cliente
mencione, llamá primero a search_catalog. Si la lista viene vacía, decilo.
- NUNCA pidas datos que ya están en el contexto (cart, dirección, método).
- NO ofrezcas promociones, métodos de pago, ni info que no esté en store.
- Si el cliente mezcla pedido + duda + cambio de tema, resolvé con tools y
aclarás lo que falte en say.
- Si te pide algo fuera de tomar pedidos (queja, cambio, factura, otro idioma),
llamá escalate_to_human.
CÓMO PROCESAS UN MENSAJE (user message viene como JSON con working_memory):
- Releé order.cart, order.pending, last_shown_options, fsm_state, paused_until,
order.pending_location, order.matched_zone, order.delivery_window.
- preparsed: tiene cantidades parseadas (ej: "media docena" → 6 unit; "1/4 kg"
→ 0.25 kg). Confiá en eso si su confidence ≥ 0.85.
- Si user dice "el segundo", "ese", "el primero", "el de arriba", resolvé
contra last_shown_options. Si está vacío, pedí que aclare.
- Si user da SOLO una cantidad ("300 gramos", "media docena") y hay un pending
con status=NEEDS_QUANTITY, asumí que es para ese producto y llamá set_quantity.
- Si user dice "lo de siempre" / "lo mismo de la otra vez", mirá
customer_profile.frequent_items. Si hay 1-2 items, ofrecelos con
search_catalog para confirmar y un say preguntando si va eso.
- Si menciona producto genérico ("asado") y el catálogo tiene varios,
mostrá top 3-5 con say (numerados). El sistema guarda last_shown_options
automáticamente.
- Si dice "después te digo" / "más tarde" / "ahora no puedo", llamá pause
con reason="user_paused" y un say corto tipo "Dale, cuando quieras seguimos".
CÓMO ESCRIBÍS EL say:
- 1-2 oraciones máximo. Concreto. Sin emojis.
- Confirmá lo anotado con cantidad y producto: "Va, anoté 500g de Vacío.
¿Algo más?"
- Cuando preguntás cantidad, mencioná el producto: "¿Cuántas botellas de
Chimichurri querés?" — NUNCA "¿cuántas?" pelado.
- Si no encontraste el producto, sugerí 1-2 alternativas concretas que
vinieron del catálogo: "No tengo Chinchulín, pero tengo Mollejas y
Riñones. ¿Te sirve alguno?"
- Cuando cerrás el pedido, listá items y pasás a shipping.
ORDEN DE TOOLS EN UN TURNO TÍPICO:
1. (opcional) search_catalog si hay producto sin resolver. Llamala UNA VEZ por
producto distinto. NO la llames de nuevo con la misma query si ya devolvió
resultados — usá los que tenés.
2. Si search_catalog devolvió 1 candidato fuerte → add_to_cart con qty/unit.
3. Si devolvió varios candidatos → NO sigas buscando: usá say para pedir que
elija entre los top 3-5 (numerados). El sistema guarda last_shown_options.
4. (opcional) add_to_cart / set_quantity / select_candidate / set_shipping /
set_address / set_delivery_window / confirm_order / remove_from_cart /
pause / escalate_to_human.
5. say SIEMPRE como último tool del turno. Sin say no hay respuesta.
ENVÍO Y ZONAS:
- Si store.delivery.requires_location_share es true y el cliente eligió delivery,
NUNCA confirmes zona ni costo a partir de la dirección textual. Necesitamos
la ubicación compartida (pin/location share) por WhatsApp.
- Cuando el cliente pide envío: llamá set_shipping(method="delivery"). Si la
respuesta tiene requires_location=true, decile en say: "Para confirmar zona y
costo necesito que me mandes tu ubicación por WhatsApp (pin/location share)".
- Cuando llegue la ubicación, working_memory.order.pending_location va a tener
lat/lng. Llamá set_address con el texto de la calle (la calle/numero/depto que
haya dado el cliente, o algo descriptivo si solo mandó pin). Si match → te
devuelve matched_zone con costo/días/horas. Comunicá eso y pedí día y hora.
- Si set_address devuelve out_of_zones, ofrecé pickup o pedile otra ubicación.
- Cuando el cliente confirma día y hora, llamá set_delivery_window(day, time?).
Días: lun/mar/mie/jue/vie/sab/dom. Hora opcional (HH:MM 24h). Confirmá lo
registrado en say antes de llamar a confirm_order.
- confirm_order valida que el día/hora caigan en los días/horas de la zona
(delivery) o en el schedule.pickup (pickup). Si devuelve day_not_available o
time_out_of_range, ofrecé las opciones que sí están disponibles según la zona
o el schedule.
CLIENTES QUE VUELVEN (last_delivery):
- Si working_memory.last_delivery existe (no null), el cliente ya hizo un pedido
antes y tenemos guardado: shipping_address, matched_zone, delivery_window.
- Cuando el cliente está armando un pedido nuevo y todavía no eligió método de
envío ni dio dirección, ofrecele proactivamente la opción de repetir:
"¿Te lo mandamos al mismo lugar que la última vez (Av. Corrientes 1234,
zona Centro, $1.500)? También podés pedirme otra dirección o retiro."
- Si confirma ("sí", "dale", "el mismo lugar", "como siempre"), llamá
reuse_last_delivery — copia dirección + zona y se salta el pedido del pin.
Después confirmá el día/horario (puede ser distinto al de la última).
- Si dice que no, o pide otro lugar, seguí el flujo normal (set_shipping →
pedir pin → set_address → set_delivery_window).
- last_delivery.delivery_window es sólo referencia, NO lo asumas como elegido
para esta orden. Preguntá día/hora aunque vayas a reutilizar el lugar.
LIMITES TÉCNICOS:
- Tenés un máximo de 10 tool calls por turno. No los gastes en búsquedas
redundantes — si una query ya devolvió X candidatos, NO la repitas.
- Si después de 2 búsquedas no encontrás nada útil, llamá say pidiendo que el
cliente reformule ("no te encontré X, ¿lo decís de otra forma?").
LIMITES OPERATIVOS DEL COMERCIO (NO LOS NEGOCIES):
- Lo que diga el bloque store del working_memory.
- Si el cliente pide algo fuera de eso, decí qué es lo que sí podemos.`;

View File

@@ -0,0 +1,66 @@
/**
* add_to_cart — agrega un producto resuelto al carrito.
*
* Valida woo_id contra el snapshot (anti-halucinación). Si no existe,
* devuelve error obligando re-search.
*/
import { getSnapshotItemsByIds } from "../../../shared/wooSnapshot.js";
import { createCartItem } from "../../orderModel.js";
export async function addToCartTool(args, ctx) {
const { woo_id, qty, unit } = args;
// Validar que el producto existe en el snapshot
const lookup = await getSnapshotItemsByIds({
tenantId: ctx.tenantId,
wooProductIds: [woo_id],
});
const found = (lookup?.items || [])[0];
if (!found) {
return {
ok: false,
error: "woo_id_unknown",
hint: "Volvé a llamar search_catalog para obtener un woo_id válido.",
};
}
// Crear item de carrito
const newItem = createCartItem({
woo_id,
qty,
unit,
name: found.name,
price: found.price ?? null,
});
// Si ya existe el woo_id en el cart, sumamos cantidad
const cart = ctx.order.cart || [];
const existingIdx = cart.findIndex((c) => Number(c.woo_id) === Number(woo_id));
let nextCart;
if (existingIdx >= 0) {
nextCart = cart.map((c, i) =>
i === existingIdx ? { ...c, qty: (c.qty || 0) + qty, unit: c.unit || unit } : c
);
} else {
nextCart = [...cart, newItem];
}
ctx.order = { ...ctx.order, cart: nextCart };
// Enqueue add_to_cart action para SSE/UI (reutiliza shape existente)
ctx.pending_actions.push({ type: "add_to_cart", payload: newItem });
// Reset failed_searches si hubiera
if (ctx.order.failed_searches) {
ctx.order = { ...ctx.order, failed_searches: { count: 0 } };
}
// Limpiar last_shown_options — ya resolvió la elección
ctx.last_shown_options = [];
return {
ok: true,
cart_size: nextCart.length,
added: { woo_id, name: found.name, qty, unit },
};
}

View File

@@ -0,0 +1,134 @@
/**
* confirm_order — emite create_order si hay cart + shipping completo.
*
* Validaciones extra (cuando hay schedule/zonas configuradas):
* - delivery: día y hora del delivery_window deben caer en zone.delivery_days
* y zone.delivery_hours.
* - pickup: ídem contra schedule.pickup[day].
*
* Si no hay delivery_window seteado, confirmamos igual y dejamos que el
* comercio coordine día/hora aparte.
*/
import { hasCartItems, hasShippingInfo } from "../../fsm.js";
function isHHMMInRange(time, start, end) {
if (!time || !start || !end) return true;
const t = String(time).slice(0, 5);
const a = String(start).slice(0, 5);
const b = String(end).slice(0, 5);
return t >= a && t <= b;
}
function isDayInList(day, list) {
if (!Array.isArray(list) || !list.length) return true;
return list.includes(day);
}
function formatPickupDays(schedule) {
if (!schedule || typeof schedule !== "object") return "";
const entries = Object.entries(schedule).filter(
([, v]) => v && v.enabled !== false && v.start && v.end
);
if (!entries.length) return "";
return entries.map(([k, v]) => `${k} ${v.start.slice(0, 5)}-${v.end.slice(0, 5)}`).join(", ");
}
export async function confirmOrderTool(_args, ctx) {
if (!hasCartItems(ctx.order)) {
return {
ok: false,
error: "empty_cart",
hint: "Pedile al cliente que agregue productos antes de confirmar.",
};
}
if (!hasShippingInfo(ctx.order)) {
return {
ok: false,
error: "shipping_missing",
hint:
ctx.order.is_delivery == null
? "Falta saber si es delivery o pickup. Llamá set_shipping."
: "Falta dirección. Llamá set_address.",
};
}
const win = ctx.order.delivery_window || null;
if (ctx.order.is_delivery) {
const z = ctx.order.matched_zone;
// Si hay zonas configuradas pero no se matcheó zona aún, bloquear.
const zonesConfigured = (ctx.storeConfig?.delivery_zones?.zones || []).some(
(zo) => zo?.enabled !== false
);
if (zonesConfigured && !z) {
return {
ok: false,
error: "zone_unverified",
hint:
"Falta verificar zona. Pedile la ubicación por WhatsApp y llamá set_address antes de confirmar.",
};
}
if (z && win) {
if (!isDayInList(win.day, z.delivery_days)) {
return {
ok: false,
error: "day_not_available",
hint: `La zona ${z.name} entrega ${(z.delivery_days || []).join("/")}. Pedile otro día.`,
allowed_days: z.delivery_days || [],
};
}
if (z.delivery_hours && !isHHMMInRange(win.time, z.delivery_hours.start, z.delivery_hours.end)) {
return {
ok: false,
error: "time_out_of_range",
hint: `La zona ${z.name} entrega entre ${z.delivery_hours.start.slice(0, 5)} y ${z.delivery_hours.end.slice(0, 5)}. Pedile otro horario.`,
allowed_range: z.delivery_hours,
};
}
}
} else {
// Pickup
const schedule = ctx.storeConfig?.schedule?.pickup || null;
if (schedule && win) {
const slot = schedule[win.day];
if (!slot || slot.enabled === false || !slot.start || !slot.end) {
return {
ok: false,
error: "day_not_available_pickup",
hint: `Ese día la tienda no abre. ${formatPickupDays(schedule)}.`,
};
}
if (win.time && !isHHMMInRange(win.time, slot.start, slot.end)) {
return {
ok: false,
error: "time_out_of_range_pickup",
hint: `Ese día abrimos ${slot.start.slice(0, 5)}-${slot.end.slice(0, 5)}. Pedile otro horario.`,
};
}
}
}
// Idempotencia: si ya existe create_order encolado, no duplicar
const already = ctx.pending_actions.some((a) => a.type === "create_order");
if (!already) {
ctx.pending_actions.push({
type: "create_order",
payload: {
source: "wa_bot",
delivery_window: win,
matched_zone: ctx.order.matched_zone || null,
},
});
}
return {
ok: true,
cart_size: (ctx.order.cart || []).length,
is_delivery: !!ctx.order.is_delivery,
address: ctx.order.shipping_address || null,
delivery_window: win,
matched_zone: ctx.order.matched_zone || null,
};
}

View File

@@ -0,0 +1,16 @@
/**
* escalate_to_human — pasa la conversación a awaiting_human.
* El registro real en human_takeovers se crea downstream en pipeline.js
* vía la action "request_human_takeover".
*/
export async function escalateToHumanTool(args, ctx) {
const { reason } = args;
ctx.awaiting_human = true;
ctx.awaiting_human_reason = String(reason || "unspecified");
ctx.pending_actions.push({
type: "request_human_takeover",
payload: { reason: ctx.awaiting_human_reason, source: "agent" },
});
return { ok: true, reason: ctx.awaiting_human_reason };
}

View File

@@ -0,0 +1,106 @@
/**
* Executor: parsea + valida + ejecuta tool calls del agente.
* Devuelve `obs` (objeto serializable) que se pushea como `tool` message.
*
* Convenciones:
* - obs.ok: true|false
* - obs.error: string si !ok
* - obs.terminal: true si esta tool finaliza el turno (say, pause, escalate)
*/
import Ajv from "ajv";
import { TOOL_SCHEMAS } from "./schemas.js";
import { searchCatalogTool } from "./searchCatalog.js";
import { addToCartTool } from "./addToCart.js";
import { setQuantityTool } from "./setQuantity.js";
import { selectCandidateTool } from "./selectCandidate.js";
import { removeFromCartTool } from "./removeFromCart.js";
import { setShippingTool } from "./setShipping.js";
import { setAddressTool } from "./setAddress.js";
import { setDeliveryWindowTool } from "./setDeliveryWindow.js";
import { reuseLastDeliveryTool } from "./reuseLastDelivery.js";
import { confirmOrderTool } from "./confirmOrder.js";
import { pauseTool } from "./pause.js";
import { escalateToHumanTool } from "./escalateToHuman.js";
const ajv = new Ajv({ allErrors: true, strict: false });
// Compilar validators una vez
const VALIDATORS = {};
for (const t of TOOL_SCHEMAS) {
VALIDATORS[t.function.name] = ajv.compile(t.function.parameters);
}
const TOOLS = {
search_catalog: searchCatalogTool,
add_to_cart: addToCartTool,
set_quantity: setQuantityTool,
select_candidate: selectCandidateTool,
remove_from_cart: removeFromCartTool,
set_shipping: setShippingTool,
set_address: setAddressTool,
set_delivery_window: setDeliveryWindowTool,
reuse_last_delivery: reuseLastDeliveryTool,
confirm_order: confirmOrderTool,
pause: pauseTool,
escalate_to_human: escalateToHumanTool,
// `say` se maneja inline (asigna ctx.say_text y termina el turno)
};
export async function executeToolCall(call, ctx) {
const t0 = Date.now();
const name = call?.function?.name;
const argsRaw = call?.function?.arguments || "{}";
let args;
try {
args = typeof argsRaw === "string" ? JSON.parse(argsRaw) : argsRaw;
} catch (e) {
return {
ok: false,
error: `invalid_json_args: ${String(e?.message || e)}`,
duration_ms: Date.now() - t0,
};
}
// `say` es especial: termina el turno
if (name === "say") {
const validator = VALIDATORS.say;
if (!validator(args)) {
return { ok: false, error: "say_args_invalid", details: validator.errors, duration_ms: Date.now() - t0 };
}
ctx.say_text = args.text;
return { ok: true, terminal: true, duration_ms: Date.now() - t0 };
}
const validator = VALIDATORS[name];
if (!validator) {
return { ok: false, error: `unknown_tool: ${name}`, duration_ms: Date.now() - t0 };
}
if (!validator(args)) {
return {
ok: false,
error: "args_schema_invalid",
details: validator.errors,
tool: name,
duration_ms: Date.now() - t0,
};
}
const handler = TOOLS[name];
if (!handler) {
return { ok: false, error: `tool_not_implemented: ${name}`, duration_ms: Date.now() - t0 };
}
try {
const result = await handler(args, ctx);
return { ...result, duration_ms: Date.now() - t0 };
} catch (err) {
return {
ok: false,
error: `tool_error: ${String(err?.message || err)}`,
tool: name,
duration_ms: Date.now() - t0,
};
}
}

View File

@@ -0,0 +1,14 @@
/**
* pause — marca la conversación como pausada (TTL 7d).
*
* El cart NO se limpia. Cuando el cliente vuelva, sale de paused y sigue.
*/
const PAUSE_TTL_MS = 7 * 24 * 3600 * 1000;
export async function pauseTool(args, ctx) {
const { reason = "user_paused" } = args;
ctx.paused = true;
ctx.paused_until = new Date(Date.now() + PAUSE_TTL_MS).toISOString();
return { ok: true, paused_until: ctx.paused_until, reason, terminal: false };
}

View File

@@ -0,0 +1,43 @@
/**
* remove_from_cart — quita un producto por woo_id (string numérico) o por
* substring del nombre.
*/
import { removeCartItem } from "../../orderModel.js";
export async function removeFromCartTool(args, ctx) {
const { target } = args;
const t = String(target || "").trim();
if (!t) return { ok: false, error: "empty_target" };
// Si es un número puro, intentar match por woo_id directo
const asNumber = /^\d+$/.test(t) ? Number(t) : null;
let removed = null;
let nextOrder = ctx.order;
if (asNumber != null) {
const cart = ctx.order.cart || [];
const idx = cart.findIndex((c) => Number(c.woo_id) === asNumber);
if (idx >= 0) {
removed = cart[idx];
nextOrder = { ...ctx.order, cart: cart.filter((_, i) => i !== idx) };
}
}
// Match por nombre
if (!removed) {
const result = removeCartItem(ctx.order, t);
if (result?.removed) {
removed = result.removed;
nextOrder = result.order;
}
}
if (!removed) {
return { ok: false, error: "not_found_in_cart", target: t };
}
ctx.order = nextOrder;
ctx.pending_actions.push({ type: "remove_from_cart", payload: { removed } });
return { ok: true, removed: { woo_id: removed.woo_id, name: removed.name }, cart_size: (nextOrder.cart || []).length };
}

View File

@@ -0,0 +1,52 @@
/**
* reuse_last_delivery — copia los datos de envío de la última orden del cliente
* (working_memory.last_delivery) al order actual: shipping_address, matched_zone,
* is_delivery (y opcionalmente pending_location + delivery_window).
*
* El agente lo usa cuando proactivamente ofrece "te lo mandamos al mismo lugar
* que la última vez?" y el cliente confirma. Si no hay last_delivery, devuelve
* error y el agente pide la dirección/ubicación de cero.
*/
export async function reuseLastDeliveryTool(_args, ctx) {
const last = ctx.last_delivery;
if (!last) {
return {
ok: false,
error: "no_last_delivery",
hint: "El cliente no tiene una entrega previa registrada. Pedile la ubicación de cero.",
};
}
// Pickup-only no necesita copiar nada de envío.
if (last.is_delivery === false) {
ctx.order = { ...ctx.order, is_delivery: false };
return { ok: true, is_delivery: false, hint: "El cliente la última vez retiró por el local." };
}
// Delivery: si no hay matched_zone tampoco podemos reusar (no se cerró bien).
if (!last.matched_zone) {
return {
ok: false,
error: "no_zone_in_last_delivery",
hint: "La última entrega no tenía zona registrada. Pedí la ubicación al cliente.",
};
}
ctx.order = {
...ctx.order,
is_delivery: true,
shipping_address: last.shipping_address || ctx.order.shipping_address || null,
matched_zone: last.matched_zone,
// Reusar también la ubicación si la tenemos (evita re-pedir el pin).
pending_location: last.pending_location || ctx.order.pending_location || null,
};
return {
ok: true,
is_delivery: true,
shipping_address: ctx.order.shipping_address,
matched_zone: last.matched_zone,
last_delivery_window: last.delivery_window || null,
};
}

View File

@@ -0,0 +1,213 @@
/**
* JSON Schemas de los tools que el agente puede invocar. Formato
* OpenAI/DeepSeek (function calling).
*
* El executor valida los args con Ajv y devuelve error obligando re-llamada
* si falla.
*/
export const TOOL_SCHEMAS = [
{
type: "function",
function: {
name: "search_catalog",
description:
"Busca productos en el catálogo. Devuelve top candidatos con woo_id, nombre, precio, unidad de venta. Llamala SIEMPRE antes de cualquier add_to_cart si no tenés el woo_id.",
parameters: {
type: "object",
additionalProperties: false,
required: ["query"],
properties: {
query: { type: "string", minLength: 2, description: "Término de búsqueda libre, lo que dijo el cliente." },
hint_category: { type: "string", description: "Categoría heurística para fallback (ej. 'parrilla', 'embutidos')." },
limit: { type: "integer", minimum: 1, maximum: 10 },
},
},
},
},
{
type: "function",
function: {
name: "add_to_cart",
description: "Agrega un producto resuelto al carrito.",
parameters: {
type: "object",
additionalProperties: false,
required: ["woo_id", "qty", "unit"],
properties: {
woo_id: { type: "integer", description: "Woo product ID exacto del producto." },
qty: { type: "number", exclusiveMinimum: 0 },
unit: { type: "string", enum: ["kg", "g", "unit"] },
},
},
},
},
{
type: "function",
function: {
name: "set_quantity",
description: "Setea la cantidad de un producto pendiente que ya está resuelto pero faltaba qty/unit.",
parameters: {
type: "object",
additionalProperties: false,
required: ["pending_id", "qty", "unit"],
properties: {
pending_id: { type: "string" },
qty: { type: "number", exclusiveMinimum: 0 },
unit: { type: "string", enum: ["kg", "g", "unit"] },
},
},
},
},
{
type: "function",
function: {
name: "select_candidate",
description: "Resuelve un pending NEEDS_TYPE eligiendo uno de los candidatos mostrados (last_shown_options).",
parameters: {
type: "object",
additionalProperties: false,
required: ["pending_id", "woo_id"],
properties: {
pending_id: { type: "string" },
woo_id: { type: "integer" },
},
},
},
},
{
type: "function",
function: {
name: "remove_from_cart",
description: "Quita un producto del carrito por woo_id o nombre/query.",
parameters: {
type: "object",
additionalProperties: false,
required: ["target"],
properties: {
target: { type: "string", description: "woo_id como string o nombre del producto a quitar." },
},
},
},
},
{
type: "function",
function: {
name: "set_shipping",
description: "Setea el método de envío (delivery o pickup).",
parameters: {
type: "object",
additionalProperties: false,
required: ["method"],
properties: {
method: { type: "string", enum: ["delivery", "pickup"] },
},
},
},
},
{
type: "function",
function: {
name: "set_address",
description:
"Registra la dirección de entrega (texto, como label) y matchea zona usando la ubicación compartida " +
"por WhatsApp (working_memory.order.pending_location). Si no hay ubicación compartida, devuelve " +
"need_location: tenés que pedirle al cliente que mande el pin por WhatsApp.",
parameters: {
type: "object",
additionalProperties: false,
required: ["text"],
properties: {
text: {
type: "string",
minLength: 3,
description: "Dirección textual (calle, número, depto, referencias). Sirve como label para el repartidor.",
},
},
},
},
},
{
type: "function",
function: {
name: "reuse_last_delivery",
description:
"Reusa los datos de envío de la última orden del cliente (working_memory.last_delivery): " +
"dirección, zona y ubicación. Llamala cuando ofrezcas 'te lo mandamos al mismo lugar que la otra vez?' " +
"y el cliente confirme. Si no hay last_delivery devuelve error y tenés que pedir la ubicación de cero.",
parameters: { type: "object", additionalProperties: false, properties: {} },
},
},
{
type: "function",
function: {
name: "set_delivery_window",
description:
"Registra el día/horario que pidió el cliente para entrega o retiro. " +
"El día debe ser uno de lun/mar/mie/jue/vie/sab/dom. Hora opcional (HH:MM). " +
"confirm_order va a validar después contra la zona o el horario de pickup.",
parameters: {
type: "object",
additionalProperties: false,
required: ["day"],
properties: {
day: { type: "string", enum: ["lun", "mar", "mie", "jue", "vie", "sab", "dom"] },
time: { type: "string", pattern: "^\\d{2}:\\d{2}$", description: "HH:MM (24h). Opcional." },
},
},
},
},
{
type: "function",
function: {
name: "confirm_order",
description: "Confirma el pedido. Requiere cart no vacío y shipping completo (pickup o delivery+address). Emite acción create_order.",
parameters: { type: "object", additionalProperties: false, properties: {} },
},
},
{
type: "function",
function: {
name: "pause",
description: "Pausa la conversación cuando el cliente dice 'después te digo' o equivalente. Mantiene el cart 7 días.",
parameters: {
type: "object",
additionalProperties: false,
required: ["reason"],
properties: {
reason: { type: "string", enum: ["user_paused", "user_busy", "needs_to_check"] },
},
},
},
},
{
type: "function",
function: {
name: "escalate_to_human",
description: "Escala a un humano (quejas, dudas de pago/factura, urgencias, no podemos resolver).",
parameters: {
type: "object",
additionalProperties: false,
required: ["reason"],
properties: {
reason: { type: "string", minLength: 3 },
},
},
},
},
{
type: "function",
function: {
name: "say",
description: "Texto final que se envía al cliente. ÚLTIMO tool del turno SIEMPRE.",
parameters: {
type: "object",
additionalProperties: false,
required: ["text"],
properties: {
text: { type: "string", minLength: 1, maxLength: 600 },
},
},
},
},
];

View File

@@ -0,0 +1,106 @@
/**
* search_catalog tool — wrappea retrieveCandidates con fallback por categoría.
*
* Side effects: muta `ctx.last_shown_options` con el top de candidatos para
* que select_candidate pueda resolver "el segundo" en turnos posteriores.
*/
import { retrieveCandidates } from "../../catalogRetrieval.js";
import { searchSnapshotItems } from "../../../shared/wooSnapshot.js";
import { pool } from "../../../shared/db/pool.js";
const MIN_GOOD_SCORE = 0.4;
function summarizeCandidate(c, idx) {
return {
index: idx + 1,
woo_id: c.woo_product_id,
name: c.name,
price: c.price ?? null,
sell_unit: c.sell_unit || null,
score: typeof c._score === "number" ? Number(c._score.toFixed(2)) : null,
};
}
export async function searchCatalogTool(args, ctx) {
const { query, hint_category = null, limit = 5 } = args;
// 1) Búsqueda directa con catalogRetrieval (pg_trgm + alias + snapshot)
const result = await retrieveCandidates({
tenantId: ctx.tenantId,
query,
limit: Math.max(limit, 5),
});
let candidates = result?.candidates || [];
// 2) Fallback por categoría si la búsqueda directa rinde poco
let usedFallback = null;
if (
(candidates.length === 0 ||
(candidates[0]?._score || 0) < MIN_GOOD_SCORE) &&
hint_category
) {
const byCategory = await searchByCategory({
tenantId: ctx.tenantId,
category: hint_category,
limit,
});
if (byCategory.length > 0) {
usedFallback = "category";
candidates = byCategory;
}
}
// Recortar al limit final
const top = candidates.slice(0, limit).map(summarizeCandidate);
// Guardar last_shown_options si hay >1 (para select_candidate posterior)
if (top.length > 1) {
ctx.last_shown_options = top.map((t) => ({
index: t.index,
woo_id: t.woo_id,
name: t.name,
price: t.price,
}));
}
return {
ok: true,
query,
candidates: top,
used_fallback: usedFallback,
count: top.length,
};
}
async function searchByCategory({ tenantId, category, limit }) {
// Buscar productos cuya categories JSONB contenga el name/slug indicado.
// Sin nuevas tablas — explota lo que ya está en woo_products_snapshot.categories.
const sql = `
SELECT woo_product_id, name, sku, slug, price, stock_status, stock_qty,
categories, sell_unit, payload
FROM woo_products_snapshot
WHERE tenant_id = $1
AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(categories) cat
WHERE LOWER(cat) LIKE '%' || LOWER($2) || '%'
)
AND COALESCE(stock_status, 'instock') != 'outofstock'
ORDER BY name ASC
LIMIT $3
`;
try {
const { rows } = await pool.query(sql, [tenantId, category, limit]);
return rows.map((r) => ({
woo_product_id: r.woo_product_id,
name: r.name,
price: r.price,
sell_unit: r.sell_unit,
_score: 0.5, // score sintético para que pase MIN_GOOD_SCORE en el caller
}));
} catch (err) {
// Fallback: snapshot search por texto plano
const r = await searchSnapshotItems({ tenantId, q: category, limit });
return r?.items || [];
}
}

View File

@@ -0,0 +1,72 @@
/**
* select_candidate — resuelve un pending NEEDS_TYPE eligiendo woo_id.
*
* Si el pending tenía `requested_qty` ya, lo promueve directo a READY → cart.
* Sino lo deja en NEEDS_QUANTITY esperando set_quantity.
*/
import {
updatePendingItem,
moveReadyToCart,
PendingStatus,
} from "../../orderModel.js";
import { getSnapshotItemsByIds } from "../../../shared/wooSnapshot.js";
export async function selectCandidateTool(args, ctx) {
const { pending_id, woo_id } = args;
const pending = (ctx.order.pending || []).find((p) => p.id === pending_id);
if (!pending) {
return { ok: false, error: "pending_not_found" };
}
// Buscar el producto seleccionado en el snapshot para nombre/unit/precio
const lookup = await getSnapshotItemsByIds({
tenantId: ctx.tenantId,
wooProductIds: [woo_id],
});
const found = (lookup?.items || [])[0];
if (!found) {
return {
ok: false,
error: "woo_id_unknown",
hint: "El woo_id no existe en el catálogo. Probá con search_catalog.",
};
}
const sellsByWeight =
!found.sell_unit || !["unit", "unidad"].includes(found.sell_unit);
const displayUnit = found.sell_unit === "unit" ? "unit" : sellsByWeight ? "kg" : "unit";
const hasRequestedQty =
pending.requested_qty != null && Number.isFinite(pending.requested_qty) && pending.requested_qty > 0;
const finalQty = hasRequestedQty ? pending.requested_qty : null;
const finalUnit = pending.requested_unit || displayUnit;
const needsQty = sellsByWeight && !hasRequestedQty;
const updated = updatePendingItem(ctx.order, pending_id, {
selected_woo_id: woo_id,
selected_name: found.name,
selected_price: found.price ?? null,
selected_unit: displayUnit,
candidates: [],
qty: needsQty ? null : finalQty,
unit: finalUnit,
status: needsQty ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY,
});
ctx.order = moveReadyToCart(updated);
if (!needsQty) {
ctx.pending_actions.push({
type: "add_to_cart",
payload: { woo_id, qty: finalQty, unit: finalUnit },
});
ctx.last_shown_options = [];
}
return {
ok: true,
selected: { woo_id, name: found.name, unit: displayUnit, sells_by_weight: sellsByWeight },
needs_quantity: needsQty,
cart_size: (ctx.order.cart || []).length,
};
}

View File

@@ -0,0 +1,80 @@
/**
* set_address — fija dirección (texto) y matchea zona usando la ubicación
* compartida por WhatsApp (pending_location). Sin location, no se valida
* zona y se pide al cliente que mande el pin.
*/
import { findZoneForPoint } from "../../lib/geo.js";
export async function setAddressTool(args, ctx) {
const { text } = args || {};
const address = String(text || "").trim();
if (address.length < 3) return { ok: false, error: "address_too_short" };
const allZones = ctx.storeConfig?.delivery_zones?.zones || [];
const enabled = allZones.filter((z) => z?.enabled !== false);
// Sin zonas configuradas, aceptamos la dirección sin validar zona.
if (!enabled.length) {
ctx.order = {
...ctx.order,
shipping_address: address,
is_delivery: ctx.order.is_delivery == null ? true : ctx.order.is_delivery,
matched_zone: null,
};
return { ok: true, address, in_zone: true, reason: "no_zones_configured" };
}
const loc = ctx.order?.pending_location;
if (!loc || typeof loc.lat !== "number" || typeof loc.lng !== "number") {
return {
ok: false,
error: "need_location",
hint:
"Pedile al cliente que te mande su ubicación por WhatsApp (pin/location share) " +
"para validar zona y costo. Sin ubicación no podemos confirmar envío.",
available_zones: enabled.map((z) => ({
name: z.name,
delivery_cost: z.delivery_cost ?? null,
})),
};
}
const matched = findZoneForPoint(loc.lng, loc.lat, enabled);
if (!matched) {
return {
ok: false,
error: "out_of_zones",
hint:
"La ubicación que mandó está fuera de las zonas que cubre la carnicería. " +
"Ofrecé pickup o pedile otra ubicación dentro de las zonas habilitadas.",
available_zones: enabled.map((z) => ({
name: z.name,
delivery_cost: z.delivery_cost ?? null,
})),
};
}
const zoneSummary = {
id: matched.id,
name: matched.name,
delivery_cost: matched.delivery_cost ?? null,
delivery_days: Array.isArray(matched.delivery_days) ? matched.delivery_days : [],
delivery_hours: matched.delivery_hours || null,
min_order_amount: matched.min_order_amount ?? 0,
};
ctx.order = {
...ctx.order,
shipping_address: address,
is_delivery: ctx.order.is_delivery == null ? true : ctx.order.is_delivery,
matched_zone: zoneSummary,
};
return {
ok: true,
address,
in_zone: true,
matched_zone: zoneSummary,
};
}

View File

@@ -0,0 +1,26 @@
/**
* set_delivery_window — registra el día/horario que pidió el cliente.
*
* El LLM lo llama cuando el cliente confirma "el martes a las 11" o similar.
* confirm_order valida después contra zone.delivery_days/delivery_hours
* (delivery) o schedule.pickup (pickup).
*/
const DAY_KEYS = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
export async function setDeliveryWindowTool(args, ctx) {
const { day, time } = args || {};
if (!DAY_KEYS.includes(day)) {
return { ok: false, error: "invalid_day", allowed: DAY_KEYS };
}
if (time != null && !/^\d{2}:\d{2}$/.test(String(time))) {
return { ok: false, error: "invalid_time", hint: "Formato HH:MM (24h)." };
}
ctx.order = {
...ctx.order,
delivery_window: { day, time: time || null },
};
return { ok: true, day, time: time || null };
}

View File

@@ -0,0 +1,36 @@
/**
* set_quantity — completa la cantidad de un pending NEEDS_QUANTITY y lo
* promueve a READY → cart.
*/
import { updatePendingItem, moveReadyToCart, PendingStatus } from "../../orderModel.js";
export async function setQuantityTool(args, ctx) {
const { pending_id, qty, unit } = args;
const pending = (ctx.order.pending || []).find((p) => p.id === pending_id);
if (!pending) {
return { ok: false, error: "pending_not_found", hint: "Verificá el pending_id contra working_memory.order.pending." };
}
if (!pending.selected_woo_id) {
return { ok: false, error: "pending_not_resolved", hint: "Llamá select_candidate primero para resolver el producto." };
}
const updated = updatePendingItem(ctx.order, pending_id, {
qty,
unit,
status: PendingStatus.READY,
});
ctx.order = moveReadyToCart(updated);
ctx.pending_actions.push({ type: "add_to_cart", payload: { woo_id: pending.selected_woo_id, qty, unit } });
ctx.last_shown_options = [];
if (ctx.order.failed_searches) {
ctx.order = { ...ctx.order, failed_searches: { count: 0 } };
}
return {
ok: true,
promoted: { name: pending.selected_name, qty, unit },
cart_size: (ctx.order.cart || []).length,
};
}

View File

@@ -0,0 +1,50 @@
/**
* set_shipping — fija el método de envío (delivery o pickup).
*
* Cuando method=delivery y hay zonas configuradas, devuelve hints para que
* el LLM le pida al cliente que comparta ubicación si no la tenemos.
*/
export async function setShippingTool(args, ctx) {
const { method } = args || {};
if (method !== "delivery" && method !== "pickup") {
return { ok: false, error: "invalid_method" };
}
ctx.order = { ...ctx.order, is_delivery: method === "delivery" };
if (method === "pickup") {
return {
ok: true,
method,
requires_address: false,
requires_location: false,
};
}
// Delivery
const zones = ctx.storeConfig?.delivery_zones?.zones || [];
const enabledZones = zones.filter((z) => z?.enabled !== false);
const hasZones = enabledZones.length > 0;
const hasLocation = !!ctx.order?.pending_location?.lat;
const hasMatchedZone = !!ctx.order?.matched_zone;
return {
ok: true,
method,
requires_address: !ctx.order.shipping_address,
requires_location: hasZones && !hasLocation && !hasMatchedZone,
available_zones: hasZones
? enabledZones.map((z) => ({
name: z.name,
delivery_cost: z.delivery_cost ?? null,
delivery_days: z.delivery_days || [],
delivery_hours: z.delivery_hours || null,
}))
: [],
hint:
hasZones && !hasLocation
? "Pedile al cliente que te mande su ubicación por WhatsApp (pin/location share) para validar zona y costo de envío."
: null,
};
}

View File

@@ -0,0 +1,131 @@
/**
* WorkingMemory — payload contextual que recibe el agente cada turno.
*
* Se serializa como JSON y se inyecta en el primer USER message. NO va en
* system (eso permite cachear el system prompt entre turnos).
*
* Reglas de poda:
* - history: últimos 8 mensajes, content truncado a 200 chars.
* - customer_profile: top 5 frequent_items.
* - last_shown_options: top 8.
* - cart/pending: enteros, sin truncar.
*/
import { parseQuantity } from "./quantityParser.js";
import { buildStoreContextVars, buildZonesForLLM } from "../storeContext.js";
const HISTORY_MAX = 8;
const HISTORY_CHAR_CAP = 200;
const LAST_SHOWN_MAX = 8;
function nowIso() {
return new Date().toISOString();
}
function truncate(s, n) {
if (s == null) return "";
const str = String(s);
if (str.length <= n) return str;
return str.slice(0, n - 1) + "…";
}
/**
* @param {Object} params
* @param {string} params.text - Mensaje crudo del usuario en este turno.
* @param {Object} params.order - order del context (cart, pending, etc.)
* @param {string} params.prev_state - estado FSM previo
* @param {Array} params.conversation_history
* @param {Object} params.storeConfig - resultado de getStoreConfig
* @param {Object} params.customerProfile - perfil del cliente (puede ser null)
* @param {Array} params.lastShownOptions - opciones del turno previo
*/
export function buildWorkingMemory({
text,
order = {},
prev_state = "IDLE",
conversation_history = [],
storeConfig = {},
customerProfile = null,
lastShownOptions = [],
lastDelivery = null,
}) {
const storeVars = buildStoreContextVars(storeConfig);
const cart = (order.cart || []).map((it) => ({
woo_id: it.woo_id,
name: it.name,
qty: it.qty,
unit: it.unit,
price: it.price ?? null,
}));
const pending = (order.pending || []).map((p) => ({
id: p.id,
query: p.query,
status: p.status,
selected_woo_id: p.selected_woo_id ?? null,
selected_name: p.selected_name ?? null,
selected_unit: p.selected_unit ?? null,
requested_qty: p.requested_qty ?? null,
requested_unit: p.requested_unit ?? null,
candidates: (p.candidates || []).slice(0, 8).map((c, i) => ({
index: i + 1,
woo_id: c.woo_id ?? c.woo_product_id,
name: c.name,
price: c.price ?? null,
display_unit: c.display_unit ?? null,
})),
}));
const history = (conversation_history || []).slice(-HISTORY_MAX).map((m) => ({
role: m.role === "user" ? "user" : "assistant",
text: truncate(m.content || m.text || "", HISTORY_CHAR_CAP),
}));
const last_shown_options = (lastShownOptions || []).slice(0, LAST_SHOWN_MAX).map((o, i) => ({
index: o.index ?? i + 1,
woo_id: o.woo_id,
name: o.name,
price: o.price ?? null,
}));
const preparsed = parseQuantity(text || "");
const zones = buildZonesForLLM(storeConfig.delivery_zones);
const pickupSchedule = storeConfig.schedule?.pickup || null;
return {
now: nowIso(),
store: {
name: storeVars.store_name || "la carnicería",
hours_today: storeVars.store_hours_today || "consultar",
delivery: {
zones, // [{id,name,delivery_cost,delivery_days,delivery_hours,min_order_amount}]
zones_summary: storeVars.delivery_zones_summary || "",
requires_location_share: zones.length > 0, // hint para el LLM
},
pickup: {
schedule: pickupSchedule, // { lun:{start,end,enabled}, mar:..., ... } o null
hours_today: storeVars.pickup_hours_today || "",
},
},
fsm_state: prev_state || "IDLE",
order: {
cart,
pending,
is_delivery: order.is_delivery ?? null,
shipping_address: order.shipping_address ?? null,
woo_order_id: order.woo_order_id ?? null,
pending_location: order.pending_location || null, // { lat, lng, label?, received_at }
matched_zone: order.matched_zone || null, // resumen de zona matched (set por set_address)
delivery_window: order.delivery_window || null, // { day, time } elegido por cliente
},
last_shown_options,
paused_until: order.paused_until ?? null,
customer_profile: customerProfile,
last_delivery: lastDelivery, // null si es 1er pedido
history,
user_message: text || "",
preparsed,
};
}

View File

@@ -6,7 +6,7 @@ import {
searchProductAliases,
getProductEmbedding,
upsertProductEmbedding,
getAllAliasProductMappings,
searchAliasProductMappings,
} from "../2-identity/db/repo.js";
function getOpenAiKey() {
@@ -138,60 +138,62 @@ export async function retrieveCandidates({
const audit = { query: q, sources: {}, boosts: {}, embeddings: {} };
// 1) Buscar aliases que matcheen la query
const aliases = await searchProductAliases({ tenant_id: tenantId, q, limit: 20 });
// 1) Buscar aliases con fuzzy matching (pg_trgm).
// Captura plurales, diminutivos y typos sin reglas escritas.
const [aliases, mappings] = await Promise.all([
searchProductAliases({ tenant_id: tenantId, q, limit: 20 }),
searchAliasProductMappings({ tenant_id: tenantId, q, limit: 50 }),
]);
const aliasBoostByProduct = new Map();
const aliasProductIds = new Set();
// También buscar en alias_product_mappings (multi-producto)
const allMappings = await getAllAliasProductMappings({ tenant_id: tenantId });
const normalizedQuery = normalizeText(q);
const queryWords = new Set(normalizedQuery.split(" ").filter(Boolean));
// Buscar mappings cuyos aliases matcheen la query
for (const mapping of allMappings) {
const aliasNorm = normalizeText(mapping.alias);
// Match exacto o parcial del alias
if (aliasNorm === normalizedQuery || normalizedQuery.includes(aliasNorm) || aliasNorm.includes(normalizedQuery)) {
const id = Number(mapping.woo_product_id);
const score = Number(mapping.score || 1);
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, score));
// alias_product_mappings: score * similarity (premia tanto reglas explícitas como fuzziness)
for (const m of mappings) {
const id = m.woo_product_id;
const boost = m.score * m.similarity;
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost));
aliasProductIds.add(id);
} else {
// Check word overlap
const aliasWords = new Set(aliasNorm.split(" ").filter(Boolean));
for (const word of queryWords) {
if (aliasWords.has(word)) {
const id = Number(mapping.woo_product_id);
const score = Number(mapping.score || 1) * 0.7; // Partial match gets lower score
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, score));
aliasProductIds.add(id);
break;
}
}
}
}
// También incluir aliases legacy (product_aliases.woo_product_id)
// product_aliases legacy (1 alias → 1 producto)
for (const a of aliases) {
if (a?.woo_product_id) {
const id = Number(a.woo_product_id);
const boost = Number(a.boost || 0);
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost || 0));
const boost = Number(a.boost || 0) * (a.similarity || 1);
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost));
aliasProductIds.add(id);
}
}
audit.sources.aliases = aliases.length;
audit.sources.alias_mappings = aliasProductIds.size;
audit.sources.alias_mappings = mappings.length;
// 2) Buscar productos por nombre/slug (búsqueda literal)
const { items: wooItems, source: wooSource } = await searchSnapshotItems({
// 2) Buscar productos por nombre/slug (búsqueda literal con query original)
let { items: wooItems, source: wooSource } = await searchSnapshotItems({
tenantId,
q,
limit: lim,
});
audit.sources.snapshot = { source: wooSource, count: wooItems?.length || 0 };
// 2b) Si el query literal no rinde pero un alias matcheó (typo/plural/diminutivo),
// re-buscar el snapshot con el normalized_alias del mejor match.
// Esto cierra el loop: "vasio" → alias "vacio" → buscar "vacio" en productos.
if ((!wooItems || wooItems.length === 0) && aliases.length > 0) {
const refined = aliases[0]?.normalized_alias;
if (refined && refined.toLowerCase() !== q.toLowerCase()) {
const { items: aliasRefined, source: refSource } = await searchSnapshotItems({
tenantId,
q: refined,
limit: lim,
});
if (aliasRefined?.length) {
wooItems = aliasRefined;
audit.sources.snapshot_refined = { query: refined, source: refSource, count: aliasRefined.length };
}
}
}
// 3) Traer productos que matchearon por alias pero no por búsqueda literal
const foundIds = new Set(wooItems.map(w => Number(w.woo_product_id)));
const missingAliasIds = [...aliasProductIds].filter(id => !foundIds.has(id));

View File

@@ -1,7 +1,8 @@
/**
* FSM simplificada para el flujo conversacional.
*
* Estados lineales: IDLE → CART → SHIPPING → PAYMENT → WAITING_WEBHOOKS
* Estados lineales: IDLE → CART → SHIPPING → IDLE (post-confirmación).
* El bot toma pedidos y datos de entrega; el cobro se gestiona offline.
* Regla universal: add_to_cart SIEMPRE vuelve a CART desde cualquier estado.
*/
@@ -9,9 +10,8 @@ export const ConversationState = Object.freeze({
IDLE: "IDLE",
CART: "CART",
SHIPPING: "SHIPPING",
PAYMENT: "PAYMENT",
WAITING_WEBHOOKS: "WAITING_WEBHOOKS",
AWAITING_HUMAN: "AWAITING_HUMAN", // Esperando respuesta de un humano
PAUSED: "PAUSED", // Cliente dijo "después te digo" - cart preservado, TTL 7d
AWAITING_HUMAN: "AWAITING_HUMAN",
});
export const ALL_STATES = Object.freeze(Object.values(ConversationState));
@@ -19,23 +19,22 @@ export const ALL_STATES = Object.freeze(Object.values(ConversationState));
// Intents válidos por estado
export const INTENTS_BY_STATE = Object.freeze({
[ConversationState.IDLE]: [
"greeting", "add_to_cart", "browse", "price_query", "recommend", "other"
"greeting", "add_to_cart", "browse", "price_query", "recommend", "other",
],
[ConversationState.CART]: [
"add_to_cart", "remove_from_cart", "browse", "price_query",
"recommend", "view_cart", "confirm_order", "other"
"recommend", "view_cart", "confirm_order", "other",
],
[ConversationState.SHIPPING]: [
"provide_address", "select_shipping", "add_to_cart", "view_cart", "other"
"provide_address", "select_shipping", "add_to_cart", "view_cart", "other",
],
[ConversationState.PAYMENT]: [
"select_payment", "add_to_cart", "view_cart", "other"
],
[ConversationState.WAITING_WEBHOOKS]: [
"add_to_cart", "view_cart", "other"
[ConversationState.PAUSED]: [
// Cualquier intent del cliente lo reactiva; el agente decide el destino.
"greeting", "add_to_cart", "browse", "view_cart", "confirm_order",
"select_shipping", "provide_address", "remove_from_cart", "other",
],
[ConversationState.AWAITING_HUMAN]: [
"other" // En este estado, el bot no procesa - espera respuesta humana
"other",
],
});
@@ -44,21 +43,18 @@ export const INTENTS_BY_STATE = Object.freeze({
*/
export function shouldReturnToCart(state, nlu, text = "") {
if (state === ConversationState.CART || state === ConversationState.IDLE) {
return false; // Ya está en CART o IDLE (IDLE irá a CART naturalmente)
return false;
}
// En SHIPPING/PAYMENT, números solos son selecciones de opción, no productos
const isCheckoutState = state === ConversationState.SHIPPING || state === ConversationState.PAYMENT;
// En SHIPPING, números solos son selecciones de opción, no productos
const isCheckoutState = state === ConversationState.SHIPPING;
const isJustNumber = /^\s*\d+([.,]\d+)?\s*$/.test(text || "");
if (isCheckoutState && isJustNumber) {
return false; // No redirigir, es una selección de opción
return false;
}
const intent = nlu?.intent;
// Si el intent es add_to_cart, browse, price_query, o recommend → volver a CART
// Pero solo si hay una query de producto real (no vacía)
if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) {
// Verificar que hay un producto real mencionado
const hasRealProduct = nlu?.entities?.product_query &&
String(nlu.entities.product_query).trim().length > 2;
const hasRealItems = Array.isArray(nlu?.entities?.items) &&
@@ -66,11 +62,9 @@ export function shouldReturnToCart(state, nlu, text = "") {
if (hasRealProduct || hasRealItems) {
return true;
}
// Si no hay producto real, no redirigir (probablemente es una selección numérica mal interpretada)
return false;
}
// Si hay menciones de producto en entities (con contenido real)
if (nlu?.entities?.product_query && String(nlu.entities.product_query).trim().length > 2) return true;
if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.some(i => i?.product_query?.trim().length > 2)) return true;
@@ -100,43 +94,27 @@ export function hasShippingInfo(order) {
return false;
}
export function hasPaymentInfo(order) {
return order?.payment_type === "cash" || order?.payment_type === "link";
}
export function isPaid(order) {
return order?.is_paid === true;
}
/**
* Deriva el siguiente estado basado en el contexto y signals.
*
* signals: {
* confirm_order: boolean, // Usuario quiere cerrar pedido
* shipping_selected: boolean, // Usuario seleccionó delivery/pickup
* payment_selected: boolean, // Usuario seleccionó método de pago
* shipping_completed: boolean, // Shipping info quedó completa (gatilla create_order + IDLE)
* return_to_cart: boolean, // Forzar volver a CART
* }
*/
export function deriveNextState(prevState, order = {}, signals = {}) {
// Regla 0: Si se fuerza volver a CART
if (signals.return_to_cart) {
return ConversationState.CART;
}
// Regla 1: Si está pagado, completado (volver a IDLE para nueva conversación)
if (isPaid(order)) {
// Si la orden ya fue creada en Woo, volvemos a IDLE para nueva conversación.
if (order?.woo_order_id) {
return ConversationState.IDLE;
}
// Regla 2: Si tiene woo_order_id y espera pago
if (order?.woo_order_id && !isPaid(order)) {
return ConversationState.WAITING_WEBHOOKS;
}
// Desde IDLE
if (prevState === ConversationState.IDLE) {
// Si hay cart o pending items, ir a CART
if (hasCartItems(order) || hasPendingItems(order)) {
return ConversationState.CART;
}
@@ -145,11 +123,9 @@ export function deriveNextState(prevState, order = {}, signals = {}) {
// Desde CART
if (prevState === ConversationState.CART) {
// Si hay pending items sin resolver, quedarse en CART
if (hasPendingItems(order)) {
return ConversationState.CART;
}
// Si usuario confirma orden y hay items en cart, ir a SHIPPING
if (signals.confirm_order && hasCartItems(order)) {
return ConversationState.SHIPPING;
}
@@ -158,69 +134,49 @@ export function deriveNextState(prevState, order = {}, signals = {}) {
// Desde SHIPPING
if (prevState === ConversationState.SHIPPING) {
// Si ya tiene shipping info completa, ir a PAYMENT
if (hasShippingInfo(order)) {
return ConversationState.PAYMENT;
// Una vez completado el shipping, la orden se crea y vuelve a IDLE.
if (signals.shipping_completed || hasShippingInfo(order)) {
return ConversationState.IDLE;
}
return ConversationState.SHIPPING;
}
// Desde PAYMENT
if (prevState === ConversationState.PAYMENT) {
// Si ya tiene payment info, ir a WAITING_WEBHOOKS
if (signals.payment_selected || hasPaymentInfo(order)) {
return ConversationState.WAITING_WEBHOOKS;
}
return ConversationState.PAYMENT;
}
// Desde WAITING_WEBHOOKS
if (prevState === ConversationState.WAITING_WEBHOOKS) {
if (isPaid(order)) {
return ConversationState.IDLE;
}
return ConversationState.WAITING_WEBHOOKS;
}
// Default
return prevState || ConversationState.IDLE;
}
// Transiciones permitidas (para validación)
const ALLOWED = Object.freeze({
[ConversationState.IDLE]: [
ConversationState.IDLE,
ConversationState.CART,
ConversationState.AWAITING_HUMAN, // Puede ir a esperar humano
ConversationState.PAUSED,
ConversationState.AWAITING_HUMAN,
],
[ConversationState.CART]: [
ConversationState.CART,
ConversationState.SHIPPING,
ConversationState.IDLE, // Si vacía el carrito
ConversationState.AWAITING_HUMAN, // Producto no encontrado
ConversationState.IDLE,
ConversationState.PAUSED,
ConversationState.AWAITING_HUMAN,
],
[ConversationState.SHIPPING]: [
ConversationState.SHIPPING,
ConversationState.PAYMENT,
ConversationState.CART, // Volver a agregar productos
ConversationState.IDLE,
ConversationState.CART,
ConversationState.PAUSED,
ConversationState.AWAITING_HUMAN,
],
[ConversationState.PAYMENT]: [
ConversationState.PAYMENT,
ConversationState.WAITING_WEBHOOKS,
ConversationState.CART, // Volver a agregar productos
ConversationState.AWAITING_HUMAN,
],
[ConversationState.WAITING_WEBHOOKS]: [
ConversationState.WAITING_WEBHOOKS,
ConversationState.IDLE, // Pago completado
ConversationState.CART, // Agregar más productos
[ConversationState.PAUSED]: [
// Cualquier mensaje saca de paused
ConversationState.PAUSED,
ConversationState.CART,
ConversationState.SHIPPING,
ConversationState.IDLE,
ConversationState.AWAITING_HUMAN,
],
[ConversationState.AWAITING_HUMAN]: [
ConversationState.AWAITING_HUMAN, // Sigue esperando
ConversationState.CART, // Humano respondió, volver a procesar
ConversationState.IDLE, // Humano canceló
ConversationState.AWAITING_HUMAN,
ConversationState.CART,
ConversationState.IDLE,
],
});
@@ -237,7 +193,5 @@ export function safeNextState(prevState, order, signals) {
const desired = deriveNextState(prevState, order, signals);
const v = validateTransition(prevState, desired);
if (v.ok) return { next_state: desired, validation: v };
// Si la transición no es válida, forzar a un estado seguro
// En el nuevo modelo, siempre podemos ir a CART
return { next_state: ConversationState.CART, validation: { ...v, forced_to_cart: true } };
}

View File

@@ -1,5 +1,5 @@
/**
* Tests para fsm.js
* Tests para fsm.js (sin payment / waiting — el bot no maneja pagos).
*/
import { describe, it, expect } from 'vitest';
import {
@@ -11,555 +11,211 @@ import {
hasPendingItems,
hasReadyPendingItems,
hasShippingInfo,
hasPaymentInfo,
isPaid,
deriveNextState,
validateTransition,
safeNextState,
} from './fsm.js';
// ─────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────
describe('ConversationState', () => {
it('tiene todos los estados definidos', () => {
it('tiene los estados del flujo (incluye PAUSED)', () => {
expect(ConversationState.IDLE).toBe('IDLE');
expect(ConversationState.CART).toBe('CART');
expect(ConversationState.SHIPPING).toBe('SHIPPING');
expect(ConversationState.PAYMENT).toBe('PAYMENT');
expect(ConversationState.WAITING_WEBHOOKS).toBe('WAITING_WEBHOOKS');
expect(ConversationState.PAUSED).toBe('PAUSED');
expect(ConversationState.AWAITING_HUMAN).toBe('AWAITING_HUMAN');
expect(ConversationState.PAYMENT).toBeUndefined();
expect(ConversationState.WAITING_WEBHOOKS).toBeUndefined();
});
it('ALL_STATES contiene todos', () => {
expect(ALL_STATES).toContain('IDLE');
expect(ALL_STATES).toContain('CART');
expect(ALL_STATES).toContain('SHIPPING');
expect(ALL_STATES).toContain('PAYMENT');
expect(ALL_STATES).toContain('WAITING_WEBHOOKS');
expect(ALL_STATES).toContain('AWAITING_HUMAN');
expect(ALL_STATES).toHaveLength(6);
it('ALL_STATES contiene 5 estados', () => {
expect(ALL_STATES).toEqual(expect.arrayContaining(['IDLE', 'CART', 'SHIPPING', 'PAUSED', 'AWAITING_HUMAN']));
expect(ALL_STATES).toHaveLength(5);
});
it('INTENTS_BY_STATE define intents para cada estado', () => {
it('INTENTS_BY_STATE define intents por estado', () => {
expect(INTENTS_BY_STATE[ConversationState.IDLE]).toContain('greeting');
expect(INTENTS_BY_STATE[ConversationState.CART]).toContain('add_to_cart');
expect(INTENTS_BY_STATE[ConversationState.SHIPPING]).toContain('provide_address');
expect(INTENTS_BY_STATE[ConversationState.PAYMENT]).toContain('select_payment');
});
});
// ─────────────────────────────────────────────────────────────
// hasCartItems
// ─────────────────────────────────────────────────────────────
describe('hasCartItems', () => {
it('retorna true si cart tiene items', () => {
const order = { cart: [{ woo_id: 1, qty: 1 }] };
expect(hasCartItems(order)).toBe(true);
expect(hasCartItems({ cart: [{ woo_id: 1, qty: 1 }] })).toBe(true);
});
it('retorna false si cart está vacío', () => {
const order = { cart: [] };
expect(hasCartItems(order)).toBe(false);
expect(hasCartItems({ cart: [] })).toBe(false);
});
it('retorna false si cart es undefined', () => {
const order = {};
expect(hasCartItems(order)).toBe(false);
expect(hasCartItems({})).toBe(false);
});
it('retorna false si order es null', () => {
it('retorna false si order es null/undefined', () => {
expect(hasCartItems(null)).toBe(false);
});
it('retorna false si order es undefined', () => {
expect(hasCartItems(undefined)).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────
// hasPendingItems
// ─────────────────────────────────────────────────────────────
describe('hasPendingItems', () => {
it('retorna true si hay NEEDS_TYPE', () => {
const order = { pending: [{ status: 'NEEDS_TYPE' }] };
expect(hasPendingItems(order)).toBe(true);
expect(hasPendingItems({ pending: [{ status: 'NEEDS_TYPE' }] })).toBe(true);
});
it('retorna true si hay NEEDS_QUANTITY', () => {
const order = { pending: [{ status: 'NEEDS_QUANTITY' }] };
expect(hasPendingItems(order)).toBe(true);
expect(hasPendingItems({ pending: [{ status: 'NEEDS_QUANTITY' }] })).toBe(true);
});
it('retorna false si solo hay READY', () => {
const order = { pending: [{ status: 'READY' }] };
expect(hasPendingItems(order)).toBe(false);
expect(hasPendingItems({ pending: [{ status: 'READY' }] })).toBe(false);
});
it('retorna false si pending está vacío', () => {
const order = { pending: [] };
expect(hasPendingItems(order)).toBe(false);
});
it('retorna false si order es null', () => {
expect(hasPendingItems(null)).toBe(false);
});
it('detecta entre múltiples items', () => {
const order = {
pending: [
{ status: 'READY' },
{ status: 'NEEDS_TYPE' },
]
};
expect(hasPendingItems(order)).toBe(true);
expect(hasPendingItems({ pending: [{ status: 'READY' }, { status: 'NEEDS_TYPE' }] })).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────
// hasReadyPendingItems
// ─────────────────────────────────────────────────────────────
describe('hasReadyPendingItems', () => {
it('retorna true si hay READY', () => {
const order = { pending: [{ status: 'READY' }] };
expect(hasReadyPendingItems(order)).toBe(true);
expect(hasReadyPendingItems({ pending: [{ status: 'READY' }] })).toBe(true);
});
it('retorna false si no hay READY', () => {
const order = { pending: [{ status: 'NEEDS_TYPE' }] };
expect(hasReadyPendingItems(order)).toBe(false);
});
it('retorna false si pending vacío', () => {
const order = { pending: [] };
expect(hasReadyPendingItems(order)).toBe(false);
});
it('retorna false si order es null', () => {
expect(hasReadyPendingItems(null)).toBe(false);
expect(hasReadyPendingItems({ pending: [{ status: 'NEEDS_TYPE' }] })).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────
// hasShippingInfo
// ─────────────────────────────────────────────────────────────
describe('hasShippingInfo', () => {
it('retorna true para pickup (no necesita dirección)', () => {
const order = { is_delivery: false };
expect(hasShippingInfo(order)).toBe(true);
expect(hasShippingInfo({ is_delivery: false })).toBe(true);
});
it('retorna true para delivery con dirección', () => {
const order = { is_delivery: true, shipping_address: 'Calle Falsa 123' };
expect(hasShippingInfo(order)).toBe(true);
expect(hasShippingInfo({ is_delivery: true, shipping_address: 'Calle Falsa 123' })).toBe(true);
});
it('retorna false para delivery sin dirección', () => {
const order = { is_delivery: true, shipping_address: null };
expect(hasShippingInfo(order)).toBe(false);
expect(hasShippingInfo({ is_delivery: true, shipping_address: null })).toBe(false);
});
it('retorna false si is_delivery es null', () => {
const order = { is_delivery: null };
expect(hasShippingInfo(order)).toBe(false);
});
it('retorna false para order vacío', () => {
expect(hasShippingInfo({})).toBe(false);
expect(hasShippingInfo({ is_delivery: null })).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────
// hasPaymentInfo
// ─────────────────────────────────────────────────────────────
describe('hasPaymentInfo', () => {
it('retorna true para cash', () => {
const order = { payment_type: 'cash' };
expect(hasPaymentInfo(order)).toBe(true);
});
it('retorna true para link', () => {
const order = { payment_type: 'link' };
expect(hasPaymentInfo(order)).toBe(true);
});
it('retorna false para null', () => {
const order = { payment_type: null };
expect(hasPaymentInfo(order)).toBe(false);
});
it('retorna false para undefined', () => {
const order = {};
expect(hasPaymentInfo(order)).toBe(false);
});
it('retorna false para otros valores', () => {
const order = { payment_type: 'bitcoin' };
expect(hasPaymentInfo(order)).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────
// isPaid
// ─────────────────────────────────────────────────────────────
describe('isPaid', () => {
it('retorna true si is_paid es true', () => {
const order = { is_paid: true };
expect(isPaid(order)).toBe(true);
});
it('retorna false si is_paid es false', () => {
const order = { is_paid: false };
expect(isPaid(order)).toBe(false);
});
it('retorna false si is_paid es undefined', () => {
const order = {};
expect(isPaid(order)).toBe(false);
});
it('retorna false si order es null', () => {
expect(isPaid(null)).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────
// shouldReturnToCart
// ─────────────────────────────────────────────────────────────
describe('shouldReturnToCart', () => {
describe('no redirige si ya está en CART o IDLE', () => {
it('retorna false en CART', () => {
it('no redirige si ya está en CART o IDLE', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
expect(shouldReturnToCart(ConversationState.CART, nlu)).toBe(false);
});
it('retorna false en IDLE', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
expect(shouldReturnToCart(ConversationState.IDLE, nlu)).toBe(false);
});
});
describe('redirige desde otros estados', () => {
it('redirige add_to_cart desde SHIPPING', () => {
it('redirige add_to_cart desde SHIPPING con producto real', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
});
it('redirige add_to_cart desde PAYMENT', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'vacío' } };
expect(shouldReturnToCart(ConversationState.PAYMENT, nlu)).toBe(true);
});
it('redirige browse desde SHIPPING', () => {
const nlu = { intent: 'browse', entities: { product_query: 'carnes' } };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
});
});
describe('no redirige números solos en checkout', () => {
it('no redirige "1" en SHIPPING', () => {
it('no redirige números solos en SHIPPING (selección de opción)', () => {
const nlu = { intent: 'add_to_cart', entities: {} };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1')).toBe(false);
});
it('no redirige "2" en PAYMENT', () => {
const nlu = { intent: 'other', entities: {} };
expect(shouldReturnToCart(ConversationState.PAYMENT, nlu, '2')).toBe(false);
});
it('no redirige "1.5" en SHIPPING', () => {
const nlu = { intent: 'add_to_cart', entities: {} };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1.5')).toBe(false);
});
});
describe('requiere producto real', () => {
it('no redirige sin product_query', () => {
it('no redirige sin producto real', () => {
const nlu = { intent: 'add_to_cart', entities: {} };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, 'algo')).toBe(false);
expect(shouldReturnToCart(ConversationState.SHIPPING, { intent: 'add_to_cart', entities: { product_query: 'ab' } })).toBe(false);
});
it('no redirige con product_query muy corto', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'ab' } };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(false);
});
it('redirige con items array', () => {
const nlu = {
intent: 'add_to_cart',
entities: { items: [{ product_query: 'provoleta' }] }
};
it('redirige con items array que tenga producto real', () => {
const nlu = { intent: 'add_to_cart', entities: { items: [{ product_query: 'provoleta' }] } };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
});
});
});
// ─────────────────────────────────────────────────────────────
// deriveNextState
// ─────────────────────────────────────────────────────────────
describe('deriveNextState', () => {
describe('return_to_cart signal', () => {
it('fuerza CART si return_to_cart', () => {
const result = deriveNextState(
ConversationState.PAYMENT,
{},
{ return_to_cart: true }
);
expect(result).toBe(ConversationState.CART);
});
it('return_to_cart fuerza CART', () => {
expect(deriveNextState(ConversationState.SHIPPING, {}, { return_to_cart: true })).toBe(ConversationState.CART);
});
describe('pagado', () => {
it('va a IDLE si está pagado', () => {
const order = { is_paid: true };
const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {});
expect(result).toBe(ConversationState.IDLE);
});
it('IDLE va a CART si hay cart o pending', () => {
expect(deriveNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {})).toBe(ConversationState.CART);
expect(deriveNextState(ConversationState.IDLE, { cart: [], pending: [{ status: 'NEEDS_TYPE' }] }, {})).toBe(ConversationState.CART);
});
describe('esperando pago', () => {
it('va a WAITING_WEBHOOKS si tiene woo_order_id', () => {
const order = { woo_order_id: 123, is_paid: false };
const result = deriveNextState(ConversationState.PAYMENT, order, {});
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
});
it('IDLE queda en IDLE si vacío', () => {
expect(deriveNextState(ConversationState.IDLE, { cart: [], pending: [] }, {})).toBe(ConversationState.IDLE);
});
describe('IDLE -> CART', () => {
it('va a CART si hay cart items', () => {
const order = { cart: [{ woo_id: 1 }], pending: [] };
const result = deriveNextState(ConversationState.IDLE, order, {});
expect(result).toBe(ConversationState.CART);
});
it('va a CART si hay pending items', () => {
const order = { cart: [], pending: [{ status: 'NEEDS_TYPE' }] };
const result = deriveNextState(ConversationState.IDLE, order, {});
expect(result).toBe(ConversationState.CART);
});
it('queda en IDLE si vacío', () => {
const order = { cart: [], pending: [] };
const result = deriveNextState(ConversationState.IDLE, order, {});
expect(result).toBe(ConversationState.IDLE);
});
});
describe('CART -> SHIPPING', () => {
it('queda en CART si hay pending items', () => {
it('CART queda en CART con pending', () => {
const order = { cart: [{ woo_id: 1 }], pending: [{ status: 'NEEDS_TYPE' }] };
const result = deriveNextState(ConversationState.CART, order, { confirm_order: true });
expect(result).toBe(ConversationState.CART);
expect(deriveNextState(ConversationState.CART, order, { confirm_order: true })).toBe(ConversationState.CART);
});
it('va a SHIPPING con confirm_order y cart items', () => {
it('CART → SHIPPING con confirm + items', () => {
const order = { cart: [{ woo_id: 1 }], pending: [] };
const result = deriveNextState(ConversationState.CART, order, { confirm_order: true });
expect(result).toBe(ConversationState.SHIPPING);
expect(deriveNextState(ConversationState.CART, order, { confirm_order: true })).toBe(ConversationState.SHIPPING);
});
it('queda en CART sin confirm_order', () => {
const order = { cart: [{ woo_id: 1 }], pending: [] };
const result = deriveNextState(ConversationState.CART, order, {});
expect(result).toBe(ConversationState.CART);
});
it('SHIPPING → IDLE cuando shipping queda completo (orden creada offline)', () => {
expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: false }, {})).toBe(ConversationState.IDLE);
expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: true, shipping_address: 'Calle 1' }, {})).toBe(ConversationState.IDLE);
});
describe('SHIPPING -> PAYMENT', () => {
it('va a PAYMENT con shipping info (pickup)', () => {
const order = { is_delivery: false };
const result = deriveNextState(ConversationState.SHIPPING, order, {});
expect(result).toBe(ConversationState.PAYMENT);
it('SHIPPING queda en SHIPPING sin info completa', () => {
expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: true }, {})).toBe(ConversationState.SHIPPING);
});
it('va a PAYMENT con shipping info (delivery + address)', () => {
const order = { is_delivery: true, shipping_address: 'Calle 123' };
const result = deriveNextState(ConversationState.SHIPPING, order, {});
expect(result).toBe(ConversationState.PAYMENT);
it('woo_order_id existente vuelve a IDLE (orden ya creada)', () => {
expect(deriveNextState(ConversationState.SHIPPING, { woo_order_id: 999 }, {})).toBe(ConversationState.IDLE);
});
it('queda en SHIPPING sin info completa', () => {
const order = { is_delivery: true, shipping_address: null };
const result = deriveNextState(ConversationState.SHIPPING, order, {});
expect(result).toBe(ConversationState.SHIPPING);
});
});
describe('PAYMENT -> WAITING_WEBHOOKS', () => {
it('va a WAITING con payment_selected', () => {
const order = {};
const result = deriveNextState(
ConversationState.PAYMENT,
order,
{ payment_selected: true }
);
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
});
it('va a WAITING si ya tiene payment_type', () => {
const order = { payment_type: 'cash' };
const result = deriveNextState(ConversationState.PAYMENT, order, {});
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
});
});
describe('WAITING_WEBHOOKS', () => {
it('va a IDLE si está pagado', () => {
const order = { is_paid: true };
const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {});
expect(result).toBe(ConversationState.IDLE);
});
it('queda en WAITING si no está pagado', () => {
const order = { is_paid: false };
const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {});
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
});
});
describe('default', () => {
it('retorna IDLE si no hay estado previo', () => {
const result = deriveNextState(null, {}, {});
expect(result).toBe(ConversationState.IDLE);
});
it('default sin estado previo retorna IDLE', () => {
expect(deriveNextState(null, {}, {})).toBe(ConversationState.IDLE);
});
});
// ─────────────────────────────────────────────────────────────
// validateTransition
// ─────────────────────────────────────────────────────────────
describe('validateTransition', () => {
describe('transiciones válidas', () => {
it('IDLE -> IDLE es válido', () => {
const result = validateTransition(ConversationState.IDLE, ConversationState.IDLE);
expect(result.ok).toBe(true);
it('IDLE → CART es válida', () => {
expect(validateTransition(ConversationState.IDLE, ConversationState.CART).ok).toBe(true);
});
it('IDLE -> CART es válido', () => {
const result = validateTransition(ConversationState.IDLE, ConversationState.CART);
expect(result.ok).toBe(true);
it('CART → SHIPPING es válida', () => {
expect(validateTransition(ConversationState.CART, ConversationState.SHIPPING).ok).toBe(true);
});
it('CART -> SHIPPING es válido', () => {
const result = validateTransition(ConversationState.CART, ConversationState.SHIPPING);
expect(result.ok).toBe(true);
it('SHIPPING → IDLE es válida (cierre de orden)', () => {
expect(validateTransition(ConversationState.SHIPPING, ConversationState.IDLE).ok).toBe(true);
});
it('SHIPPING -> PAYMENT es válido', () => {
const result = validateTransition(ConversationState.SHIPPING, ConversationState.PAYMENT);
expect(result.ok).toBe(true);
it('SHIPPING → CART (volver a agregar) es válida', () => {
expect(validateTransition(ConversationState.SHIPPING, ConversationState.CART).ok).toBe(true);
});
it('PAYMENT -> WAITING_WEBHOOKS es válido', () => {
const result = validateTransition(ConversationState.PAYMENT, ConversationState.WAITING_WEBHOOKS);
expect(result.ok).toBe(true);
it('IDLE → SHIPPING es inválida (debe pasar por CART)', () => {
const r = validateTransition(ConversationState.IDLE, ConversationState.SHIPPING);
expect(r.ok).toBe(false);
expect(r.reason).toBe('invalid_transition');
});
it('SHIPPING -> CART (volver) es válido', () => {
const result = validateTransition(ConversationState.SHIPPING, ConversationState.CART);
expect(result.ok).toBe(true);
});
});
describe('transiciones inválidas', () => {
it('IDLE -> PAYMENT es inválido', () => {
const result = validateTransition(ConversationState.IDLE, ConversationState.PAYMENT);
expect(result.ok).toBe(false);
expect(result.reason).toBe('invalid_transition');
});
it('CART -> WAITING_WEBHOOKS es inválido', () => {
const result = validateTransition(ConversationState.CART, ConversationState.WAITING_WEBHOOKS);
expect(result.ok).toBe(false);
});
});
describe('estados desconocidos', () => {
it('estado previo desconocido', () => {
const result = validateTransition('UNKNOWN', ConversationState.CART);
expect(result.ok).toBe(false);
expect(result.reason).toBe('unknown_prev_state');
});
it('estado siguiente desconocido', () => {
const result = validateTransition(ConversationState.IDLE, 'UNKNOWN');
expect(result.ok).toBe(false);
expect(result.reason).toBe('unknown_next_state');
});
});
describe('maneja null/undefined', () => {
it('prevState null se trata como IDLE', () => {
const result = validateTransition(null, ConversationState.CART);
expect(result.ok).toBe(true);
});
it('nextState null se trata como IDLE', () => {
const result = validateTransition(ConversationState.IDLE, null);
expect(result.ok).toBe(true);
const r = validateTransition('UNKNOWN', ConversationState.CART);
expect(r.ok).toBe(false);
expect(r.reason).toBe('unknown_prev_state');
});
it('null se trata como IDLE', () => {
expect(validateTransition(null, ConversationState.CART).ok).toBe(true);
expect(validateTransition(ConversationState.IDLE, null).ok).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────
// safeNextState
// ─────────────────────────────────────────────────────────────
describe('safeNextState', () => {
it('retorna estado derivado si transición válida', () => {
it('retorna estado derivado si la transición es válida', () => {
const order = { cart: [{ woo_id: 1 }], pending: [] };
const result = safeNextState(ConversationState.CART, order, { confirm_order: true });
expect(result.next_state).toBe(ConversationState.SHIPPING);
expect(result.validation.ok).toBe(true);
const r = safeNextState(ConversationState.CART, order, { confirm_order: true });
expect(r.next_state).toBe(ConversationState.SHIPPING);
expect(r.validation.ok).toBe(true);
});
it('fuerza CART si transición inválida', () => {
// Forzar una situación donde deriveNextState retornaría un estado inválido
// Esto es difícil de provocar porque deriveNextState ya es bastante seguro
// Pero podemos verificar que la lógica de fallback existe
const order = {};
const result = safeNextState(ConversationState.IDLE, order, {});
it('flow IDLE → CART → SHIPPING → IDLE', () => {
let r = safeNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {});
expect(r.next_state).toBe(ConversationState.CART);
// Debería quedarse en IDLE (transición válida)
expect(result.next_state).toBe(ConversationState.IDLE);
expect(result.validation.ok).toBe(true);
});
r = safeNextState(ConversationState.CART, { cart: [{ woo_id: 1 }], pending: [] }, { confirm_order: true });
expect(r.next_state).toBe(ConversationState.SHIPPING);
it('incluye validation en resultado', () => {
const order = { is_delivery: false };
const result = safeNextState(ConversationState.SHIPPING, order, {});
expect(result).toHaveProperty('next_state');
expect(result).toHaveProperty('validation');
expect(result.validation).toHaveProperty('ok');
});
it('maneja transition IDLE -> CART -> SHIPPING flow', () => {
// Paso 1: IDLE con cart items -> CART
let result = safeNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {});
expect(result.next_state).toBe(ConversationState.CART);
// Paso 2: CART con confirm -> SHIPPING
result = safeNextState(ConversationState.CART, { cart: [{ woo_id: 1 }], pending: [] }, { confirm_order: true });
expect(result.next_state).toBe(ConversationState.SHIPPING);
// Paso 3: SHIPPING con pickup -> PAYMENT
result = safeNextState(ConversationState.SHIPPING, { is_delivery: false }, {});
expect(result.next_state).toBe(ConversationState.PAYMENT);
// Paso 4: PAYMENT con payment_selected -> WAITING
result = safeNextState(ConversationState.PAYMENT, {}, { payment_selected: true });
expect(result.next_state).toBe(ConversationState.WAITING_WEBHOOKS);
r = safeNextState(ConversationState.SHIPPING, { is_delivery: false }, { shipping_completed: true });
expect(r.next_state).toBe(ConversationState.IDLE);
});
});

View File

@@ -0,0 +1,51 @@
/**
* Geometría liviana para validar punto-en-polígono sin deps.
*
* Polígonos vienen en GeoJSON: { type: "Polygon", coordinates: [[[lng,lat],...]] }.
* GeoJSON usa orden [lng, lat] (X, Y). Mantenemos esa convención puertas adentro.
*/
/**
* Ray casting (algoritmo clásico). Devuelve true si (lng, lat) cae dentro del
* anillo exterior del polígono. No considera agujeros (holes) — para CABA y un
* editor que dibuja polígonos simples, alcanza.
*
* @param {number} lng
* @param {number} lat
* @param {{type:string, coordinates:Array<Array<[number,number]>>}} polygon
* @returns {boolean}
*/
export function pointInPolygon(lng, lat, polygon) {
if (!polygon || polygon.type !== "Polygon" || !Array.isArray(polygon.coordinates)) return false;
const ring = polygon.coordinates[0];
if (!Array.isArray(ring) || ring.length < 3) return false;
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0], yi = ring[i][1];
const xj = ring[j][0], yj = ring[j][1];
const intersect =
(yi > lat) !== (yj > lat) &&
lng < ((xj - xi) * (lat - yi)) / (yj - yi + 0) + xi;
if (intersect) inside = !inside;
}
return inside;
}
/**
* Busca la primera zona habilitada cuyo polígono contiene al punto.
*
* @param {number} lng
* @param {number} lat
* @param {Array<Object>} zones - { id, name, polygon, enabled, ... }
* @returns {Object|null}
*/
export function findZoneForPoint(lng, lat, zones) {
if (!Array.isArray(zones)) return null;
for (const z of zones) {
if (z?.enabled === false) continue;
if (!z?.polygon) continue;
if (pointInPolygon(lng, lat, z.polygon)) return z;
}
return null;
}

View File

@@ -0,0 +1,103 @@
import { pointInPolygon, findZoneForPoint } from "./geo.js";
const square = {
type: "Polygon",
coordinates: [[
[0, 0],
[10, 0],
[10, 10],
[0, 10],
[0, 0],
]],
};
const concave = {
type: "Polygon",
coordinates: [[
[0, 0],
[10, 0],
[10, 10],
[5, 5],
[0, 10],
[0, 0],
]],
};
describe("pointInPolygon", () => {
it("returns true para un punto dentro de un cuadrado", () => {
expect(pointInPolygon(5, 5, square)).toBe(true);
});
it("returns false para un punto fuera del cuadrado", () => {
expect(pointInPolygon(15, 5, square)).toBe(false);
expect(pointInPolygon(-1, 5, square)).toBe(false);
expect(pointInPolygon(5, 20, square)).toBe(false);
});
it("maneja polígonos cóncavos (excluye el dent del centro)", () => {
// (5, 8) cae dentro del notch — fuera del polígono cóncavo.
expect(pointInPolygon(5, 8, concave)).toBe(false);
// (2, 2) sigue dentro.
expect(pointInPolygon(2, 2, concave)).toBe(true);
});
it("returns false para input inválido", () => {
expect(pointInPolygon(0, 0, null)).toBe(false);
expect(pointInPolygon(0, 0, { type: "Point" })).toBe(false);
expect(pointInPolygon(0, 0, { type: "Polygon", coordinates: [[[0, 0], [1, 1]]] })).toBe(false);
});
it("trabaja con coordenadas reales de CABA (lng, lat)", () => {
// Polígono cuadrado pequeño alrededor del Obelisco.
const obeliscoBox = {
type: "Polygon",
coordinates: [[
[-58.39, -34.61],
[-58.37, -34.61],
[-58.37, -34.60],
[-58.39, -34.60],
[-58.39, -34.61],
]],
};
// Obelisco aprox: -58.3816, -34.6037
expect(pointInPolygon(-58.3816, -34.6037, obeliscoBox)).toBe(true);
// Mar del Plata
expect(pointInPolygon(-57.55, -38.0, obeliscoBox)).toBe(false);
});
});
describe("findZoneForPoint", () => {
const zones = [
{ id: "centro", name: "Centro", polygon: square, enabled: true, delivery_cost: 1500 },
{
id: "norte",
name: "Norte",
enabled: true,
polygon: {
type: "Polygon",
coordinates: [[[20, 20], [30, 20], [30, 30], [20, 30], [20, 20]]],
},
delivery_cost: 2000,
},
];
it("devuelve la zona que contiene al punto", () => {
const z = findZoneForPoint(5, 5, zones);
expect(z?.id).toBe("centro");
});
it("devuelve null si ningún polígono contiene al punto", () => {
expect(findZoneForPoint(15, 15, zones)).toBeNull();
});
it("ignora zonas con enabled=false", () => {
const muted = zones.map((z) => ({ ...z, enabled: z.id === "centro" ? false : z.enabled }));
expect(findZoneForPoint(5, 5, muted)).toBeNull();
});
it("tolera input inválido", () => {
expect(findZoneForPoint(0, 0, null)).toBeNull();
expect(findZoneForPoint(0, 0, [])).toBeNull();
expect(findZoneForPoint(0, 0, [{ id: "x" }])).toBeNull();
});
});

View File

@@ -1,73 +0,0 @@
Sos {{bot_name}}, asistente de {{store_name}}. Procesá consultas sobre el catálogo.
TIPOS DE CONSULTAS:
1. price_query - Consulta de precios
Señales: "cuánto sale", "precio de", "cuánto cuesta", "a cuánto está"
Extraer: product_query (el producto que pregunta)
2. browse - Consulta de disponibilidad
Señales: "tenés", "hay", "vendés", "tienen"
Extraer: product_query
3. recommend - Pedido de recomendación/planificación
Señales: "qué me recomendás", "qué llevo", "para X personas", "para un asado"
Extraer:
- people_count: número de personas si lo menciona
- event_type: tipo de evento (asado, cumple, reunión)
- product_query: producto específico si lo menciona
EJEMPLOS:
Input: "cuánto sale el vacío?"
Output:
{
"intent": "price_query",
"product_query": "vacío",
"people_count": null,
"event_type": null
}
Input: "tenés chimichurri?"
Output:
{
"intent": "browse",
"product_query": "chimichurri",
"people_count": null,
"event_type": null
}
Input: "qué me recomendás para 8 personas?"
Output:
{
"intent": "recommend",
"product_query": null,
"people_count": 8,
"event_type": "asado"
}
Input: "para un asado de 6, qué llevo?"
Output:
{
"intent": "recommend",
"product_query": null,
"people_count": 6,
"event_type": "asado"
}
Input: "qué vino va bien con carne?"
Output:
{
"intent": "recommend",
"product_query": "vino",
"people_count": null,
"event_type": null
}
FORMATO JSON:
{
"intent": "price_query|browse|recommend",
"product_query": "texto" | null,
"people_count": number | null,
"event_type": "asado|cumple|reunion" | null
}

View File

@@ -1,23 +0,0 @@
Sos {{bot_name}}, el asistente virtual de {{store_name}}.
PERSONALIDAD:
- Carnicero profesional argentino con años de experiencia
- Usás voseo natural (vos, querés, tenés, decime)
- Amable y cálido pero eficiente, no muy formal
- Conocedor de cortes de carne y tradiciones del asado argentino
- Podés hacer algún comentario simpático sobre el asado si viene al caso
- Respuestas concisas, no te extendés demasiado
CONTEXTO DEL NEGOCIO:
- Horario: {{store_hours}}
- Dirección: {{store_address}}
INSTRUCCIONES:
El cliente te saluda. Respondé de forma cálida y preguntá en qué podés ayudar.
Si hay alguna promo del día o corte destacado, mencionalo brevemente.
FORMATO DE RESPUESTA (JSON):
{
"intent": "greeting",
"reply": "tu respuesta al cliente"
}

View File

@@ -1,120 +0,0 @@
Sos un sistema NLU para una carnicería argentina. Extraé productos del mensaje del usuario.
REGLAS CRÍTICAS (seguir estrictamente):
0. EXTRAER TODOS LOS PRODUCTOS - NUNCA OMITIR NINGUNO
Si el mensaje menciona 5 productos, el array items DEBE tener 5 elementos.
NUNCA omitas productos, incluso si no estás seguro del nombre exacto.
Extraé cada producto mencionado, separado por comas, "y", saltos de línea, etc.
1. SIEMPRE USAR ARRAY "items"
Aunque sea UN SOLO producto, SIEMPRE devolver un array "items" con al menos un elemento.
Cada item tiene: product_query, quantity, unit
2. COPIAR TEXTO EXACTO
El campo "product_query" debe ser el texto EXACTO que usó el cliente.
- Si dice "asado de tira" → product_query: "asado de tira"
- Si dice "vacío" → product_query: "vacío"
- Si dice "carre de cerdo" → product_query: "carre de cerdo"
- Si dice "provoletas wapi" → product_query: "provoletas wapi"
- NUNCA modifiques, combines ni inventes nombres
3. EXTRAER CANTIDADES (pueden estar antes o después del producto)
- "2kg de X" → quantity: 2, unit: "kg"
- "X 1kg" → quantity: 1, unit: "kg" (cantidad después del producto)
- "3 provoletas" → quantity: 3, unit: "unidad"
- "medio kilo" → quantity: 0.5, unit: "kg"
- Sin cantidad → quantity: null
4. UNIDADES
- kg: kilos, kilo, kilogramo
- g: gramos, gr
- unidad: unidades, u (para productos que no se pesan)
5. INTENTS
- add_to_cart: agregar productos (quiero, dame, anotame, poneme, hola quiero)
- remove_from_cart: quitar productos (sacame, quitame)
- view_cart: ver carrito (qué tengo, qué anoté, mi pedido)
- confirm_order: cerrar pedido (listo, eso es todo, cerrar)
EJEMPLOS:
Input: "hola, quiero 1kg de asado, vacio, carre de cerdo 1kg, chorizo mixto 1kg y 3 provoletas wapi"
Output:
{
"intent": "add_to_cart",
"confidence": 0.95,
"items": [
{"product_query": "asado", "quantity": 1, "unit": "kg"},
{"product_query": "vacio", "quantity": null, "unit": null},
{"product_query": "carre de cerdo", "quantity": 1, "unit": "kg"},
{"product_query": "chorizo mixto", "quantity": 1, "unit": "kg"},
{"product_query": "provoletas wapi", "quantity": 3, "unit": "unidad"}
]
}
Input: "Te pido:\n2kg de vacío\n3kg de asado de tira\n1kg de chorizos mixtos\n2 provoletas"
Output:
{
"intent": "add_to_cart",
"confidence": 0.95,
"items": [
{"product_query": "vacío", "quantity": 2, "unit": "kg"},
{"product_query": "asado de tira", "quantity": 3, "unit": "kg"},
{"product_query": "chorizos mixtos", "quantity": 1, "unit": "kg"},
{"product_query": "provoletas", "quantity": 2, "unit": "unidad"}
]
}
Input: "dame 1kg de vacío"
Output:
{
"intent": "add_to_cart",
"confidence": 0.95,
"items": [
{"product_query": "vacío", "quantity": 1, "unit": "kg"}
]
}
Input: "quiero asado"
Output:
{
"intent": "add_to_cart",
"confidence": 0.9,
"items": [
{"product_query": "asado", "quantity": null, "unit": null}
]
}
Input: "sacame el chorizo"
Output:
{
"intent": "remove_from_cart",
"confidence": 0.9,
"items": [
{"product_query": "chorizo", "quantity": null, "unit": null}
]
}
Input: "qué tengo anotado?"
Output:
{
"intent": "view_cart",
"confidence": 0.95,
"items": []
}
Input: "listo, eso sería todo"
Output:
{
"intent": "confirm_order",
"confidence": 0.95,
"items": []
}
FORMATO JSON ESTRICTO:
{
"intent": "add_to_cart|remove_from_cart|view_cart|confirm_order",
"confidence": 0.0-1.0,
"items": [{product_query, quantity, unit}, ...]
}

View File

@@ -1,60 +0,0 @@
Extraé información de pago del mensaje del usuario.
ENTIDADES A EXTRAER:
1. payment_method
- "cash": pago en efectivo
Señales: efectivo, cash, plata, en mano
- "link": pago electrónico (tarjeta, transferencia, link de pago)
Señales: tarjeta, link, transferencia, QR, mercadopago, MP
- null: no se puede determinar
EJEMPLOS:
Input: "efectivo"
Output:
{
"intent": "select_payment",
"payment_method": "cash"
}
Input: "con tarjeta"
Output:
{
"intent": "select_payment",
"payment_method": "link"
}
Input: "link de pago"
Output:
{
"intent": "select_payment",
"payment_method": "link"
}
Input: "pago cuando llega"
Output:
{
"intent": "select_payment",
"payment_method": "cash"
}
Input: "transferencia"
Output:
{
"intent": "select_payment",
"payment_method": "link"
}
Input: "1" (si el contexto indica que 1=efectivo)
Output:
{
"intent": "select_payment",
"payment_method": "cash"
}
FORMATO JSON:
{
"intent": "select_payment",
"payment_method": "cash" | "link" | null
}

View File

@@ -1,33 +0,0 @@
Clasificá el dominio del mensaje del usuario. Respondé SOLO JSON válido.
{"domain":"greeting|orders|shipping|payment|browse|other"}
REGLAS DE CLASIFICACIÓN:
1. greeting - Saludos sin mención de productos
- "hola", "buen día", "buenas tardes", "qué tal", "hey"
- NO si menciona productos junto al saludo
2. orders - Todo relacionado con pedidos y productos
- Agregar productos: "quiero", "dame", "anotame", "poneme", cantidad + producto
- Quitar productos: "sacame", "quitame", "no quiero"
- Ver carrito: "qué tengo", "qué anoté", "mi pedido"
- Confirmar: "listo", "eso es todo", "cerrar pedido"
3. shipping - Envío y entrega
- Método: "delivery", "envío", "retiro", "buscar", "sucursal"
- Dirección: textos con calle, número, barrio
4. payment - Métodos de pago
- "efectivo", "tarjeta", "transferencia", "link", "mercadopago"
5. browse - Consultas de catálogo
- Precios: "cuánto sale", "precio de"
- Disponibilidad: "tenés", "hay", "vendés"
- Recomendaciones: "qué me recomendás", "para X personas"
6. other - Cualquier otra cosa
Estado actual: {{state}}
Mensaje a clasificar: [se provee en el input]

View File

@@ -1,64 +0,0 @@
Extraé información de envío del mensaje del usuario.
ENTIDADES A EXTRAER:
1. shipping_method
- "delivery": el cliente quiere que le lleven el pedido
Señales: delivery, envío, enviar, que me lo traigan, llevar
- "pickup": el cliente pasa a buscar
Señales: retiro, retirar, buscar, paso, sucursal
- null: no se puede determinar
2. address
- Texto de la dirección de entrega
- Solo extraer si hay datos concretos (calle, número, barrio, etc.)
- null: si no hay dirección
EJEMPLOS:
Input: "delivery"
Output:
{
"intent": "select_shipping",
"shipping_method": "delivery",
"address": null
}
Input: "paso a buscar"
Output:
{
"intent": "select_shipping",
"shipping_method": "pickup",
"address": null
}
Input: "Av. Corrientes 1234, Almagro"
Output:
{
"intent": "provide_address",
"shipping_method": null,
"address": "Av. Corrientes 1234, Almagro"
}
Input: "delivery a Palermo, calle Honduras 5000"
Output:
{
"intent": "select_shipping",
"shipping_method": "delivery",
"address": "Palermo, calle Honduras 5000"
}
Input: "1" (si el contexto indica que 1=delivery)
Output:
{
"intent": "select_shipping",
"shipping_method": "delivery",
"address": null
}
FORMATO JSON:
{
"intent": "select_shipping|provide_address",
"shipping_method": "delivery" | "pickup" | null,
"address": "texto de dirección" | null
}

View File

@@ -1,164 +0,0 @@
/**
* Human Fallback - Lógica para escalar conversaciones a humanos
*
* Se activa cuando:
* - No se encuentra un producto en el catálogo
* - El NLU tiene baja confianza
* - Casos especiales que requieren atención humana
*/
import { ConversationState } from "../fsm.js";
import { createEmptyOrder } from "../orderModel.js";
/**
* Crea una respuesta de takeover para cuando no se encuentra un producto
*
* @param {Object} params
* @param {string} params.pendingQuery - La query/producto que no se encontró
* @param {Object} params.order - Estado actual del pedido
* @param {Object} params.context - Contexto adicional para el humano
* @returns {Object} Resultado con plan y decision para el pipeline
*/
export function createHumanTakeoverResponse({ pendingQuery, order, context = {} }) {
const currentOrder = order || createEmptyOrder();
// Mensaje amigable para el usuario
const reply = `Dejame consultar con el equipo sobre "${pendingQuery}". Te respondo en un momento.`;
return {
plan: {
reply,
next_state: ConversationState.AWAITING_HUMAN,
intent: "human_takeover",
missing_fields: ["human_response"],
order_action: "none",
},
decision: {
actions: [
{
type: "request_human_takeover",
payload: {
pending_query: pendingQuery,
reason: "product_not_found",
context_snapshot: {
order: currentOrder,
...context,
},
},
},
],
order: currentOrder,
audit: {
human_takeover_requested: true,
pending_query: pendingQuery,
},
},
};
}
/**
* Verifica si debería escalar a humano basado en los resultados del catálogo
*
* @param {Object} params
* @param {Array} params.candidates - Candidatos encontrados en el catálogo
* @param {string} params.query - Query original del usuario
* @param {number} params.confidenceThreshold - Umbral de confianza mínimo
* @returns {boolean} true si debería escalar a humano
*/
export function shouldEscalateToHuman({ candidates = [], query, confidenceThreshold = 0.3 }) {
// Si no hay candidatos, escalar
if (!candidates || candidates.length === 0) {
return true;
}
// Si el mejor candidato tiene score muy bajo, escalar
const bestScore = candidates[0]?._score || 0;
if (bestScore < confidenceThreshold) {
return true;
}
// Si la query es muy diferente al nombre del mejor candidato (por nombre)
// Esto es un heurístico simple para detectar confusiones
const bestName = (candidates[0]?.name || "").toLowerCase();
const queryLower = (query || "").toLowerCase();
// Si no hay overlap significativo de palabras, podría ser confusión
const queryWords = queryLower.split(/\s+/).filter(w => w.length > 2);
const nameWords = bestName.split(/\s+/).filter(w => w.length > 2);
if (queryWords.length > 0 && nameWords.length > 0) {
const overlap = queryWords.filter(qw =>
nameWords.some(nw => nw.includes(qw) || qw.includes(nw))
);
// Si hay muy poco overlap y el score no es muy alto, escalar
if (overlap.length === 0 && bestScore < 0.7) {
return true;
}
}
return false;
}
/**
* Genera mensaje de respuesta cuando el humano responde al takeover
*
* @param {Object} params
* @param {string} params.humanResponse - Respuesta del humano
* @param {Object} params.order - Estado actual del pedido
* @returns {Object} Resultado para continuar el flujo normal
*/
export function createHumanResponseResult({ humanResponse, order }) {
const currentOrder = order || createEmptyOrder();
return {
plan: {
reply: humanResponse,
next_state: ConversationState.CART, // Volver al flujo normal
intent: "human_response",
missing_fields: [],
order_action: "none",
},
decision: {
actions: [
{
type: "human_response_sent",
payload: {},
},
],
order: currentOrder,
audit: {
human_response_processed: true,
},
},
};
}
/**
* Verifica si el estado actual es AWAITING_HUMAN
*/
export function isAwaitingHuman(state) {
return state === ConversationState.AWAITING_HUMAN || state === "AWAITING_HUMAN";
}
/**
* Genera respuesta cuando el usuario envía mensaje mientras está en AWAITING_HUMAN
*/
export function createWaitingForHumanResponse({ order }) {
const currentOrder = order || createEmptyOrder();
return {
plan: {
reply: "Estoy esperando respuesta del equipo sobre tu consulta. Te aviso apenas tenga novedades.",
next_state: ConversationState.AWAITING_HUMAN,
intent: "other",
missing_fields: ["human_response"],
order_action: "none",
},
decision: {
actions: [],
order: currentOrder,
audit: { still_waiting_human: true },
},
};
}

Some files were not shown because too many files have changed in this diff Show More