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

@@ -240,10 +240,7 @@ export async function getStoreConfig({ tenantId }) {
deliveryEnabled: settings.delivery_enabled,
pickupEnabled: settings.pickup_enabled,
schedule,
// Campos legacy para compatibilidad
delivery_days: settings.delivery_days,
delivery_hours_start: settings.delivery_hours_start,
delivery_hours_end: settings.delivery_hours_end,
delivery_zones: settings.delivery_zones || {},
};
}

View File

@@ -27,6 +27,7 @@ export async function handleEvolutionWebhook(body) {
from: parsed.chat_id.replace("@s.whatsapp.net", ""),
displayName: parsed.from_name || null,
text: parsed.text,
inboundLocation: parsed.location || null,
provider: "evolution",
message_id: parsed.message_id || crypto.randomUUID(),
meta: { pushName: parsed.from_name, ts: parsed.ts, source: parsed.source },

View File

@@ -3,11 +3,18 @@ import { processMessage } from "../../2-identity/services/pipeline.js";
import { getTenantId } from "../../shared/tenant.js";
export async function handleSimSend(body) {
const { chat_id, from_phone, text } = body || {};
if (!chat_id || !from_phone || !text) {
return { status: 400, payload: { ok: false, error: "chat_id, from_phone, text are required" } };
const { chat_id, from_phone, text, location } = body || {};
if (!chat_id || !from_phone || (!text && !location)) {
return { status: 400, payload: { ok: false, error: "chat_id, from_phone, and text or location are required" } };
}
// Aceptar location share desde el simulator. Mismo formato que el parser de
// Evolution: { lat, lng, label? }.
const inboundLocation =
location && typeof location.lat === "number" && typeof location.lng === "number"
? { lat: location.lat, lng: location.lng, label: location.label || null }
: null;
const provider = "sim";
const message_id = crypto.randomUUID();
const tenantId = getTenantId();
@@ -16,7 +23,8 @@ export async function handleSimSend(body) {
tenantId,
chat_id,
from: from_phone,
text,
text: text || "",
inboundLocation,
provider,
message_id,
});

View File

@@ -34,8 +34,19 @@ export function parseEvolutionWebhook(reqBody) {
(typeof msg.extendedTextMessage?.text === "string" && msg.extendedTextMessage.text) ||
"";
// extract location share (WhatsApp pin). Evolution wraps Baileys formats:
// - locationMessage: { degreesLatitude, degreesLongitude, name?, address? }
// - liveLocationMessage: { degreesLatitude, degreesLongitude }
const loc = msg.locationMessage || msg.liveLocationMessage || null;
const lat = loc?.degreesLatitude;
const lng = loc?.degreesLongitude;
const location =
typeof lat === "number" && typeof lng === "number"
? { lat, lng, label: loc?.name || loc?.address || null }
: null;
const cleanText = String(text).trim();
if (!cleanText) return { ok: false, reason: "empty_text" };
if (!cleanText && !location) return { ok: false, reason: "empty_message" };
// metadata
const pushName = data.pushName || null;
@@ -48,6 +59,7 @@ export function parseEvolutionWebhook(reqBody) {
chat_id: remoteJid,
message_id: messageId || null,
text: cleanText,
location,
from_name: pushName,
message_type: messageType || null,
ts,

View File

@@ -121,6 +121,7 @@ export async function processMessage({
message_id,
displayName = null,
meta = null,
inboundLocation = null,
}) {
const { started_at, mark, msBetween } = makePerf();
const prev = await touchConversationState({ tenant_id: tenantId, wa_chat_id: chat_id });
@@ -190,6 +191,22 @@ let externalCustomerId = await getExternalCustomerIdByChat({
_reset_at: new Date().toISOString(),
};
}
// Si llegó una ubicación compartida (WhatsApp pin), guardarla en pending
// para que el agente la lea via working_memory y matchee zona en set_address.
if (inboundLocation && typeof inboundLocation.lat === "number" && typeof inboundLocation.lng === "number") {
const baseOrder = reducedContext.order && typeof reducedContext.order === "object" ? reducedContext.order : {};
const merged = { ...baseOrder };
if (!Array.isArray(merged.cart)) merged.cart = [];
if (!Array.isArray(merged.pending)) merged.pending = [];
merged.pending_location = {
lat: inboundLocation.lat,
lng: inboundLocation.lng,
label: inboundLocation.label || null,
received_at: new Date().toISOString(),
};
reducedContext.order = merged;
}
let decision;
let plan;
let llmMeta;

View File

@@ -321,6 +321,8 @@ function toBasketItem(item) {
function buildContextPatch(ctx) {
const order = ctx.order || createEmptyOrder();
return {
// Persist the full order object so pending_location/matched_zone/delivery_window
// sobrevivan turno a turno (migrateOldContext devuelve ctx.order tal cual).
order,
order_basket: { items: (order.cart || []).map(toBasketItem) },
pending_items: (order.pending || []).map((p) => ({

View File

@@ -30,7 +30,8 @@ REGLAS DURAS:
llamá escalate_to_human.
CÓMO PROCESAS UN MENSAJE (user message viene como JSON con working_memory):
- Releé order.cart, order.pending, last_shown_options, fsm_state, paused_until.
- Releé order.cart, order.pending, last_shown_options, fsm_state, paused_until,
order.pending_location, order.matched_zone, order.delivery_window.
- preparsed: tiene cantidades parseadas (ej: "media docena" → 6 unit; "1/4 kg"
→ 0.25 kg). Confiá en eso si su confidence ≥ 0.85.
- Si user dice "el segundo", "ese", "el primero", "el de arriba", resolvé
@@ -65,9 +66,30 @@ ORDEN DE TOOLS EN UN TURNO TÍPICO:
3. Si devolvió varios candidatos → NO sigas buscando: usá say para pedir que
elija entre los top 3-5 (numerados). El sistema guarda last_shown_options.
4. (opcional) add_to_cart / set_quantity / select_candidate / set_shipping /
set_address / confirm_order / remove_from_cart / pause / escalate_to_human.
set_address / set_delivery_window / confirm_order / remove_from_cart /
pause / escalate_to_human.
5. say SIEMPRE como último tool del turno. Sin say no hay respuesta.
ENVÍO Y ZONAS:
- Si store.delivery.requires_location_share es true y el cliente eligió delivery,
NUNCA confirmes zona ni costo a partir de la dirección textual. Necesitamos
la ubicación compartida (pin/location share) por WhatsApp.
- Cuando el cliente pide envío: llamá set_shipping(method="delivery"). Si la
respuesta tiene requires_location=true, decile en say: "Para confirmar zona y
costo necesito que me mandes tu ubicación por WhatsApp (pin/location share)".
- Cuando llegue la ubicación, working_memory.order.pending_location va a tener
lat/lng. Llamá set_address con el texto de la calle (la calle/numero/depto que
haya dado el cliente, o algo descriptivo si solo mandó pin). Si match → te
devuelve matched_zone con costo/días/horas. Comunicá eso y pedí día y hora.
- Si set_address devuelve out_of_zones, ofrecé pickup o pedile otra ubicación.
- Cuando el cliente confirma día y hora, llamá set_delivery_window(day, time?).
Días: lun/mar/mie/jue/vie/sab/dom. Hora opcional (HH:MM 24h). Confirmá lo
registrado en say antes de llamar a confirm_order.
- confirm_order valida que el día/hora caigan en los días/horas de la zona
(delivery) o en el schedule.pickup (pickup). Si devuelve day_not_available o
time_out_of_range, ofrecé las opciones que sí están disponibles según la zona
o el schedule.
LIMITES TÉCNICOS:
- Tenés un máximo de 10 tool calls por turno. No los gastes en búsquedas
redundantes — si una query ya devolvió X candidatos, NO la repitas.

View File

@@ -1,12 +1,46 @@
/**
* confirm_order — emite create_order si hay cart + shipping completo.
*
* Validaciones extra (cuando hay schedule/zonas configuradas):
* - delivery: día y hora del delivery_window deben caer en zone.delivery_days
* y zone.delivery_hours.
* - pickup: ídem contra schedule.pickup[day].
*
* Si no hay delivery_window seteado, confirmamos igual y dejamos que el
* comercio coordine día/hora aparte.
*/
import { hasCartItems, hasShippingInfo } from "../../fsm.js";
function isHHMMInRange(time, start, end) {
if (!time || !start || !end) return true;
const t = String(time).slice(0, 5);
const a = String(start).slice(0, 5);
const b = String(end).slice(0, 5);
return t >= a && t <= b;
}
function isDayInList(day, list) {
if (!Array.isArray(list) || !list.length) return true;
return list.includes(day);
}
function formatPickupDays(schedule) {
if (!schedule || typeof schedule !== "object") return "";
const entries = Object.entries(schedule).filter(
([, v]) => v && v.enabled !== false && v.start && v.end
);
if (!entries.length) return "";
return entries.map(([k, v]) => `${k} ${v.start.slice(0, 5)}-${v.end.slice(0, 5)}`).join(", ");
}
export async function confirmOrderTool(_args, ctx) {
if (!hasCartItems(ctx.order)) {
return { ok: false, error: "empty_cart", hint: "Pedile al cliente que agregue productos antes de confirmar." };
return {
ok: false,
error: "empty_cart",
hint: "Pedile al cliente que agregue productos antes de confirmar.",
};
}
if (!hasShippingInfo(ctx.order)) {
return {
@@ -18,15 +52,83 @@ export async function confirmOrderTool(_args, ctx) {
: "Falta dirección. Llamá set_address.",
};
}
const win = ctx.order.delivery_window || null;
if (ctx.order.is_delivery) {
const z = ctx.order.matched_zone;
// Si hay zonas configuradas pero no se matcheó zona aún, bloquear.
const zonesConfigured = (ctx.storeConfig?.delivery_zones?.zones || []).some(
(zo) => zo?.enabled !== false
);
if (zonesConfigured && !z) {
return {
ok: false,
error: "zone_unverified",
hint:
"Falta verificar zona. Pedile la ubicación por WhatsApp y llamá set_address antes de confirmar.",
};
}
if (z && win) {
if (!isDayInList(win.day, z.delivery_days)) {
return {
ok: false,
error: "day_not_available",
hint: `La zona ${z.name} entrega ${(z.delivery_days || []).join("/")}. Pedile otro día.`,
allowed_days: z.delivery_days || [],
};
}
if (z.delivery_hours && !isHHMMInRange(win.time, z.delivery_hours.start, z.delivery_hours.end)) {
return {
ok: false,
error: "time_out_of_range",
hint: `La zona ${z.name} entrega entre ${z.delivery_hours.start.slice(0, 5)} y ${z.delivery_hours.end.slice(0, 5)}. Pedile otro horario.`,
allowed_range: z.delivery_hours,
};
}
}
} else {
// Pickup
const schedule = ctx.storeConfig?.schedule?.pickup || null;
if (schedule && win) {
const slot = schedule[win.day];
if (!slot || slot.enabled === false || !slot.start || !slot.end) {
return {
ok: false,
error: "day_not_available_pickup",
hint: `Ese día la tienda no abre. ${formatPickupDays(schedule)}.`,
};
}
if (win.time && !isHHMMInRange(win.time, slot.start, slot.end)) {
return {
ok: false,
error: "time_out_of_range_pickup",
hint: `Ese día abrimos ${slot.start.slice(0, 5)}-${slot.end.slice(0, 5)}. Pedile otro horario.`,
};
}
}
}
// Idempotencia: si ya existe create_order encolado, no duplicar
const already = ctx.pending_actions.some((a) => a.type === "create_order");
if (!already) {
ctx.pending_actions.push({ type: "create_order", payload: { source: "wa_bot" } });
ctx.pending_actions.push({
type: "create_order",
payload: {
source: "wa_bot",
delivery_window: win,
matched_zone: ctx.order.matched_zone || null,
},
});
}
return {
ok: true,
cart_size: (ctx.order.cart || []).length,
is_delivery: !!ctx.order.is_delivery,
address: ctx.order.shipping_address || null,
delivery_window: win,
matched_zone: ctx.order.matched_zone || null,
};
}

View File

@@ -17,6 +17,7 @@ import { selectCandidateTool } from "./selectCandidate.js";
import { removeFromCartTool } from "./removeFromCart.js";
import { setShippingTool } from "./setShipping.js";
import { setAddressTool } from "./setAddress.js";
import { setDeliveryWindowTool } from "./setDeliveryWindow.js";
import { confirmOrderTool } from "./confirmOrder.js";
import { pauseTool } from "./pause.js";
import { escalateToHumanTool } from "./escalateToHuman.js";
@@ -37,6 +38,7 @@ const TOOLS = {
remove_from_cart: removeFromCartTool,
set_shipping: setShippingTool,
set_address: setAddressTool,
set_delivery_window: setDeliveryWindowTool,
confirm_order: confirmOrderTool,
pause: pauseTool,
escalate_to_human: escalateToHumanTool,

View File

@@ -109,13 +109,39 @@ export const TOOL_SCHEMAS = [
type: "function",
function: {
name: "set_address",
description: "Setea la dirección de entrega y valida zona. Si está fuera de zona devuelve error.",
description:
"Registra la dirección de entrega (texto, como label) y matchea zona usando la ubicación compartida " +
"por WhatsApp (working_memory.order.pending_location). Si no hay ubicación compartida, devuelve " +
"need_location: tenés que pedirle al cliente que mande el pin por WhatsApp.",
parameters: {
type: "object",
additionalProperties: false,
required: ["text"],
properties: {
text: { type: "string", minLength: 5 },
text: {
type: "string",
minLength: 3,
description: "Dirección textual (calle, número, depto, referencias). Sirve como label para el repartidor.",
},
},
},
},
},
{
type: "function",
function: {
name: "set_delivery_window",
description:
"Registra el día/horario que pidió el cliente para entrega o retiro. " +
"El día debe ser uno de lun/mar/mie/jue/vie/sab/dom. Hora opcional (HH:MM). " +
"confirm_order va a validar después contra la zona o el horario de pickup.",
parameters: {
type: "object",
additionalProperties: false,
required: ["day"],
properties: {
day: { type: "string", enum: ["lun", "mar", "mie", "jue", "vie", "sab", "dom"] },
time: { type: "string", pattern: "^\\d{2}:\\d{2}$", description: "HH:MM (24h). Opcional." },
},
},
},

View File

@@ -1,36 +1,80 @@
/**
* set_address — fija dirección y valida zona.
* set_address — fija dirección (texto) y matchea zona usando la ubicación
* compartida por WhatsApp (pending_location). Sin location, no se valida
* zona y se pide al cliente que mande el pin.
*/
import { checkAddressInZone } from "../../storeContext.js";
import { findZoneForPoint } from "../../lib/geo.js";
export async function setAddressTool(args, ctx) {
const { text } = args;
const { text } = args || {};
const address = String(text || "").trim();
if (address.length < 5) return { ok: false, error: "address_too_short" };
if (address.length < 3) return { ok: false, error: "address_too_short" };
// Validar zona si hay zonas cargadas
const zoneCheck = checkAddressInZone({
address,
storeConfig: ctx.storeConfig,
});
if (!zoneCheck.inZone) {
const allZones = ctx.storeConfig?.delivery_zones?.zones || [];
const enabled = allZones.filter((z) => z?.enabled !== false);
// Sin zonas configuradas, aceptamos la dirección sin validar zona.
if (!enabled.length) {
ctx.order = {
...ctx.order,
shipping_address: address,
is_delivery: ctx.order.is_delivery == null ? true : ctx.order.is_delivery,
matched_zone: null,
};
return { ok: true, address, in_zone: true, reason: "no_zones_configured" };
}
const loc = ctx.order?.pending_location;
if (!loc || typeof loc.lat !== "number" || typeof loc.lng !== "number") {
return {
ok: false,
error: "out_of_zone",
reason: zoneCheck.reason,
available_zones: zoneCheck.zones,
hint: "Pedile al cliente otra dirección o ofrecele pickup.",
error: "need_location",
hint:
"Pedile al cliente que te mande su ubicación por WhatsApp (pin/location share) " +
"para validar zona y costo. Sin ubicación no podemos confirmar envío.",
available_zones: enabled.map((z) => ({
name: z.name,
delivery_cost: z.delivery_cost ?? null,
})),
};
}
// Si no había is_delivery seteado, asumir delivery=true (le dieron dirección)
const is_delivery = ctx.order.is_delivery == null ? true : ctx.order.is_delivery;
ctx.order = { ...ctx.order, shipping_address: address, is_delivery };
const matched = findZoneForPoint(loc.lng, loc.lat, enabled);
if (!matched) {
return {
ok: false,
error: "out_of_zones",
hint:
"La ubicación que mandó está fuera de las zonas que cubre la carnicería. " +
"Ofrecé pickup o pedile otra ubicación dentro de las zonas habilitadas.",
available_zones: enabled.map((z) => ({
name: z.name,
delivery_cost: z.delivery_cost ?? null,
})),
};
}
const zoneSummary = {
id: matched.id,
name: matched.name,
delivery_cost: matched.delivery_cost ?? null,
delivery_days: Array.isArray(matched.delivery_days) ? matched.delivery_days : [],
delivery_hours: matched.delivery_hours || null,
min_order_amount: matched.min_order_amount ?? 0,
};
ctx.order = {
...ctx.order,
shipping_address: address,
is_delivery: ctx.order.is_delivery == null ? true : ctx.order.is_delivery,
matched_zone: zoneSummary,
};
return {
ok: true,
address,
in_zone: true,
matched_zone: zoneCheck.matched || null,
matched_zone: zoneSummary,
};
}

View File

@@ -0,0 +1,26 @@
/**
* set_delivery_window — registra el día/horario que pidió el cliente.
*
* El LLM lo llama cuando el cliente confirma "el martes a las 11" o similar.
* confirm_order valida después contra zone.delivery_days/delivery_hours
* (delivery) o schedule.pickup (pickup).
*/
const DAY_KEYS = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
export async function setDeliveryWindowTool(args, ctx) {
const { day, time } = args || {};
if (!DAY_KEYS.includes(day)) {
return { ok: false, error: "invalid_day", allowed: DAY_KEYS };
}
if (time != null && !/^\d{2}:\d{2}$/.test(String(time))) {
return { ok: false, error: "invalid_time", hint: "Formato HH:MM (24h)." };
}
ctx.order = {
...ctx.order,
delivery_window: { day, time: time || null },
};
return { ok: true, day, time: time || null };
}

View File

@@ -1,12 +1,50 @@
/**
* set_shipping — fija el método de envío.
* set_shipping — fija el método de envío (delivery o pickup).
*
* Cuando method=delivery y hay zonas configuradas, devuelve hints para que
* el LLM le pida al cliente que comparta ubicación si no la tenemos.
*/
export async function setShippingTool(args, ctx) {
const { method } = args;
const { method } = args || {};
if (method !== "delivery" && method !== "pickup") {
return { ok: false, error: "invalid_method" };
}
ctx.order = { ...ctx.order, is_delivery: method === "delivery" };
return { ok: true, method, requires_address: method === "delivery" && !ctx.order.shipping_address };
if (method === "pickup") {
return {
ok: true,
method,
requires_address: false,
requires_location: false,
};
}
// Delivery
const zones = ctx.storeConfig?.delivery_zones?.zones || [];
const enabledZones = zones.filter((z) => z?.enabled !== false);
const hasZones = enabledZones.length > 0;
const hasLocation = !!ctx.order?.pending_location?.lat;
const hasMatchedZone = !!ctx.order?.matched_zone;
return {
ok: true,
method,
requires_address: !ctx.order.shipping_address,
requires_location: hasZones && !hasLocation && !hasMatchedZone,
available_zones: hasZones
? enabledZones.map((z) => ({
name: z.name,
delivery_cost: z.delivery_cost ?? null,
delivery_days: z.delivery_days || [],
delivery_hours: z.delivery_hours || null,
}))
: [],
hint:
hasZones && !hasLocation
? "Pedile al cliente que te mande su ubicación por WhatsApp (pin/location share) para validar zona y costo de envío."
: null,
};
}

View File

@@ -12,7 +12,7 @@
*/
import { parseQuantity } from "./quantityParser.js";
import { buildStoreContextVars } from "../storeContext.js";
import { buildStoreContextVars, buildZonesForLLM } from "../storeContext.js";
const HISTORY_MAX = 8;
const HISTORY_CHAR_CAP = 200;
@@ -90,14 +90,22 @@ export function buildWorkingMemory({
const preparsed = parseQuantity(text || "");
const zones = buildZonesForLLM(storeConfig.delivery_zones);
const pickupSchedule = storeConfig.schedule?.pickup || null;
return {
now: nowIso(),
store: {
name: storeVars.store_name || "la carnicería",
hours_today: storeVars.store_hours_today || "consultar",
delivery: {
available_now: storeVars.delivery_available_now || "",
zones, // [{id,name,delivery_cost,delivery_days,delivery_hours,min_order_amount}]
zones_summary: storeVars.delivery_zones_summary || "",
requires_location_share: zones.length > 0, // hint para el LLM
},
pickup: {
schedule: pickupSchedule, // { lun:{start,end,enabled}, mar:..., ... } o null
hours_today: storeVars.pickup_hours_today || "",
},
},
fsm_state: prev_state || "IDLE",
@@ -107,6 +115,9 @@ export function buildWorkingMemory({
is_delivery: order.is_delivery ?? null,
shipping_address: order.shipping_address ?? null,
woo_order_id: order.woo_order_id ?? null,
pending_location: order.pending_location || null, // { lat, lng, label?, received_at }
matched_zone: order.matched_zone || null, // resumen de zona matched (set por set_address)
delivery_window: order.delivery_window || null, // { day, time } elegido por cliente
},
last_shown_options,
paused_until: order.paused_until ?? null,

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

View File

@@ -0,0 +1,103 @@
import { pointInPolygon, findZoneForPoint } from "./geo.js";
const square = {
type: "Polygon",
coordinates: [[
[0, 0],
[10, 0],
[10, 10],
[0, 10],
[0, 0],
]],
};
const concave = {
type: "Polygon",
coordinates: [[
[0, 0],
[10, 0],
[10, 10],
[5, 5],
[0, 10],
[0, 0],
]],
};
describe("pointInPolygon", () => {
it("returns true para un punto dentro de un cuadrado", () => {
expect(pointInPolygon(5, 5, square)).toBe(true);
});
it("returns false para un punto fuera del cuadrado", () => {
expect(pointInPolygon(15, 5, square)).toBe(false);
expect(pointInPolygon(-1, 5, square)).toBe(false);
expect(pointInPolygon(5, 20, square)).toBe(false);
});
it("maneja polígonos cóncavos (excluye el dent del centro)", () => {
// (5, 8) cae dentro del notch — fuera del polígono cóncavo.
expect(pointInPolygon(5, 8, concave)).toBe(false);
// (2, 2) sigue dentro.
expect(pointInPolygon(2, 2, concave)).toBe(true);
});
it("returns false para input inválido", () => {
expect(pointInPolygon(0, 0, null)).toBe(false);
expect(pointInPolygon(0, 0, { type: "Point" })).toBe(false);
expect(pointInPolygon(0, 0, { type: "Polygon", coordinates: [[[0, 0], [1, 1]]] })).toBe(false);
});
it("trabaja con coordenadas reales de CABA (lng, lat)", () => {
// Polígono cuadrado pequeño alrededor del Obelisco.
const obeliscoBox = {
type: "Polygon",
coordinates: [[
[-58.39, -34.61],
[-58.37, -34.61],
[-58.37, -34.60],
[-58.39, -34.60],
[-58.39, -34.61],
]],
};
// Obelisco aprox: -58.3816, -34.6037
expect(pointInPolygon(-58.3816, -34.6037, obeliscoBox)).toBe(true);
// Mar del Plata
expect(pointInPolygon(-57.55, -38.0, obeliscoBox)).toBe(false);
});
});
describe("findZoneForPoint", () => {
const zones = [
{ id: "centro", name: "Centro", polygon: square, enabled: true, delivery_cost: 1500 },
{
id: "norte",
name: "Norte",
enabled: true,
polygon: {
type: "Polygon",
coordinates: [[[20, 20], [30, 20], [30, 30], [20, 30], [20, 20]]],
},
delivery_cost: 2000,
},
];
it("devuelve la zona que contiene al punto", () => {
const z = findZoneForPoint(5, 5, zones);
expect(z?.id).toBe("centro");
});
it("devuelve null si ningún polígono contiene al punto", () => {
expect(findZoneForPoint(15, 15, zones)).toBeNull();
});
it("ignora zonas con enabled=false", () => {
const muted = zones.map((z) => ({ ...z, enabled: z.id === "centro" ? false : z.enabled }));
expect(findZoneForPoint(5, 5, muted)).toBeNull();
});
it("tolera input inválido", () => {
expect(findZoneForPoint(0, 0, null)).toBeNull();
expect(findZoneForPoint(0, 0, [])).toBeNull();
expect(findZoneForPoint(0, 0, [{ id: "x" }])).toBeNull();
});
});

View File

@@ -25,6 +25,9 @@ export function createEmptyOrder() {
is_delivery: null, // true | false | null
shipping_address: null,
woo_order_id: null,
pending_location: null, // { lat, lng, label?, received_at } — última ubicación compartida por WhatsApp
matched_zone: null, // resumen de zona matched (id, name, cost, days, hours)
delivery_window: null, // { day: "lun"|..., time: "HH:MM" } seleccionado por el cliente
};
}

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