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:
13
db/migrations/20260502181148_drop_legacy_zones_format.sql
Normal file
13
db/migrations/20260502181148_drop_legacy_zones_format.sql
Normal 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;
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 || "",
|
||||||
|
|||||||
363
public/components/zone-map-editor.js
Normal file
363
public/components/zone-map-editor.js
Normal 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: '© <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);
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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." },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/modules/3-turn-engine/agent/tools/setDeliveryWindow.js
Normal file
26
src/modules/3-turn-engine/agent/tools/setDeliveryWindow.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
51
src/modules/3-turn-engine/lib/geo.js
Normal file
51
src/modules/3-turn-engine/lib/geo.js
Normal 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;
|
||||||
|
}
|
||||||
103
src/modules/3-turn-engine/lib/geo.test.js
Normal file
103
src/modules/3-turn-engine/lib/geo.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user