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,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;
}