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/test-panel.js";
import "./components/takeovers-crud.js";
import "./components/zone-map-editor.js";
import "./components/settings-crud.js";
import { connectSSE } from "./lib/sse.js";
import { initRouter } from "./lib/router.js";

View File

@@ -10,19 +10,12 @@ const DAYS = [
{ id: "dom", label: "Domingo", short: "D" },
];
// Lista oficial de 48 barrios de CABA
const CABA_BARRIOS = [
"Agronomía", "Almagro", "Balvanera", "Barracas", "Belgrano", "Boedo",
"Caballito", "Chacarita", "Coghlan", "Colegiales", "Constitución",
"Flores", "Floresta", "La Boca", "La Paternal", "Liniers",
"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"
];
function makeZoneId(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 SettingsCrud extends HTMLElement {
constructor() {
@@ -31,6 +24,9 @@ class SettingsCrud extends HTMLElement {
this.settings = null;
this.loading = false;
this.saving = false;
this.zones = [];
this.selectedZoneId = null;
this._mapEditor = null;
this.shadowRoot.innerHTML = `
<style>
@@ -128,60 +124,40 @@ class SettingsCrud extends HTMLElement {
.min-order-field { margin-top:16px; padding-top:16px; border-top:1px solid var(--border); }
/* Zonas de entrega */
.zones-search { margin-bottom:12px; }
.zones-search input {
width:100%; padding:10px 14px;
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;
background-size:18px; padding-left:38px;
}
.zones-list { max-height:400px; overflow-y:auto; display:flex; flex-direction:column; gap:4px; }
/* Zonas de entrega — editor con mapa */
.zones-layout { display:grid; grid-template-columns:280px 1fr; gap:16px; }
.zones-side { display:flex; flex-direction:column; gap:8px; min-width:0; }
.zones-side-header { display:flex; align-items:center; justify-content:space-between; }
.zones-side-header h4 { margin:0; font-size:13px; color:var(--text); }
.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; }
.zone-row {
display:grid;
grid-template-columns:32px 1fr;
gap:12px;
align-items:start;
padding:10px 12px;
background:var(--panel-2);
border-radius:8px;
border:1px solid var(--border);
transition:border-color .2s;
display:flex; align-items:center; gap:10px;
padding:10px 12px; border-radius:var(--r-md, 10px);
background:var(--panel-2); border:1px solid var(--border);
cursor:pointer; transition:border-color .15s, background .15s;
}
.zone-row:hover { border-color:var(--border-hi); }
.zone-row.active { border-color:var(--accent); background:var(--accent-soft); }
.zone-row.hidden { display:none; }
.zone-toggle {
width:32px; height:18px; background:var(--border-hi); border-radius:9px;
cursor:pointer; position:relative; transition:background .2s; margin-top:2px;
}
.zone-toggle.active { background:var(--ok); }
.zone-toggle::after {
content:''; position:absolute; top:2px; left:2px;
width:14px; height:14px; background:#fff; border-radius:50%;
transition:transform .2s;
}
.zone-toggle.active::after { transform:translateX(14px); }
.zone-content { display:flex; flex-direction:column; gap:8px; }
.zone-name { font-size:14px; color:var(--text); font-weight:500; }
.zone-config { display:none; gap:16px; flex-wrap:wrap; align-items:center; }
.zone-row.active .zone-config { display:flex; }
.zone-days { display:flex; gap:4px; }
.zone-day {
width:28px; height:28px; border-radius:6px;
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);
}
.zone-row.disabled { opacity:.55; }
.zone-swatch { width:14px; height:14px; border-radius:4px; flex-shrink:0; }
.zone-row-main { flex:1; min-width:0; display:flex; flex-direction:column; gap: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; }
.zones-empty { padding:24px; text-align:center; color:var(--text-muted); font-size:13px; }
.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; }
.zone-form .row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.zone-form .row.three { grid-template-columns:2fr 1fr 1fr; }
.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-days-pick { display:flex; gap:4px; flex-wrap:wrap; }
.zone-day-pick { padding:4px 8px; border-radius:6px; font-size:11px; font-weight:600; cursor:pointer;
background:var(--border); color:var(--text-muted); border:1px solid transparent; }
.zone-day-pick.active { background:var(--accent); color:var(--text-on-acc, #fff); }
.zone-row-actions { display:flex; gap:8px; justify-content:flex-end; }
.zone-row-actions button { padding:6px 10px; font-size:12px; }
.zone-row-actions .danger { background:var(--err); }
.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); }
.zones-summary strong { color:var(--text); }
</style>
@@ -204,14 +180,12 @@ class SettingsCrud extends HTMLElement {
try {
this.settings = await api.getSettings();
// Asegurar que schedule existe
if (!this.settings.schedule) {
this.settings.schedule = { delivery: {}, pickup: {} };
}
// Asegurar que delivery_zones existe
if (!this.settings.delivery_zones) {
this.settings.delivery_zones = {};
}
const dz = this.settings.delivery_zones || {};
this.zones = Array.isArray(dz.zones) ? dz.zones.map((z) => ({ ...z })) : [];
this.selectedZoneId = this.zones[0]?.id || null;
this.loading = false;
this.render();
} catch (e) {
@@ -275,70 +249,115 @@ class SettingsCrud extends HTMLElement {
}).join("");
}
// Convierte nombre de barrio a key (ej: "Villa Crespo" -> "villa_crespo")
barrioToKey(name) {
return name.toLowerCase()
.normalize("NFD").replace(/[\u0300-\u036f]/g, "") // quitar acentos
.replace(/\s+/g, "_");
ZONE_PALETTE_VARS() {
return ["--chart-blue", "--chart-green", "--chart-purple", "--chart-orange", "--chart-pink", "--chart-gray"];
}
getZoneConfig(barrioKey) {
return this.settings?.delivery_zones?.[barrioKey] || null;
zoneSwatchColor(idx) {
const palette = this.ZONE_PALETTE_VARS();
return `var(${palette[idx % palette.length]})`;
}
setZoneConfig(barrioKey, config) {
if (!this.settings.delivery_zones) {
this.settings.delivery_zones = {};
}
if (config === null) {
delete this.settings.delivery_zones[barrioKey];
} else {
this.settings.delivery_zones[barrioKey] = config;
formatDaysShort(days) {
if (!Array.isArray(days) || !days.length) return "\u2014";
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 (idx.length >= 3 && idx.every((v, i, arr) => i === 0 || v === arr[i - 1] + 1)) {
return `${order[idx[0]]}-${order[idx[idx.length - 1]]}`;
}
return idx.map((i) => order[i]).join("/");
}
renderZonesList() {
return CABA_BARRIOS.map(barrio => {
const key = this.barrioToKey(barrio);
const config = this.getZoneConfig(key);
const isActive = config?.enabled === true;
const days = config?.days || [];
const cost = config?.delivery_cost || 0;
if (!this.zones.length) {
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>`;
}
return this.zones.map((z, i) => {
const cost = z.delivery_cost != null ? `$${Number(z.delivery_cost).toLocaleString("es-AR")}` : "";
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 `
<div class="zone-row ${isActive ? 'active' : ''}" data-barrio="${key}">
<div class="zone-toggle ${isActive ? 'active' : ''}" data-barrio="${key}"></div>
<div class="zone-content">
<span class="zone-name">${barrio}</span>
<div class="zone-config">
<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 class="zone-row ${active} ${disabled}" data-zone-id="${z.id}">
<div class="zone-swatch" style="background:${this.zoneSwatchColor(i)}"></div>
<div class="zone-row-main">
<div class="zone-row-name">${this.escapeHtml(z.name || z.id)}</div>
<div class="zone-row-meta">${this.escapeHtml(meta || "sin configurar")}</div>
</div>
</div>
`;
}).join("");
}
renderZonesSummary() {
const zones = this.settings?.delivery_zones || {};
const activeZones = Object.entries(zones).filter(([k, v]) => v?.enabled);
if (activeZones.length === 0) {
return `<div class="zones-summary">No hay zonas de entrega configuradas. Activá los barrios donde hacés delivery.</div>`;
renderZoneForm() {
const z = this.zones.find((x) => x.id === this.selectedZoneId);
if (!z) {
return `<div class="zones-empty">Seleccioná una zona en la lista o dibujá una nueva en el mapa para configurarla.</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() {
@@ -434,19 +453,32 @@ class SettingsCrud extends HTMLElement {
<div class="panel">
<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>
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 class="zones-search">
<input type="text" id="zoneSearch" placeholder="Buscar barrio..." />
<div class="zones-layout">
<div class="zones-side">
<div class="zones-side-header">
<h4>Zonas</h4>
<button id="zoneCreateBtn" type="button">+ Crear zona</button>
</div>
<div class="zones-list" id="zonesList">
${this.renderZonesList()}
</div>
<div id="zoneFormSlot">
${this.renderZoneForm()}
</div>
${this.renderZonesSummary()}
</div>
<div>
<zone-map-editor id="zoneMapEditor" height="520px"></zone-map-editor>
</div>
</div>
</div>
<div class="actions">
<button id="saveBtn" ${this.saving ? "disabled" : ""}>${this.saving ? "Guardando..." : "Guardar Configuración"}</button>
@@ -518,70 +550,124 @@ class SettingsCrud extends HTMLElement {
// Reset button
this.shadowRoot.getElementById("resetBtn")?.addEventListener("click", () => this.load());
// Zone search
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.setupZoneEditor();
}
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
this.shadowRoot.querySelectorAll(".zone-day").forEach(dayBtn => {
dayBtn.addEventListener("click", () => {
const barrio = dayBtn.dataset.barrio;
const day = dayBtn.dataset.day;
const config = this.getZoneConfig(barrio);
if (!config) return;
const z = this.zones.find((x) => x.id === this.selectedZoneId);
if (!z) 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);
if (idx >= 0) {
days.splice(idx, 1);
} else {
days.push(day);
if (idx >= 0) days.splice(idx, 1);
else days.push(day);
z.delivery_days = days;
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
dayBtn.classList.toggle("active", days.includes(day));
});
});
refreshZonesPanel() {
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
this.shadowRoot.querySelectorAll(".zone-cost-input").forEach(input => {
input.addEventListener("change", () => {
const barrio = input.dataset.barrio;
const config = this.getZoneConfig(barrio);
if (!config) return;
config.delivery_cost = parseFloat(input.value) || 0;
this.setZoneConfig(barrio, config);
refreshZonesList() {
const list = this.shadowRoot.getElementById("zonesList");
if (list) list.innerHTML = this.renderZonesList();
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();
});
});
}
@@ -611,8 +697,30 @@ class SettingsCrud extends HTMLElement {
// Collect schedule from inputs
const schedule = this.collectScheduleFromInputs();
// Collect delivery zones (already in settings from event handlers)
const delivery_zones = this.settings.delivery_zones || {};
// Antes de serializar, refrescar polígonos desde el editor por si el
// 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 = {
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,
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 };