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

@@ -1,39 +1,40 @@
/**
* Store Context - Helpers para inyectar info de la tienda en respuestas.
* Store Context — helpers para inyectar info de la tienda en el agente.
*
* Estos helpers leen del storeConfig ya cargado por getStoreConfig (en
* pipeline / turnEngine) y producen variables consumibles por reply templates
* (renderReply via {{var}}) y por el reply rewriter (como hint contextual).
*
* Filosofía: cuando los datos no están cargados, las vars resuelven a strings
* vacíos / hints neutros. Los templates están diseñados para tolerar ausencia.
* Schema canónico de delivery_zones (JSONB en tenant_settings):
* {
* zones: [
* {
* id: "centro",
* name: "Centro / Microcentro",
* polygon: { type: "Polygon", coordinates: [[[lng,lat], ...]] },
* delivery_cost: 1500,
* delivery_days: ["lun","mar","mie","jue","vie","sab"],
* delivery_hours: { start: "10:00", end: "20:00" },
* min_order_amount: 0,
* enabled: true
* }
* ],
* default_center: [lng, lat] // opcional, para el editor
* }
*/
const DAY_KEYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
const DAY_KEYS_LONG = ["lunes", "martes", "miercoles", "jueves", "viernes", "sabado", "domingo"];
import { findZoneForPoint } from "./lib/geo.js";
/**
* Devuelve la clave de día (mon..sun) para hoy.
*/
function todayKey() {
// Date.getDay(): 0 = domingo, 1 = lunes
const d = new Date().getDay();
// mapear a mon..sun
return DAY_KEYS[(d + 6) % 7];
const DAY_KEYS_SHORT = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
const DAY_NAMES = {
lun: "Lunes", mar: "Martes", mie: "Miércoles",
jue: "Jueves", vie: "Viernes", sab: "Sábado", dom: "Domingo",
};
function todayShortKey() {
// Date.getDay(): 0=Dom..6=Sab → mapeamos a lun..dom (0=lun)
return DAY_KEYS_SHORT[(new Date().getDay() + 6) % 7];
}
/**
* Extrae el horario (string) de un day-key del schedule jsonb.
* Formato esperado: { mon: { start: '09:00', end: '14:00', enabled: true }, ... }
* Tolera variantes (lunes, monday, etc.) y formatos planos.
*/
function pickDaySlot(scheduleObj, dayIdx) {
function pickDaySlot(scheduleObj, dayKey) {
if (!scheduleObj || typeof scheduleObj !== "object") return null;
const keys = [DAY_KEYS[dayIdx], DAY_KEYS_LONG[dayIdx]];
for (const k of keys) {
if (scheduleObj[k]) return scheduleObj[k];
}
return null;
return scheduleObj[dayKey] || null;
}
function formatDaySlot(slot) {
@@ -45,104 +46,114 @@ function formatDaySlot(slot) {
return `${start} a ${end}`;
}
/**
* Resumen de zonas de delivery: lista de barrios o "Consultar zonas".
*/
/**
* Lista plana de barrios (lowercase) habilitados para delivery.
*/
function getDeliveryZoneNames(deliveryZones) {
function getZones(deliveryZones) {
if (!deliveryZones || typeof deliveryZones !== "object") return [];
const names = [];
if (Array.isArray(deliveryZones.zones)) {
for (const z of deliveryZones.zones) if (z?.name) names.push(z.name);
} else if (deliveryZones.caba?.barrios) {
if (Array.isArray(deliveryZones.caba.barrios)) names.push(...deliveryZones.caba.barrios);
} else {
for (const [k, v] of Object.entries(deliveryZones)) {
if (v === true) names.push(k);
else if (v?.name) names.push(v.name);
}
}
return names.map((n) => String(n).toLowerCase().trim()).filter(Boolean);
if (!Array.isArray(deliveryZones.zones)) return [];
return deliveryZones.zones.filter((z) => z && z.enabled !== false);
}
/**
* Verifica si un texto de dirección menciona un barrio en zona.
* - Sin zonas configuradas: retorna `{ inZone: true, reason: "no_zones_configured" }`
* (no bloqueamos hasta que el comercio cargue las zonas).
* - Con zonas: matching simple por inclusion del barrio en el texto (lowercase, sin tildes).
* Devuelve los nombres de zonas habilitadas (sin polygon, para UI/log).
*/
export function checkAddressInZone({ address, storeConfig }) {
const zones = getDeliveryZoneNames(storeConfig?.delivery_zones);
if (!zones.length) {
export function getDeliveryZoneNames(deliveryZones) {
return getZones(deliveryZones).map((z) => String(z.name || z.id || "").trim()).filter(Boolean);
}
/**
* Verifica si una ubicación (lat/lng) cae dentro de alguna zona habilitada.
*
* @param {Object} args
* @param {{lat:number, lng:number}} [args.location]
* @param {Object} args.storeConfig
* @returns {{inZone:boolean, reason:string, matched_zone?:Object, zones:Array}}
*/
export function checkAddressInZone({ location, storeConfig }) {
const allZones = getZones(storeConfig?.delivery_zones);
if (!allZones.length) {
return { inZone: true, reason: "no_zones_configured", zones: [] };
}
const norm = String(address || "")
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.trim();
if (!norm) return { inZone: false, reason: "empty_address", zones };
const match = zones.find((z) => {
const zNorm = z.normalize("NFD").replace(/[̀-ͯ]/g, "");
return norm.includes(zNorm);
});
if (match) return { inZone: true, reason: "matched", matched: match, zones };
return { inZone: false, reason: "not_in_zone", zones };
if (!location || typeof location.lat !== "number" || typeof location.lng !== "number") {
return {
inZone: false,
reason: "need_location",
zones: allZones.map(zoneSummary),
};
}
const matched = findZoneForPoint(location.lng, location.lat, allZones);
if (matched) {
return { inZone: true, reason: "matched", matched_zone: zoneSummary(matched), zones: allZones.map(zoneSummary) };
}
return { inZone: false, reason: "out_of_zones", zones: allZones.map(zoneSummary) };
}
function summarizeDeliveryZones(deliveryZones) {
if (!deliveryZones || typeof deliveryZones !== "object") return "";
const names = [];
// Soporta varios formatos:
// 1) { caba: { barrios: ["Palermo", "Belgrano"] } }
// 2) { zones: [{ name }] }
// 3) { palermo: true, belgrano: true } (flat)
if (Array.isArray(deliveryZones.zones)) {
for (const z of deliveryZones.zones) if (z?.name) names.push(z.name);
} else if (deliveryZones.caba?.barrios) {
if (Array.isArray(deliveryZones.caba.barrios)) names.push(...deliveryZones.caba.barrios);
} else {
for (const [k, v] of Object.entries(deliveryZones)) {
if (v === true) names.push(k);
else if (v?.name) names.push(v.name);
}
}
if (!names.length) return "";
if (names.length <= 5) return names.join(", ");
return `${names.slice(0, 5).join(", ")} y otros`;
function zoneSummary(z) {
return {
id: z.id,
name: z.name,
delivery_cost: z.delivery_cost ?? null,
delivery_days: Array.isArray(z.delivery_days) ? z.delivery_days : [],
delivery_hours: z.delivery_hours || null,
min_order_amount: z.min_order_amount ?? 0,
};
}
/**
* Construye variables de contexto de tienda para usar en reply templates.
* Cuando los datos no están, las vars vienen vacías — los templates las
* absorben sin romper.
*
* @param {Object} storeConfig - resultado de getStoreConfig({ tenantId })
* @returns {Object} vars para applyVariables / renderReply
* Formato compacto que ve el LLM en working_memory.store.delivery.zones[].
* Sin polygon (no le sirve al modelo), sí con costos/días/horas.
*/
export function buildZonesForLLM(deliveryZones) {
return getZones(deliveryZones).map(zoneSummary);
}
/**
* Resumen humano para system/replies cuando el LLM lo necesita en prosa.
* Ejemplo: "Centro ($1500, lun-sab 10-20h), Palermo ($2000, mar/jue 11-19h)".
*/
export function summarizeDeliveryZones(deliveryZones) {
const zones = getZones(deliveryZones);
if (!zones.length) return "";
return zones.map((z) => {
const cost = z.delivery_cost != null ? `$${Number(z.delivery_cost).toLocaleString("es-AR")}` : "";
const days = formatDaysList(z.delivery_days);
const hours = z.delivery_hours?.start && z.delivery_hours?.end
? `${z.delivery_hours.start.slice(0, 5)}-${z.delivery_hours.end.slice(0, 5)}h`
: "";
const tail = [cost, days, hours].filter(Boolean).join(" ");
return tail ? `${z.name} (${tail})` : z.name;
}).join(", ");
}
function formatDaysList(days) {
if (!Array.isArray(days) || !days.length) return "";
// Detectar rango consecutivo lun-sab etc.
const idx = days.map((d) => DAY_KEYS_SHORT.indexOf(d)).filter((i) => i >= 0).sort((a, b) => a - b);
if (idx.length >= 3) {
const isContiguous = idx.every((v, i, arr) => i === 0 || v === arr[i - 1] + 1);
if (isContiguous) return `${DAY_KEYS_SHORT[idx[0]]}-${DAY_KEYS_SHORT[idx[idx.length - 1]]}`;
}
return idx.map((i) => DAY_KEYS_SHORT[i]).join("/");
}
/**
* Construye variables de contexto de tienda. El bot ya no usa templates de
* texto, pero workingMemory sigue tomando algunos de estos campos.
*/
export function buildStoreContextVars(storeConfig = {}) {
const dayIdx = (new Date().getDay() + 6) % 7; // 0=lunes
const dayKey = todayShortKey();
const sched = storeConfig.schedule || {};
const deliverySlot = pickDaySlot(sched.delivery, dayIdx);
const pickupSlot = pickDaySlot(sched.pickup, dayIdx);
const storeHoursToday = formatDaySlot(deliverySlot) || formatDaySlot(pickupSlot) || "";
const deliveryAvailableNow = deliverySlot ? "sí" : (storeConfig.deliveryEnabled === false ? "no" : "");
const deliveryZonesSummary = summarizeDeliveryZones(storeConfig.delivery_zones);
const pickupSlot = pickDaySlot(sched.pickup, dayKey);
const storeHoursToday = formatDaySlot(pickupSlot) || "";
return {
store_name: storeConfig.name || "",
bot_name: storeConfig.botName || "",
store_address: storeConfig.address || "",
store_phone: storeConfig.phone || "",
store_hours: storeConfig.hours || "",
store_hours_today: storeHoursToday,
delivery_hours: storeConfig.deliveryHours || "",
pickup_hours: storeConfig.pickupHours || "",
delivery_available_now: deliveryAvailableNow,
delivery_zones_summary: deliveryZonesSummary,
pickup_hours_today: formatDaySlot(pickupSlot) || "",
delivery_zones_summary: summarizeDeliveryZones(storeConfig.delivery_zones),
};
}
export const __test__ = { todayShortKey, pickDaySlot, formatDaySlot, formatDaysList, DAY_NAMES };