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>
This commit is contained in:
Lucas Tettamanti
2026-05-02 15:31:25 -03:00
parent 0bf26f8eb5
commit aed79078de
22 changed files with 1288 additions and 327 deletions

View File

@@ -0,0 +1,363 @@
/**
* <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 _libsPromise = null;
function ensureLeaflet() {
if (window.L && window.L.PM) return Promise.resolve();
if (_libsPromise) return _libsPromise;
_libsPromise = (async () => {
if (!document.querySelector(`link[href="${LEAFLET_CSS}"]`)) {
const l1 = document.createElement("link");
l1.rel = "stylesheet"; l1.href = LEAFLET_CSS;
document.head.appendChild(l1);
}
if (!document.querySelector(`link[href="${GEOMAN_CSS}"]`)) {
const l2 = document.createElement("link");
l2.rel = "stylesheet"; l2.href = GEOMAN_CSS;
document.head.appendChild(l2);
}
if (!window.L) {
await loadScript(LEAFLET_JS);
}
if (!window.L.PM) {
await loadScript(GEOMAN_JS);
}
})();
return _libsPromise;
}
function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) {
resolve();
return;
}
const s = document.createElement("script");
s.src = src;
s.async = true;
s.onload = () => resolve();
s.onerror = () => reject(new Error(`Failed to load ${src}`));
document.head.appendChild(s);
});
}
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";
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.appendChild(this._mapDiv);
ensureLeaflet().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>`;
});
}
disconnectedCallback() {
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();
}
_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);
}