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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user