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,13 @@
-- migrate:up
-- Limpiar formato legacy de delivery_zones (caba.barrios, flat) y dejar solo
-- el schema nuevo { zones: [...] }. Pre-prod: no preservamos data legacy.
UPDATE tenant_settings
SET delivery_zones = '{}'::jsonb
WHERE
delivery_zones IS NULL
OR NOT (delivery_zones ? 'zones')
OR jsonb_typeof(delivery_zones->'zones') <> 'array';
-- migrate:down
-- noop: no preservamos el formato legacy.
SELECT 1;

View File

@@ -12,6 +12,7 @@ import "./components/quantities-crud.js";
import "./components/orders-crud.js"; import "./components/orders-crud.js";
import "./components/test-panel.js"; import "./components/test-panel.js";
import "./components/takeovers-crud.js"; import "./components/takeovers-crud.js";
import "./components/zone-map-editor.js";
import "./components/settings-crud.js"; import "./components/settings-crud.js";
import { connectSSE } from "./lib/sse.js"; import { connectSSE } from "./lib/sse.js";
import { initRouter } from "./lib/router.js"; import { initRouter } from "./lib/router.js";

View File

@@ -10,19 +10,12 @@ const DAYS = [
{ id: "dom", label: "Domingo", short: "D" }, { id: "dom", label: "Domingo", short: "D" },
]; ];
// Lista oficial de 48 barrios de CABA function makeZoneId(name) {
const CABA_BARRIOS = [ return String(name || "").toLowerCase()
"Agronomía", "Almagro", "Balvanera", "Barracas", "Belgrano", "Boedo", .normalize("NFD").replace(/[̀-ͯ]/g, "")
"Caballito", "Chacarita", "Coghlan", "Colegiales", "Constitución", .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
"Flores", "Floresta", "La Boca", "La Paternal", "Liniers", .slice(0, 40) || `zona-${Date.now().toString(36)}`;
"Mataderos", "Monte Castro", "Montserrat", "Nueva Pompeya", "Núñez", }
"Palermo", "Parque Avellaneda", "Parque Chacabuco", "Parque Chas",
"Parque Patricios", "Puerto Madero", "Recoleta", "Retiro", "Saavedra",
"San Cristóbal", "San Nicolás", "San Telmo", "Vélez Sársfield", "Versalles",
"Villa Crespo", "Villa del Parque", "Villa Devoto", "Villa General Mitre",
"Villa Lugano", "Villa Luro", "Villa Ortúzar", "Villa Pueyrredón",
"Villa Real", "Villa Riachuelo", "Villa Santa Rita", "Villa Soldati", "Villa Urquiza"
];
class SettingsCrud extends HTMLElement { class SettingsCrud extends HTMLElement {
constructor() { constructor() {
@@ -31,6 +24,9 @@ class SettingsCrud extends HTMLElement {
this.settings = null; this.settings = null;
this.loading = false; this.loading = false;
this.saving = false; this.saving = false;
this.zones = [];
this.selectedZoneId = null;
this._mapEditor = null;
this.shadowRoot.innerHTML = ` this.shadowRoot.innerHTML = `
<style> <style>
@@ -128,60 +124,40 @@ class SettingsCrud extends HTMLElement {
.min-order-field { margin-top:16px; padding-top:16px; border-top:1px solid var(--border); } .min-order-field { margin-top:16px; padding-top:16px; border-top:1px solid var(--border); }
/* Zonas de entrega */ /* Zonas de entrega — editor con mapa */
.zones-search { margin-bottom:12px; } .zones-layout { display:grid; grid-template-columns:280px 1fr; gap:16px; }
.zones-search input { .zones-side { display:flex; flex-direction:column; gap:8px; min-width:0; }
width:100%; padding:10px 14px; .zones-side-header { display:flex; align-items:center; justify-content:space-between; }
background:var(--panel-2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%236c7a89'%3E%3Cpath d='M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z'/%3E%3C/svg%3E") no-repeat 12px center; .zones-side-header h4 { margin:0; font-size:13px; color:var(--text); }
background-size:18px; padding-left:38px; .zones-side-header button { padding:6px 10px; font-size:12px; }
} .zones-list { display:flex; flex-direction:column; gap:6px; max-height:480px; overflow-y:auto; padding-right:4px; }
.zones-list { max-height:400px; overflow-y:auto; display:flex; flex-direction:column; gap:4px; }
.zone-row { .zone-row {
display:grid; display:flex; align-items:center; gap:10px;
grid-template-columns:32px 1fr; padding:10px 12px; border-radius:var(--r-md, 10px);
gap:12px; background:var(--panel-2); border:1px solid var(--border);
align-items:start; cursor:pointer; transition:border-color .15s, background .15s;
padding:10px 12px;
background:var(--panel-2);
border-radius:8px;
border:1px solid var(--border);
transition:border-color .2s;
} }
.zone-row:hover { border-color:var(--border-hi); }
.zone-row.active { border-color:var(--accent); background:var(--accent-soft); } .zone-row.active { border-color:var(--accent); background:var(--accent-soft); }
.zone-row.hidden { display:none; } .zone-row.disabled { opacity:.55; }
.zone-toggle { .zone-swatch { width:14px; height:14px; border-radius:4px; flex-shrink:0; }
width:32px; height:18px; background:var(--border-hi); border-radius:9px; .zone-row-main { flex:1; min-width:0; display:flex; flex-direction:column; gap:2px; }
cursor:pointer; position:relative; transition:background .2s; margin-top:2px; .zone-row-name { font-size:13px; font-weight:500; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
} .zone-row-meta { font-size:11px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.zone-toggle.active { background:var(--ok); } .zones-empty { padding:24px; text-align:center; color:var(--text-muted); font-size:13px; }
.zone-toggle::after { .zone-form { padding:14px; background:var(--panel-2); border:1px solid var(--border); border-radius:var(--r-md, 10px); display:flex; flex-direction:column; gap:12px; }
content:''; position:absolute; top:2px; left:2px; .zone-form .row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
width:14px; height:14px; background:#fff; border-radius:50%; .zone-form .row.three { grid-template-columns:2fr 1fr 1fr; }
transition:transform .2s; .zone-form label { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; }
} .zone-form input { padding:8px 10px; font-size:13px; }
.zone-toggle.active::after { transform:translateX(14px); } .zone-days-pick { display:flex; gap:4px; flex-wrap:wrap; }
.zone-content { display:flex; flex-direction:column; gap:8px; } .zone-day-pick { padding:4px 8px; border-radius:6px; font-size:11px; font-weight:600; cursor:pointer;
.zone-name { font-size:14px; color:var(--text); font-weight:500; } background:var(--border); color:var(--text-muted); border:1px solid transparent; }
.zone-config { display:none; gap:16px; flex-wrap:wrap; align-items:center; } .zone-day-pick.active { background:var(--accent); color:var(--text-on-acc, #fff); }
.zone-row.active .zone-config { display:flex; } .zone-row-actions { display:flex; gap:8px; justify-content:flex-end; }
.zone-days { display:flex; gap:4px; } .zone-row-actions button { padding:6px 10px; font-size:12px; }
.zone-day { .zone-row-actions .danger { background:var(--err); }
width:28px; height:28px; border-radius:6px; .zones-summary { margin-top:8px; padding:10px 12px; background:var(--panel-2); border-radius:var(--r-md, 10px); font-size:12px; color:var(--text-muted); }
background:var(--border-hi); color:var(--text-muted);
display:flex; align-items:center; justify-content:center;
font-size:11px; font-weight:600; cursor:pointer;
transition:all .15s;
}
.zone-day.active { background:var(--accent); color:#fff; }
.zone-day:hover { background:var(--border-hi); }
.zone-day.active:hover { background:var(--accent-hover); }
.zone-cost { display:flex; align-items:center; gap:6px; }
.zone-cost label { font-size:12px; color:var(--text-muted); }
.zone-cost input { width:90px; padding:6px 10px; font-size:13px; text-align:right; }
.zones-summary {
margin-top:12px; padding:12px; background:var(--panel-2);
border-radius:8px; font-size:13px; color:var(--text-muted);
}
.zones-summary strong { color:var(--text); } .zones-summary strong { color:var(--text); }
</style> </style>
@@ -204,14 +180,12 @@ class SettingsCrud extends HTMLElement {
try { try {
this.settings = await api.getSettings(); this.settings = await api.getSettings();
// Asegurar que schedule existe
if (!this.settings.schedule) { if (!this.settings.schedule) {
this.settings.schedule = { delivery: {}, pickup: {} }; this.settings.schedule = { delivery: {}, pickup: {} };
} }
// Asegurar que delivery_zones existe const dz = this.settings.delivery_zones || {};
if (!this.settings.delivery_zones) { this.zones = Array.isArray(dz.zones) ? dz.zones.map((z) => ({ ...z })) : [];
this.settings.delivery_zones = {}; this.selectedZoneId = this.zones[0]?.id || null;
}
this.loading = false; this.loading = false;
this.render(); this.render();
} catch (e) { } catch (e) {
@@ -275,70 +249,115 @@ class SettingsCrud extends HTMLElement {
}).join(""); }).join("");
} }
// Convierte nombre de barrio a key (ej: "Villa Crespo" -> "villa_crespo") ZONE_PALETTE_VARS() {
barrioToKey(name) { return ["--chart-blue", "--chart-green", "--chart-purple", "--chart-orange", "--chart-pink", "--chart-gray"];
return name.toLowerCase()
.normalize("NFD").replace(/[\u0300-\u036f]/g, "") // quitar acentos
.replace(/\s+/g, "_");
} }
getZoneConfig(barrioKey) { zoneSwatchColor(idx) {
return this.settings?.delivery_zones?.[barrioKey] || null; const palette = this.ZONE_PALETTE_VARS();
return `var(${palette[idx % palette.length]})`;
} }
setZoneConfig(barrioKey, config) { formatDaysShort(days) {
if (!this.settings.delivery_zones) { if (!Array.isArray(days) || !days.length) return "\u2014";
this.settings.delivery_zones = {}; const order = ["lun","mar","mie","jue","vie","sab","dom"];
} const idx = days.map((d) => order.indexOf(d)).filter((i) => i >= 0).sort((a, b) => a - b);
if (config === null) { if (idx.length >= 3 && idx.every((v, i, arr) => i === 0 || v === arr[i - 1] + 1)) {
delete this.settings.delivery_zones[barrioKey]; return `${order[idx[0]]}-${order[idx[idx.length - 1]]}`;
} else {
this.settings.delivery_zones[barrioKey] = config;
} }
return idx.map((i) => order[i]).join("/");
} }
renderZonesList() { renderZonesList() {
return CABA_BARRIOS.map(barrio => { if (!this.zones.length) {
const key = this.barrioToKey(barrio); return `<div class="zones-empty">No hay zonas dibujadas todavía. Tocá <strong>Crear zona</strong> y dibujá un polígono en el mapa.</div>`;
const config = this.getZoneConfig(key); }
const isActive = config?.enabled === true; return this.zones.map((z, i) => {
const days = config?.days || []; const cost = z.delivery_cost != null ? `$${Number(z.delivery_cost).toLocaleString("es-AR")}` : "";
const cost = config?.delivery_cost || 0; const days = this.formatDaysShort(z.delivery_days);
const start = z.delivery_hours?.start?.slice(0, 5) || "";
const end = z.delivery_hours?.end?.slice(0, 5) || "";
const hours = start && end ? `${start}-${end}` : "";
const meta = [cost, days, hours].filter(Boolean).join(" · ");
const active = z.id === this.selectedZoneId ? "active" : "";
const disabled = z.enabled === false ? "disabled" : "";
return ` return `
<div class="zone-row ${isActive ? 'active' : ''}" data-barrio="${key}"> <div class="zone-row ${active} ${disabled}" data-zone-id="${z.id}">
<div class="zone-toggle ${isActive ? 'active' : ''}" data-barrio="${key}"></div> <div class="zone-swatch" style="background:${this.zoneSwatchColor(i)}"></div>
<div class="zone-content"> <div class="zone-row-main">
<span class="zone-name">${barrio}</span> <div class="zone-row-name">${this.escapeHtml(z.name || z.id)}</div>
<div class="zone-config"> <div class="zone-row-meta">${this.escapeHtml(meta || "sin configurar")}</div>
<div class="zone-days">
${DAYS.map(d => `
<div class="zone-day ${days.includes(d.id) ? 'active' : ''}"
data-barrio="${key}" data-day="${d.id}"
title="${d.label}">${d.short}</div>
`).join("")}
</div>
<div class="zone-cost">
<label>Costo:</label>
<input type="number" class="zone-cost-input" data-barrio="${key}"
value="${cost}" min="0" step="100" placeholder="0" />
</div>
</div>
</div> </div>
</div> </div>
`; `;
}).join(""); }).join("");
} }
renderZonesSummary() { renderZoneForm() {
const zones = this.settings?.delivery_zones || {}; const z = this.zones.find((x) => x.id === this.selectedZoneId);
const activeZones = Object.entries(zones).filter(([k, v]) => v?.enabled); if (!z) {
return `<div class="zones-empty">Seleccioná una zona en la lista o dibujá una nueva en el mapa para configurarla.</div>`;
if (activeZones.length === 0) { }
return `<div class="zones-summary">No hay zonas de entrega configuradas. Activá los barrios donde hacés delivery.</div>`; const days = z.delivery_days || [];
const start = z.delivery_hours?.start?.slice(0, 5) || "10:00";
const end = z.delivery_hours?.end?.slice(0, 5) || "20:00";
return `
<div class="zone-form" data-zone-id="${z.id}">
<div class="row three">
<div class="field">
<label>Nombre</label>
<input type="text" id="zoneName" value="${this.escapeHtml(z.name || "")}" maxlength="60" />
</div>
<div class="field">
<label>Costo de envío ($)</label>
<input type="number" id="zoneCost" value="${z.delivery_cost ?? 0}" min="0" step="100" />
</div>
<div class="field">
<label>Mín. pedido ($)</label>
<input type="number" id="zoneMin" value="${z.min_order_amount ?? 0}" min="0" step="100" />
</div>
</div>
<div class="field">
<label>Días de entrega</label>
<div class="zone-days-pick">
${DAYS.map((d) => `
<span class="zone-day-pick ${days.includes(d.id) ? "active" : ""}" data-day="${d.id}" title="${d.label}">${d.short}</span>
`).join("")}
</div>
</div>
<div class="row">
<div class="field">
<label>Hora de inicio</label>
<input type="time" id="zoneStart" value="${start}" />
</div>
<div class="field">
<label>Hora de fin</label>
<input type="time" id="zoneEnd" value="${end}" />
</div>
</div>
<div class="row">
<div class="field">
<label>Estado</label>
<select id="zoneEnabled">
<option value="true" ${z.enabled !== false ? "selected" : ""}>Habilitada</option>
<option value="false" ${z.enabled === false ? "selected" : ""}>Deshabilitada (no recibe pedidos)</option>
</select>
</div>
<div class="zone-row-actions" style="align-self:end;">
<button class="secondary" id="zoneFitBtn">Centrar en mapa</button>
<button class="danger" id="zoneDeleteBtn">Eliminar zona</button>
</div>
</div>
</div>
`;
} }
return `<div class="zones-summary"><strong>${activeZones.length}</strong> zona${activeZones.length > 1 ? 's' : ''} de entrega activa${activeZones.length > 1 ? 's' : ''}</div>`; renderZonesSummary() {
const enabled = this.zones.filter((z) => z.enabled !== false);
if (!this.zones.length) {
return `<div class="zones-summary">Dibujá al menos una zona en el mapa para que el bot pueda confirmar envíos. Sin zonas, todas las direcciones quedan sin validar.</div>`;
}
return `<div class="zones-summary"><strong>${enabled.length}</strong> de <strong>${this.zones.length}</strong> zona${this.zones.length > 1 ? "s" : ""} habilitada${enabled.length === 1 ? "" : "s"}.</div>`;
} }
render() { render() {
@@ -434,19 +453,32 @@ class SettingsCrud extends HTMLElement {
<div class="panel"> <div class="panel">
<div class="panel-title"> <div class="panel-title">
<svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg> <svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
Zonas de Entrega (Barrios CABA) Zonas de Entrega
</div>
<div class="field-hint" style="margin-bottom:12px;">
Dibujá los polígonos de tus zonas en el mapa. Cada zona tiene su costo de envío, días y rango horario.
El bot valida la dirección del cliente con la ubicación que comparta por WhatsApp.
</div> </div>
<div class="zones-search"> <div class="zones-layout">
<input type="text" id="zoneSearch" placeholder="Buscar barrio..." /> <div class="zones-side">
<div class="zones-side-header">
<h4>Zonas</h4>
<button id="zoneCreateBtn" type="button">+ Crear zona</button>
</div> </div>
<div class="zones-list" id="zonesList"> <div class="zones-list" id="zonesList">
${this.renderZonesList()} ${this.renderZonesList()}
</div> </div>
<div id="zoneFormSlot">
${this.renderZoneForm()}
</div>
${this.renderZonesSummary()} ${this.renderZonesSummary()}
</div> </div>
<div>
<zone-map-editor id="zoneMapEditor" height="520px"></zone-map-editor>
</div>
</div>
</div>
<div class="actions"> <div class="actions">
<button id="saveBtn" ${this.saving ? "disabled" : ""}>${this.saving ? "Guardando..." : "Guardar Configuración"}</button> <button id="saveBtn" ${this.saving ? "disabled" : ""}>${this.saving ? "Guardando..." : "Guardar Configuración"}</button>
@@ -518,70 +550,124 @@ class SettingsCrud extends HTMLElement {
// Reset button // Reset button
this.shadowRoot.getElementById("resetBtn")?.addEventListener("click", () => this.load()); this.shadowRoot.getElementById("resetBtn")?.addEventListener("click", () => this.load());
// Zone search this.setupZoneEditor();
this.shadowRoot.getElementById("zoneSearch")?.addEventListener("input", (e) => {
const query = e.target.value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
this.shadowRoot.querySelectorAll(".zone-row").forEach(row => {
const barrio = row.dataset.barrio;
const barrioName = CABA_BARRIOS.find(b => this.barrioToKey(b) === barrio) || "";
const normalized = barrioName.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
row.classList.toggle("hidden", query && !normalized.includes(query));
});
});
// Zone toggles
this.shadowRoot.querySelectorAll(".zone-toggle").forEach(toggle => {
toggle.addEventListener("click", () => {
const barrio = toggle.dataset.barrio;
const config = this.getZoneConfig(barrio);
if (config?.enabled) {
// Desactivar zona
this.setZoneConfig(barrio, null);
} else {
// Activar zona con días default (lun-sab)
this.setZoneConfig(barrio, {
enabled: true,
days: ["lun", "mar", "mie", "jue", "vie", "sab"],
delivery_cost: 0
});
} }
this.render();
setupZoneEditor() {
const editor = this.shadowRoot.getElementById("zoneMapEditor");
if (!editor) return;
this._mapEditor = editor;
editor.zones = this.zones;
if (this.selectedZoneId) editor.selectedId = this.selectedZoneId;
editor.addEventListener("change", (e) => {
const next = e.detail.zones || [];
const merged = [];
for (const z of next) {
const existing = this.zones.find((x) => x.id === z.id);
if (existing) merged.push({ ...existing, polygon: z.polygon });
else merged.push(z);
}
this.zones = merged;
this.refreshZonesPanel();
});
editor.addEventListener("select", (e) => {
this.selectedZoneId = e.detail.id || null;
this.refreshZonesPanel();
});
this.shadowRoot.getElementById("zoneCreateBtn")?.addEventListener("click", () => {
this._mapEditor?.startDrawing();
});
this.attachZoneSideListeners();
}
attachZoneSideListeners() {
this.shadowRoot.querySelectorAll(".zone-row[data-zone-id]").forEach((row) => {
row.addEventListener("click", () => {
const id = row.dataset.zoneId;
this.selectedZoneId = id;
if (this._mapEditor) this._mapEditor.selectedId = id;
this.refreshZonesPanel();
}); });
}); });
// Zone day toggles const z = this.zones.find((x) => x.id === this.selectedZoneId);
this.shadowRoot.querySelectorAll(".zone-day").forEach(dayBtn => { if (!z) return;
dayBtn.addEventListener("click", () => {
const barrio = dayBtn.dataset.barrio;
const day = dayBtn.dataset.day;
const config = this.getZoneConfig(barrio);
if (!config) return;
const days = config.days || []; const onChange = () => this.refreshZonesList();
const nameEl = this.shadowRoot.getElementById("zoneName");
nameEl?.addEventListener("input", () => { z.name = nameEl.value; onChange(); });
const costEl = this.shadowRoot.getElementById("zoneCost");
costEl?.addEventListener("change", () => { z.delivery_cost = Number(costEl.value) || 0; onChange(); });
const minEl = this.shadowRoot.getElementById("zoneMin");
minEl?.addEventListener("change", () => { z.min_order_amount = Number(minEl.value) || 0; });
const startEl = this.shadowRoot.getElementById("zoneStart");
startEl?.addEventListener("change", () => {
z.delivery_hours = { ...(z.delivery_hours || {}), start: startEl.value };
onChange();
});
const endEl = this.shadowRoot.getElementById("zoneEnd");
endEl?.addEventListener("change", () => {
z.delivery_hours = { ...(z.delivery_hours || {}), end: endEl.value };
onChange();
});
const enabledEl = this.shadowRoot.getElementById("zoneEnabled");
enabledEl?.addEventListener("change", () => {
z.enabled = enabledEl.value === "true";
onChange();
});
this.shadowRoot.querySelectorAll(".zone-day-pick").forEach((btn) => {
btn.addEventListener("click", () => {
const day = btn.dataset.day;
const days = z.delivery_days || [];
const idx = days.indexOf(day); const idx = days.indexOf(day);
if (idx >= 0) { if (idx >= 0) days.splice(idx, 1);
days.splice(idx, 1); else days.push(day);
} else { z.delivery_days = days;
days.push(day); btn.classList.toggle("active", days.includes(day));
onChange();
});
});
this.shadowRoot.getElementById("zoneFitBtn")?.addEventListener("click", () => {
if (this._mapEditor) this._mapEditor.selectedId = z.id;
});
this.shadowRoot.getElementById("zoneDeleteBtn")?.addEventListener("click", () => {
if (this._mapEditor) this._mapEditor.removeZone(z.id);
this.zones = this.zones.filter((x) => x.id !== z.id);
if (this.selectedZoneId === z.id) this.selectedZoneId = this.zones[0]?.id || null;
this.refreshZonesPanel();
});
} }
config.days = days;
this.setZoneConfig(barrio, config);
// Update UI without full re-render refreshZonesPanel() {
dayBtn.classList.toggle("active", days.includes(day)); const list = this.shadowRoot.getElementById("zonesList");
}); const formSlot = this.shadowRoot.getElementById("zoneFormSlot");
}); if (list) list.innerHTML = this.renderZonesList();
if (formSlot) formSlot.innerHTML = this.renderZoneForm();
this.attachZoneSideListeners();
}
// Zone cost inputs refreshZonesList() {
this.shadowRoot.querySelectorAll(".zone-cost-input").forEach(input => { const list = this.shadowRoot.getElementById("zonesList");
input.addEventListener("change", () => { if (list) list.innerHTML = this.renderZonesList();
const barrio = input.dataset.barrio; this.shadowRoot.querySelectorAll(".zone-row[data-zone-id]").forEach((row) => {
const config = this.getZoneConfig(barrio); row.addEventListener("click", () => {
if (!config) return; const id = row.dataset.zoneId;
this.selectedZoneId = id;
config.delivery_cost = parseFloat(input.value) || 0; if (this._mapEditor) this._mapEditor.selectedId = id;
this.setZoneConfig(barrio, config); this.refreshZonesPanel();
}); });
}); });
} }
@@ -611,8 +697,30 @@ class SettingsCrud extends HTMLElement {
// Collect schedule from inputs // Collect schedule from inputs
const schedule = this.collectScheduleFromInputs(); const schedule = this.collectScheduleFromInputs();
// Collect delivery zones (already in settings from event handlers) // Antes de serializar, refrescar polígonos desde el editor por si el
const delivery_zones = this.settings.delivery_zones || {}; // usuario editó vértices y no llegó a disparar otro evento change.
if (this._mapEditor) {
const live = this._mapEditor.zones;
this.zones = this.zones.map((z) => {
const fromMap = live.find((x) => x.id === z.id);
return fromMap ? { ...z, polygon: fromMap.polygon } : z;
});
}
const cleanZones = this.zones
.filter((z) => z.polygon && Array.isArray(z.polygon.coordinates))
.map((z) => ({
id: z.id,
name: z.name || z.id,
polygon: z.polygon,
delivery_cost: Number(z.delivery_cost) || 0,
delivery_days: Array.isArray(z.delivery_days) ? z.delivery_days : [],
delivery_hours: z.delivery_hours || { start: "10:00", end: "20:00" },
min_order_amount: Number(z.min_order_amount) || 0,
enabled: z.enabled !== false,
}));
const delivery_zones = { zones: cleanZones };
const data = { const data = {
store_name: this.shadowRoot.getElementById("storeName")?.value || "", store_name: this.shadowRoot.getElementById("storeName")?.value || "",

View File

@@ -0,0 +1,363 @@
/**
* <zone-map-editor> — editor de zonas de delivery sobre un mapa.
*
* Light DOM (sin shadow) para que Leaflet (que asume un DOM normal con CSS
* global) funcione bien dentro de paneles que sí usan shadow DOM (settings-crud).
*
* Carga Leaflet 1.9 + leaflet-geoman desde CDN al montar. Las primeras
* instancias inyectan los <script>/<link>; las siguientes esperan a que
* window.L y window.L.PM estén disponibles.
*
* API pública:
* set zones(arr) // [{id, name, polygon, delivery_cost, delivery_days, ...}]
* get zones() // serializado actual con coordenadas GeoJSON
* set selectedId(id)
* on("change", e => ...) // dispara cuando se crea/edita/borra/renombra
* on("select", e => ...) // dispara cuando se hace click en un polígono
*
* Uso:
* const ed = document.createElement("zone-map-editor");
* ed.zones = [...];
* ed.addEventListener("change", e => console.log(e.detail.zones));
*/
const LEAFLET_VERSION = "1.9.4";
const GEOMAN_VERSION = "2.18.3";
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VERSION}/dist/leaflet.css`;
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VERSION}/dist/leaflet.js`;
const GEOMAN_CSS = `https://unpkg.com/@geoman-io/leaflet-geoman-free@${GEOMAN_VERSION}/dist/leaflet-geoman.css`;
const GEOMAN_JS = `https://unpkg.com/@geoman-io/leaflet-geoman-free@${GEOMAN_VERSION}/dist/leaflet-geoman.min.js`;
const DEFAULT_CENTER = [-34.6037, -58.3816]; // Obelisco (lat, lng — Leaflet usa [lat,lng])
const DEFAULT_ZOOM = 12;
const ZONE_PALETTE = [
"--chart-blue", "--chart-green", "--chart-purple",
"--chart-orange", "--chart-pink", "--chart-gray",
];
let _libsPromise = null;
function ensureLeaflet() {
if (window.L && window.L.PM) return Promise.resolve();
if (_libsPromise) return _libsPromise;
_libsPromise = (async () => {
if (!document.querySelector(`link[href="${LEAFLET_CSS}"]`)) {
const l1 = document.createElement("link");
l1.rel = "stylesheet"; l1.href = LEAFLET_CSS;
document.head.appendChild(l1);
}
if (!document.querySelector(`link[href="${GEOMAN_CSS}"]`)) {
const l2 = document.createElement("link");
l2.rel = "stylesheet"; l2.href = GEOMAN_CSS;
document.head.appendChild(l2);
}
if (!window.L) {
await loadScript(LEAFLET_JS);
}
if (!window.L.PM) {
await loadScript(GEOMAN_JS);
}
})();
return _libsPromise;
}
function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) {
resolve();
return;
}
const s = document.createElement("script");
s.src = src;
s.async = true;
s.onload = () => resolve();
s.onerror = () => reject(new Error(`Failed to load ${src}`));
document.head.appendChild(s);
});
}
function cssVar(name, fallback = "#0ea5e9") {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
function slugify(name) {
return String(name || "")
.toLowerCase()
.normalize("NFD").replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40) || `zona-${Date.now().toString(36)}`;
}
class ZoneMapEditor extends HTMLElement {
constructor() {
super();
this._zones = []; // estado interno (con polygon GeoJSON)
this._layersById = new Map(); // id -> leaflet layer
this._selectedId = null;
this._map = null;
this._mapDiv = null;
this._ready = false;
}
connectedCallback() {
this.style.display = "block";
this.style.position = "relative";
this.style.width = "100%";
this.style.height = this.getAttribute("height") || "480px";
this._mapDiv = document.createElement("div");
this._mapDiv.style.width = "100%";
this._mapDiv.style.height = "100%";
this._mapDiv.style.borderRadius = "var(--r-md, 10px)";
this._mapDiv.style.overflow = "hidden";
this._mapDiv.style.border = "1px solid var(--border, #e2e8f0)";
this.appendChild(this._mapDiv);
ensureLeaflet().then(() => this._initMap()).catch((err) => {
this._mapDiv.innerHTML = `<div style="padding:16px;color:var(--err);font-family:var(--font-sans);">No se pudo cargar el mapa: ${err.message}</div>`;
});
}
disconnectedCallback() {
if (this._map) {
this._map.remove();
this._map = null;
}
this._layersById.clear();
this._ready = false;
}
// ───────────── API pública ─────────────
get zones() {
// Devolver el estado interno con polígonos sincronizados desde los layers.
return this._zones.map((z) => ({ ...z, polygon: this._serializePolygon(z.id) || z.polygon || null }));
}
set zones(arr) {
const list = Array.isArray(arr) ? arr : [];
this._zones = list.map((z) => ({
id: z.id || slugify(z.name || ""),
name: z.name || "",
polygon: z.polygon || null,
delivery_cost: z.delivery_cost ?? 0,
delivery_days: Array.isArray(z.delivery_days) ? z.delivery_days : ["lun","mar","mie","jue","vie","sab"],
delivery_hours: z.delivery_hours || { start: "10:00", end: "20:00" },
min_order_amount: z.min_order_amount ?? 0,
enabled: z.enabled !== false,
}));
if (this._ready) this._renderLayers();
}
get selectedId() { return this._selectedId; }
set selectedId(id) {
this._selectedId = id || null;
if (!this._ready) return;
for (const [zid, layer] of this._layersById) {
this._applyLayerStyle(zid, layer, zid === this._selectedId);
}
if (id && this._layersById.has(id)) {
const layer = this._layersById.get(id);
try { this._map.fitBounds(layer.getBounds().pad(0.25)); } catch {}
}
}
upsertZone(zone) {
const idx = this._zones.findIndex((z) => z.id === zone.id);
if (idx >= 0) this._zones[idx] = { ...this._zones[idx], ...zone };
else this._zones.push(zone);
this._renderLayers();
this._emit("change");
}
removeZone(id) {
this._zones = this._zones.filter((z) => z.id !== id);
if (this._layersById.has(id)) {
this._map.removeLayer(this._layersById.get(id));
this._layersById.delete(id);
}
if (this._selectedId === id) this._selectedId = null;
this._emit("change");
}
startDrawing() {
if (!this._ready) return;
this._map.pm.enableDraw("Polygon", {
snappable: true,
templineStyle: { color: cssVar("--chart-blue") },
hintlineStyle: { color: cssVar("--chart-blue"), dashArray: [5, 5] },
pathOptions: this._defaultPathOptions(),
});
}
// ───────────── Internals ─────────────
_initMap() {
const L = window.L;
const center = DEFAULT_CENTER;
this._map = L.map(this._mapDiv, {
center, zoom: DEFAULT_ZOOM, zoomControl: true, attributionControl: true,
});
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(this._map);
// Geoman: solo botón "draw polygon" + edit/delete contextual desde la UI nuestra.
this._map.pm.addControls({
position: "topright",
drawCircle: false, drawCircleMarker: false, drawMarker: false,
drawPolyline: false, drawRectangle: false, drawText: false,
cutPolygon: false, rotateMode: false,
drawPolygon: true, editMode: true, dragMode: false, removalMode: true,
});
this._map.pm.setLang("es");
this._map.pm.setGlobalOptions({
pathOptions: this._defaultPathOptions(),
snappable: true,
});
// Listeners globales de geoman.
this._map.on("pm:create", (e) => this._handleCreate(e));
this._map.on("pm:remove", (e) => this._handleRemove(e));
this._ready = true;
this._renderLayers();
}
_renderLayers() {
if (!this._ready) return;
const L = window.L;
// Borrar layers que ya no existen.
for (const [id, layer] of this._layersById) {
if (!this._zones.some((z) => z.id === id)) {
this._map.removeLayer(layer);
this._layersById.delete(id);
}
}
// Crear/actualizar.
this._zones.forEach((z, i) => {
if (!z.polygon || !Array.isArray(z.polygon.coordinates)) return;
const latlngs = z.polygon.coordinates[0].map(([lng, lat]) => [lat, lng]);
const existing = this._layersById.get(z.id);
if (existing) {
existing.setLatLngs(latlngs);
this._applyLayerStyle(z.id, existing, z.id === this._selectedId);
return;
}
const color = cssVar(ZONE_PALETTE[i % ZONE_PALETTE.length]);
const layer = L.polygon(latlngs, this._pathOptions(color, z.id === this._selectedId)).addTo(this._map);
layer.bindTooltip(z.name || z.id, { sticky: true });
layer.on("click", () => this._select(z.id));
layer.on("pm:edit", () => this._handleEdit(z.id, layer));
this._layersById.set(z.id, layer);
});
}
_select(id) {
this._selectedId = id;
for (const [zid, layer] of this._layersById) {
this._applyLayerStyle(zid, layer, zid === id);
}
this._emit("select", { id });
}
_handleCreate(e) {
const layer = e.layer;
const id = `zona-${Date.now().toString(36)}`;
const i = this._zones.length;
const color = cssVar(ZONE_PALETTE[i % ZONE_PALETTE.length]);
layer.setStyle(this._pathOptions(color, true));
const polygon = this._toGeoJSONPolygon(layer.getLatLngs());
const zone = {
id,
name: `Zona ${i + 1}`,
polygon,
delivery_cost: 0,
delivery_days: ["lun","mar","mie","jue","vie","sab"],
delivery_hours: { start: "10:00", end: "20:00" },
min_order_amount: 0,
enabled: true,
};
this._zones.push(zone);
this._layersById.set(id, layer);
layer.bindTooltip(zone.name, { sticky: true });
layer.on("click", () => this._select(id));
layer.on("pm:edit", () => this._handleEdit(id, layer));
this._select(id);
this._emit("change");
this._emit("create", { id, zone });
}
_handleEdit(id, layer) {
const z = this._zones.find((x) => x.id === id);
if (!z) return;
z.polygon = this._toGeoJSONPolygon(layer.getLatLngs());
this._emit("change");
}
_handleRemove(e) {
const layer = e.layer;
let removedId = null;
for (const [id, l] of this._layersById) {
if (l === layer) { removedId = id; break; }
}
if (!removedId) return;
this._zones = this._zones.filter((z) => z.id !== removedId);
this._layersById.delete(removedId);
if (this._selectedId === removedId) this._selectedId = null;
this._emit("change");
}
_serializePolygon(id) {
const layer = this._layersById.get(id);
if (!layer) return null;
return this._toGeoJSONPolygon(layer.getLatLngs());
}
_toGeoJSONPolygon(latlngs) {
// Leaflet pasa anidado: [[{lat,lng}...]] para polígonos simples.
const ring = Array.isArray(latlngs[0]) ? latlngs[0] : latlngs;
const coords = ring.map((p) => [p.lng, p.lat]);
// Cerrar el anillo si no está cerrado (GeoJSON lo requiere).
const first = coords[0];
const last = coords[coords.length - 1];
if (!first || !last || first[0] !== last[0] || first[1] !== last[1]) {
if (first) coords.push([first[0], first[1]]);
}
return { type: "Polygon", coordinates: [coords] };
}
_defaultPathOptions() {
return this._pathOptions(cssVar("--chart-blue"), false);
}
_pathOptions(color, selected) {
return {
color,
weight: selected ? 3 : 2,
opacity: 0.95,
fillColor: color,
fillOpacity: selected ? 0.28 : 0.18,
};
}
_applyLayerStyle(id, layer, selected) {
const i = this._zones.findIndex((z) => z.id === id);
const color = cssVar(ZONE_PALETTE[(i < 0 ? 0 : i) % ZONE_PALETTE.length]);
layer.setStyle(this._pathOptions(color, selected));
}
_emit(name, detail = {}) {
detail = { zones: this.zones, ...detail };
this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
}
}
if (!customElements.get("zone-map-editor")) {
customElements.define("zone-map-editor", ZoneMapEditor);
}

View File

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

View File

@@ -27,6 +27,7 @@ export async function handleEvolutionWebhook(body) {
from: parsed.chat_id.replace("@s.whatsapp.net", ""), from: parsed.chat_id.replace("@s.whatsapp.net", ""),
displayName: parsed.from_name || null, displayName: parsed.from_name || null,
text: parsed.text, text: parsed.text,
inboundLocation: parsed.location || null,
provider: "evolution", provider: "evolution",
message_id: parsed.message_id || crypto.randomUUID(), message_id: parsed.message_id || crypto.randomUUID(),
meta: { pushName: parsed.from_name, ts: parsed.ts, source: parsed.source }, 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"; import { getTenantId } from "../../shared/tenant.js";
export async function handleSimSend(body) { export async function handleSimSend(body) {
const { chat_id, from_phone, text } = body || {}; const { chat_id, from_phone, text, location } = body || {};
if (!chat_id || !from_phone || !text) { if (!chat_id || !from_phone || (!text && !location)) {
return { status: 400, payload: { ok: false, error: "chat_id, from_phone, text are required" } }; 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 provider = "sim";
const message_id = crypto.randomUUID(); const message_id = crypto.randomUUID();
const tenantId = getTenantId(); const tenantId = getTenantId();
@@ -16,7 +23,8 @@ export async function handleSimSend(body) {
tenantId, tenantId,
chat_id, chat_id,
from: from_phone, from: from_phone,
text, text: text || "",
inboundLocation,
provider, provider,
message_id, message_id,
}); });

View File

@@ -34,8 +34,19 @@ export function parseEvolutionWebhook(reqBody) {
(typeof msg.extendedTextMessage?.text === "string" && msg.extendedTextMessage.text) || (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(); const cleanText = String(text).trim();
if (!cleanText) return { ok: false, reason: "empty_text" }; if (!cleanText && !location) return { ok: false, reason: "empty_message" };
// metadata // metadata
const pushName = data.pushName || null; const pushName = data.pushName || null;
@@ -48,6 +59,7 @@ export function parseEvolutionWebhook(reqBody) {
chat_id: remoteJid, chat_id: remoteJid,
message_id: messageId || null, message_id: messageId || null,
text: cleanText, text: cleanText,
location,
from_name: pushName, from_name: pushName,
message_type: messageType || null, message_type: messageType || null,
ts, ts,

View File

@@ -121,6 +121,7 @@ export async function processMessage({
message_id, message_id,
displayName = null, displayName = null,
meta = null, meta = null,
inboundLocation = null,
}) { }) {
const { started_at, mark, msBetween } = makePerf(); const { started_at, mark, msBetween } = makePerf();
const prev = await touchConversationState({ tenant_id: tenantId, wa_chat_id: chat_id }); 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(), _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 decision;
let plan; let plan;
let llmMeta; let llmMeta;

View File

@@ -321,6 +321,8 @@ function toBasketItem(item) {
function buildContextPatch(ctx) { function buildContextPatch(ctx) {
const order = ctx.order || createEmptyOrder(); const order = ctx.order || createEmptyOrder();
return { 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,
order_basket: { items: (order.cart || []).map(toBasketItem) }, order_basket: { items: (order.cart || []).map(toBasketItem) },
pending_items: (order.pending || []).map((p) => ({ pending_items: (order.pending || []).map((p) => ({

View File

@@ -30,7 +30,8 @@ REGLAS DURAS:
llamá escalate_to_human. llamá escalate_to_human.
CÓMO PROCESAS UN MENSAJE (user message viene como JSON con working_memory): 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" - preparsed: tiene cantidades parseadas (ej: "media docena" → 6 unit; "1/4 kg"
→ 0.25 kg). Confiá en eso si su confidence ≥ 0.85. → 0.25 kg). Confiá en eso si su confidence ≥ 0.85.
- Si user dice "el segundo", "ese", "el primero", "el de arriba", resolvé - 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 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. elija entre los top 3-5 (numerados). El sistema guarda last_shown_options.
4. (opcional) add_to_cart / set_quantity / select_candidate / set_shipping / 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. 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: LIMITES TÉCNICOS:
- Tenés un máximo de 10 tool calls por turno. No los gastes en búsquedas - 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. 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. * 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"; 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) { export async function confirmOrderTool(_args, ctx) {
if (!hasCartItems(ctx.order)) { 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)) { if (!hasShippingInfo(ctx.order)) {
return { return {
@@ -18,15 +52,83 @@ export async function confirmOrderTool(_args, ctx) {
: "Falta dirección. Llamá set_address.", : "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 // Idempotencia: si ya existe create_order encolado, no duplicar
const already = ctx.pending_actions.some((a) => a.type === "create_order"); const already = ctx.pending_actions.some((a) => a.type === "create_order");
if (!already) { 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 { return {
ok: true, ok: true,
cart_size: (ctx.order.cart || []).length, cart_size: (ctx.order.cart || []).length,
is_delivery: !!ctx.order.is_delivery, is_delivery: !!ctx.order.is_delivery,
address: ctx.order.shipping_address || null, 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 { removeFromCartTool } from "./removeFromCart.js";
import { setShippingTool } from "./setShipping.js"; import { setShippingTool } from "./setShipping.js";
import { setAddressTool } from "./setAddress.js"; import { setAddressTool } from "./setAddress.js";
import { setDeliveryWindowTool } from "./setDeliveryWindow.js";
import { confirmOrderTool } from "./confirmOrder.js"; import { confirmOrderTool } from "./confirmOrder.js";
import { pauseTool } from "./pause.js"; import { pauseTool } from "./pause.js";
import { escalateToHumanTool } from "./escalateToHuman.js"; import { escalateToHumanTool } from "./escalateToHuman.js";
@@ -37,6 +38,7 @@ const TOOLS = {
remove_from_cart: removeFromCartTool, remove_from_cart: removeFromCartTool,
set_shipping: setShippingTool, set_shipping: setShippingTool,
set_address: setAddressTool, set_address: setAddressTool,
set_delivery_window: setDeliveryWindowTool,
confirm_order: confirmOrderTool, confirm_order: confirmOrderTool,
pause: pauseTool, pause: pauseTool,
escalate_to_human: escalateToHumanTool, escalate_to_human: escalateToHumanTool,

View File

@@ -109,13 +109,39 @@ export const TOOL_SCHEMAS = [
type: "function", type: "function",
function: { function: {
name: "set_address", 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: { parameters: {
type: "object", type: "object",
additionalProperties: false, additionalProperties: false,
required: ["text"], required: ["text"],
properties: { 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) { export async function setAddressTool(args, ctx) {
const { text } = args; const { text } = args || {};
const address = String(text || "").trim(); 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 allZones = ctx.storeConfig?.delivery_zones?.zones || [];
const zoneCheck = checkAddressInZone({ const enabled = allZones.filter((z) => z?.enabled !== false);
address,
storeConfig: ctx.storeConfig, // Sin zonas configuradas, aceptamos la dirección sin validar zona.
}); if (!enabled.length) {
if (!zoneCheck.inZone) { 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 { return {
ok: false, ok: false,
error: "out_of_zone", error: "need_location",
reason: zoneCheck.reason, hint:
available_zones: zoneCheck.zones, "Pedile al cliente que te mande su ubicación por WhatsApp (pin/location share) " +
hint: "Pedile al cliente otra dirección o ofrecele pickup.", "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 matched = findZoneForPoint(loc.lng, loc.lat, enabled);
const is_delivery = ctx.order.is_delivery == null ? true : ctx.order.is_delivery; if (!matched) {
ctx.order = { ...ctx.order, shipping_address: address, is_delivery }; 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 { return {
ok: true, ok: true,
address, address,
in_zone: true, 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) { export async function setShippingTool(args, ctx) {
const { method } = args; const { method } = args || {};
if (method !== "delivery" && method !== "pickup") { if (method !== "delivery" && method !== "pickup") {
return { ok: false, error: "invalid_method" }; return { ok: false, error: "invalid_method" };
} }
ctx.order = { ...ctx.order, is_delivery: method === "delivery" }; 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 { parseQuantity } from "./quantityParser.js";
import { buildStoreContextVars } from "../storeContext.js"; import { buildStoreContextVars, buildZonesForLLM } from "../storeContext.js";
const HISTORY_MAX = 8; const HISTORY_MAX = 8;
const HISTORY_CHAR_CAP = 200; const HISTORY_CHAR_CAP = 200;
@@ -90,14 +90,22 @@ export function buildWorkingMemory({
const preparsed = parseQuantity(text || ""); const preparsed = parseQuantity(text || "");
const zones = buildZonesForLLM(storeConfig.delivery_zones);
const pickupSchedule = storeConfig.schedule?.pickup || null;
return { return {
now: nowIso(), now: nowIso(),
store: { store: {
name: storeVars.store_name || "la carnicería", name: storeVars.store_name || "la carnicería",
hours_today: storeVars.store_hours_today || "consultar", hours_today: storeVars.store_hours_today || "consultar",
delivery: { 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 || "", 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", fsm_state: prev_state || "IDLE",
@@ -107,6 +115,9 @@ export function buildWorkingMemory({
is_delivery: order.is_delivery ?? null, is_delivery: order.is_delivery ?? null,
shipping_address: order.shipping_address ?? null, shipping_address: order.shipping_address ?? null,
woo_order_id: order.woo_order_id ?? 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, last_shown_options,
paused_until: order.paused_until ?? null, 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 is_delivery: null, // true | false | null
shipping_address: null, shipping_address: null,
woo_order_id: 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 * Schema canónico de delivery_zones (JSONB en tenant_settings):
* pipeline / turnEngine) y producen variables consumibles por reply templates * {
* (renderReply via {{var}}) y por el reply rewriter (como hint contextual). * zones: [
* * {
* Filosofía: cuando los datos no están cargados, las vars resuelven a strings * id: "centro",
* vacíos / hints neutros. Los templates están diseñados para tolerar ausencia. * 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"]; import { findZoneForPoint } from "./lib/geo.js";
const DAY_KEYS_LONG = ["lunes", "martes", "miercoles", "jueves", "viernes", "sabado", "domingo"];
/** const DAY_KEYS_SHORT = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
* Devuelve la clave de día (mon..sun) para hoy. const DAY_NAMES = {
*/ lun: "Lunes", mar: "Martes", mie: "Miércoles",
function todayKey() { jue: "Jueves", vie: "Viernes", sab: "Sábado", dom: "Domingo",
// Date.getDay(): 0 = domingo, 1 = lunes };
const d = new Date().getDay();
// mapear a mon..sun function todayShortKey() {
return DAY_KEYS[(d + 6) % 7]; // 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) {
* 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) {
if (!scheduleObj || typeof scheduleObj !== "object") return null; if (!scheduleObj || typeof scheduleObj !== "object") return null;
const keys = [DAY_KEYS[dayIdx], DAY_KEYS_LONG[dayIdx]]; return scheduleObj[dayKey] || null;
for (const k of keys) {
if (scheduleObj[k]) return scheduleObj[k];
}
return null;
} }
function formatDaySlot(slot) { function formatDaySlot(slot) {
@@ -45,104 +46,114 @@ function formatDaySlot(slot) {
return `${start} a ${end}`; return `${start} a ${end}`;
} }
/** function getZones(deliveryZones) {
* Resumen de zonas de delivery: lista de barrios o "Consultar zonas".
*/
/**
* Lista plana de barrios (lowercase) habilitados para delivery.
*/
function getDeliveryZoneNames(deliveryZones) {
if (!deliveryZones || typeof deliveryZones !== "object") return []; if (!deliveryZones || typeof deliveryZones !== "object") return [];
const names = []; if (!Array.isArray(deliveryZones.zones)) return [];
if (Array.isArray(deliveryZones.zones)) { return deliveryZones.zones.filter((z) => z && z.enabled !== false);
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);
} }
/** /**
* Verifica si un texto de dirección menciona un barrio en zona. * Devuelve los nombres de zonas habilitadas (sin polygon, para UI/log).
* - 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).
*/ */
export function checkAddressInZone({ address, storeConfig }) { export function getDeliveryZoneNames(deliveryZones) {
const zones = getDeliveryZoneNames(storeConfig?.delivery_zones); return getZones(deliveryZones).map((z) => String(z.name || z.id || "").trim()).filter(Boolean);
if (!zones.length) { }
/**
* 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: [] }; return { inZone: true, reason: "no_zones_configured", zones: [] };
} }
const norm = String(address || "") if (!location || typeof location.lat !== "number" || typeof location.lng !== "number") {
.toLowerCase() return {
.normalize("NFD") inZone: false,
.replace(/[̀-ͯ]/g, "") reason: "need_location",
.trim(); zones: allZones.map(zoneSummary),
if (!norm) return { inZone: false, reason: "empty_address", zones }; };
const match = zones.find((z) => { }
const zNorm = z.normalize("NFD").replace(/[̀-ͯ]/g, ""); const matched = findZoneForPoint(location.lng, location.lat, allZones);
return norm.includes(zNorm); if (matched) {
}); return { inZone: true, reason: "matched", matched_zone: zoneSummary(matched), zones: allZones.map(zoneSummary) };
if (match) return { inZone: true, reason: "matched", matched: match, zones }; }
return { inZone: false, reason: "not_in_zone", zones }; return { inZone: false, reason: "out_of_zones", zones: allZones.map(zoneSummary) };
} }
function summarizeDeliveryZones(deliveryZones) { function zoneSummary(z) {
if (!deliveryZones || typeof deliveryZones !== "object") return ""; return {
const names = []; id: z.id,
// Soporta varios formatos: name: z.name,
// 1) { caba: { barrios: ["Palermo", "Belgrano"] } } delivery_cost: z.delivery_cost ?? null,
// 2) { zones: [{ name }] } delivery_days: Array.isArray(z.delivery_days) ? z.delivery_days : [],
// 3) { palermo: true, belgrano: true } (flat) delivery_hours: z.delivery_hours || null,
if (Array.isArray(deliveryZones.zones)) { min_order_amount: z.min_order_amount ?? 0,
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`;
} }
/** /**
* Construye variables de contexto de tienda para usar en reply templates. * Formato compacto que ve el LLM en working_memory.store.delivery.zones[].
* Cuando los datos no están, las vars vienen vacías — los templates las * Sin polygon (no le sirve al modelo), sí con costos/días/horas.
* absorben sin romper. */
* export function buildZonesForLLM(deliveryZones) {
* @param {Object} storeConfig - resultado de getStoreConfig({ tenantId }) return getZones(deliveryZones).map(zoneSummary);
* @returns {Object} vars para applyVariables / renderReply }
/**
* 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 = {}) { export function buildStoreContextVars(storeConfig = {}) {
const dayIdx = (new Date().getDay() + 6) % 7; // 0=lunes const dayKey = todayShortKey();
const sched = storeConfig.schedule || {}; const sched = storeConfig.schedule || {};
const deliverySlot = pickDaySlot(sched.delivery, dayIdx); const pickupSlot = pickDaySlot(sched.pickup, dayKey);
const pickupSlot = pickDaySlot(sched.pickup, dayIdx); const storeHoursToday = formatDaySlot(pickupSlot) || "";
const storeHoursToday = formatDaySlot(deliverySlot) || formatDaySlot(pickupSlot) || "";
const deliveryAvailableNow = deliverySlot ? "sí" : (storeConfig.deliveryEnabled === false ? "no" : "");
const deliveryZonesSummary = summarizeDeliveryZones(storeConfig.delivery_zones);
return { return {
store_name: storeConfig.name || "", store_name: storeConfig.name || "",
bot_name: storeConfig.botName || "", bot_name: storeConfig.botName || "",
store_address: storeConfig.address || "", store_address: storeConfig.address || "",
store_phone: storeConfig.phone || "", store_phone: storeConfig.phone || "",
store_hours: storeConfig.hours || "",
store_hours_today: storeHoursToday, store_hours_today: storeHoursToday,
delivery_hours: storeConfig.deliveryHours || "", pickup_hours_today: formatDaySlot(pickupSlot) || "",
pickup_hours: storeConfig.pickupHours || "", delivery_zones_summary: summarizeDeliveryZones(storeConfig.delivery_zones),
delivery_available_now: deliveryAvailableNow,
delivery_zones_summary: deliveryZonesSummary,
}; };
} }
export const __test__ = { todayShortKey, pickDaySlot, formatDaySlot, formatDaysList, DAY_NAMES };