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>
160 lines
5.5 KiB
JavaScript
160 lines
5.5 KiB
JavaScript
/**
|
|
* Store Context — helpers para inyectar info de la tienda en el agente.
|
|
*
|
|
* 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
|
|
* }
|
|
*/
|
|
|
|
import { findZoneForPoint } from "./lib/geo.js";
|
|
|
|
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];
|
|
}
|
|
|
|
function pickDaySlot(scheduleObj, dayKey) {
|
|
if (!scheduleObj || typeof scheduleObj !== "object") return null;
|
|
return scheduleObj[dayKey] || null;
|
|
}
|
|
|
|
function formatDaySlot(slot) {
|
|
if (!slot) return null;
|
|
if (slot.enabled === false) return null;
|
|
const start = (slot.start || "").slice(0, 5);
|
|
const end = (slot.end || "").slice(0, 5);
|
|
if (!start || !end) return null;
|
|
return `${start} a ${end}`;
|
|
}
|
|
|
|
function getZones(deliveryZones) {
|
|
if (!deliveryZones || typeof deliveryZones !== "object") return [];
|
|
if (!Array.isArray(deliveryZones.zones)) return [];
|
|
return deliveryZones.zones.filter((z) => z && z.enabled !== false);
|
|
}
|
|
|
|
/**
|
|
* Devuelve los nombres de zonas habilitadas (sin polygon, para UI/log).
|
|
*/
|
|
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: [] };
|
|
}
|
|
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 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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 dayKey = todayShortKey();
|
|
const sched = storeConfig.schedule || {};
|
|
|
|
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_today: storeHoursToday,
|
|
pickup_hours_today: formatDaySlot(pickupSlot) || "",
|
|
delivery_zones_summary: summarizeDeliveryZones(storeConfig.delivery_zones),
|
|
};
|
|
}
|
|
|
|
export const __test__ = { todayShortKey, pickDaySlot, formatDaySlot, formatDaysList, DAY_NAMES };
|