From aed79078dedb207221b43988d26f5e7c2ac28212 Mon Sep 17 00:00:00 2001 From: Lucas Tettamanti Date: Sat, 2 May 2026 15:31:25 -0300 Subject: [PATCH] =?UTF-8?q?Zonas=20de=20delivery=20por=20pol=C3=ADgono=20+?= =?UTF-8?q?=20horarios=20+=20location=20share=20por=20WhatsApp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...0260502181148_drop_legacy_zones_format.sql | 13 + public/app.js | 1 + public/components/settings-crud.js | 478 +++++++++++------- public/components/zone-map-editor.js | 363 +++++++++++++ src/modules/0-ui/db/settingsRepo.js | 5 +- src/modules/1-intake/handlers/evolution.js | 1 + src/modules/1-intake/handlers/sim.js | 16 +- .../1-intake/services/evolutionParser.js | 14 +- src/modules/2-identity/services/pipeline.js | 17 + src/modules/3-turn-engine/agent/runTurn.js | 2 + .../3-turn-engine/agent/systemPrompt.js | 26 +- .../3-turn-engine/agent/tools/confirmOrder.js | 106 +++- .../3-turn-engine/agent/tools/executor.js | 2 + .../3-turn-engine/agent/tools/schemas.js | 30 +- .../3-turn-engine/agent/tools/setAddress.js | 80 ++- .../agent/tools/setDeliveryWindow.js | 26 + .../3-turn-engine/agent/tools/setShipping.js | 44 +- .../3-turn-engine/agent/workingMemory.js | 15 +- src/modules/3-turn-engine/lib/geo.js | 51 ++ src/modules/3-turn-engine/lib/geo.test.js | 103 ++++ src/modules/3-turn-engine/orderModel.js | 3 + src/modules/3-turn-engine/storeContext.js | 219 ++++---- 22 files changed, 1288 insertions(+), 327 deletions(-) create mode 100644 db/migrations/20260502181148_drop_legacy_zones_format.sql create mode 100644 public/components/zone-map-editor.js create mode 100644 src/modules/3-turn-engine/agent/tools/setDeliveryWindow.js create mode 100644 src/modules/3-turn-engine/lib/geo.js create mode 100644 src/modules/3-turn-engine/lib/geo.test.js diff --git a/db/migrations/20260502181148_drop_legacy_zones_format.sql b/db/migrations/20260502181148_drop_legacy_zones_format.sql new file mode 100644 index 0000000..5439bd4 --- /dev/null +++ b/db/migrations/20260502181148_drop_legacy_zones_format.sql @@ -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; diff --git a/public/app.js b/public/app.js index 0190944..77a3bc5 100644 --- a/public/app.js +++ b/public/app.js @@ -12,6 +12,7 @@ import "./components/quantities-crud.js"; import "./components/orders-crud.js"; import "./components/test-panel.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"; diff --git a/public/components/settings-crud.js b/public/components/settings-crud.js index b355632..39c542e 100644 --- a/public/components/settings-crud.js +++ b/public/components/settings-crud.js @@ -10,19 +10,12 @@ const DAYS = [ { id: "dom", label: "Domingo", short: "D" }, ]; -// Lista oficial de 48 barrios de CABA -const CABA_BARRIOS = [ - "Agronomía", "Almagro", "Balvanera", "Barracas", "Belgrano", "Boedo", - "Caballito", "Chacarita", "Coghlan", "Colegiales", "Constitución", - "Flores", "Floresta", "La Boca", "La Paternal", "Liniers", - "Mataderos", "Monte Castro", "Montserrat", "Nueva Pompeya", "Núñez", - "Palermo", "Parque Avellaneda", "Parque Chacabuco", "Parque Chas", - "Parque Patricios", "Puerto Madero", "Recoleta", "Retiro", "Saavedra", - "San Cristóbal", "San Nicolás", "San Telmo", "Vélez Sársfield", "Versalles", - "Villa Crespo", "Villa del Parque", "Villa Devoto", "Villa General Mitre", - "Villa Lugano", "Villa Luro", "Villa Ortúzar", "Villa Pueyrredón", - "Villa Real", "Villa Riachuelo", "Villa Santa Rita", "Villa Soldati", "Villa Urquiza" -]; +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() { @@ -31,6 +24,9 @@ class SettingsCrud extends HTMLElement { this.settings = null; this.loading = false; this.saving = false; + this.zones = []; + this.selectedZoneId = null; + this._mapEditor = null; this.shadowRoot.innerHTML = ` @@ -204,14 +180,12 @@ class SettingsCrud extends HTMLElement { try { this.settings = await api.getSettings(); - // Asegurar que schedule existe if (!this.settings.schedule) { this.settings.schedule = { delivery: {}, pickup: {} }; } - // Asegurar que delivery_zones existe - if (!this.settings.delivery_zones) { - this.settings.delivery_zones = {}; - } + 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) { @@ -275,70 +249,115 @@ class SettingsCrud extends HTMLElement { }).join(""); } - // Convierte nombre de barrio a key (ej: "Villa Crespo" -> "villa_crespo") - barrioToKey(name) { - return name.toLowerCase() - .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // quitar acentos - .replace(/\s+/g, "_"); + ZONE_PALETTE_VARS() { + return ["--chart-blue", "--chart-green", "--chart-purple", "--chart-orange", "--chart-pink", "--chart-gray"]; } - getZoneConfig(barrioKey) { - return this.settings?.delivery_zones?.[barrioKey] || null; + zoneSwatchColor(idx) { + const palette = this.ZONE_PALETTE_VARS(); + return `var(${palette[idx % palette.length]})`; } - setZoneConfig(barrioKey, config) { - if (!this.settings.delivery_zones) { - this.settings.delivery_zones = {}; - } - if (config === null) { - delete this.settings.delivery_zones[barrioKey]; - } else { - this.settings.delivery_zones[barrioKey] = config; + 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() { - return CABA_BARRIOS.map(barrio => { - const key = this.barrioToKey(barrio); - const config = this.getZoneConfig(key); - const isActive = config?.enabled === true; - const days = config?.days || []; - const cost = config?.delivery_cost || 0; - + if (!this.zones.length) { + return `
No hay zonas dibujadas todavía. Tocá Crear zona y dibujá un polígono en el mapa.
`; + } + 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 ` -
-
-
- ${barrio} -
-
- ${DAYS.map(d => ` -
${d.short}
- `).join("")} -
-
- - -
-
+
+
+
+
${this.escapeHtml(z.name || z.id)}
+
${this.escapeHtml(meta || "sin configurar")}
`; }).join(""); } - renderZonesSummary() { - const zones = this.settings?.delivery_zones || {}; - const activeZones = Object.entries(zones).filter(([k, v]) => v?.enabled); - - if (activeZones.length === 0) { - return `
No hay zonas de entrega configuradas. Activá los barrios donde hacés delivery.
`; + renderZoneForm() { + const z = this.zones.find((x) => x.id === this.selectedZoneId); + if (!z) { + return `
Seleccioná una zona en la lista o dibujá una nueva en el mapa para configurarla.
`; } - - return `
${activeZones.length} zona${activeZones.length > 1 ? 's' : ''} de entrega activa${activeZones.length > 1 ? 's' : ''}
`; + 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 ` +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ ${DAYS.map((d) => ` + ${d.short} + `).join("")} +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ `; + } + + renderZonesSummary() { + const enabled = this.zones.filter((z) => z.enabled !== false); + if (!this.zones.length) { + return `
Dibujá al menos una zona en el mapa para que el bot pueda confirmar envíos. Sin zonas, todas las direcciones quedan sin validar.
`; + } + return `
${enabled.length} de ${this.zones.length} zona${this.zones.length > 1 ? "s" : ""} habilitada${enabled.length === 1 ? "" : "s"}.
`; } render() { @@ -434,18 +453,31 @@ class SettingsCrud extends HTMLElement {
- Zonas de Entrega (Barrios CABA) + Zonas de Entrega
- -