Limpiar legacy delivery_* + arreglar carga del mapa en shadow DOM

Backend cleanup (todo el delivery vive ahora en delivery_zones.zones[]):
- Migration drop columns delivery_enabled / delivery_days / delivery_hours_start /
  delivery_hours_end / delivery_min_order y limpieza de schedule.delivery JSONB.
- settingsRepo: SELECT/INSERT/UPDATE sólo con campos vigentes, formatScheduleHours
  trabaja sobre pickup.
- handlers/settings: defaults sin legacy, validateSchedule sólo para pickup,
  validateDeliveryZones nuevo (estructura GeoJSON + días).
- seed_piaf_settings_and_replies + tenant_settings migrations alineadas: schedule
  sólo tiene pickup, delivery_zones queda en {} para reconfigurar via UI.

Frontend cleanup:
- settings-crud: borrado el panel "Delivery (Envío a domicilio)" + minOrder,
  toggle deliveryEnabled, y el grid schedule.delivery. collectScheduleFromInputs
  ahora sólo procesa pickup. save() ya no envía delivery_enabled/min_order.

Fix mapa (no cargaba):
- zone-map-editor: los <link> a leaflet.css/leaflet-geoman.css se inyectaban en
  document.head, que NO cruza el shadow DOM de settings-crud, por lo que las
  reglas de Leaflet no aplicaban al div del mapa. Ahora los <link> se anclan
  como hijos del propio web component; al estar en light DOM dentro del shadow
  root del padre, sí aplican.
- Espera explícita a que el stylesheet cargue antes de instanciar L.map.
- ResizeObserver + invalidateSize() para cuando el contenedor cambia tamaño
  (router muestra/oculta panel, tabs, etc).

Smoke E2E sin regresión: 1kg vacío + envío → location en Centro → "martes 12hs"
→ orden confirmada con $28.000 (producto + envío). 157/157 tests verde.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Tettamanti
2026-05-02 15:37:47 -03:00
parent aed79078de
commit c93955fa55
6 changed files with 219 additions and 394 deletions

View File

@@ -8,33 +8,18 @@ UPDATE tenant_settings
SET SET
store_name = COALESCE(NULLIF(store_name, 'Mi Negocio'), 'Piaf'), store_name = COALESCE(NULLIF(store_name, 'Mi Negocio'), 'Piaf'),
bot_name = COALESCE(NULLIF(bot_name, 'Bot'), 'Piaf'), bot_name = COALESCE(NULLIF(bot_name, 'Bot'), 'Piaf'),
delivery_enabled = true,
pickup_enabled = true, pickup_enabled = true,
schedule = '{ schedule = '{
"delivery": {
"mon": {"enabled": true, "start": "09:00", "end": "13:00"},
"tue": {"enabled": true, "start": "09:00", "end": "13:00"},
"wed": {"enabled": true, "start": "09:00", "end": "13:00"},
"thu": {"enabled": true, "start": "09:00", "end": "13:00"},
"fri": {"enabled": true, "start": "09:00", "end": "13:00"},
"sat": {"enabled": true, "start": "09:00", "end": "13:00"},
"sun": {"enabled": false}
},
"pickup": { "pickup": {
"mon": {"enabled": true, "start": "09:00", "end": "20:00"}, "lun": {"enabled": true, "start": "09:00", "end": "20:00"},
"tue": {"enabled": true, "start": "09:00", "end": "20:00"}, "mar": {"enabled": true, "start": "09:00", "end": "20:00"},
"wed": {"enabled": true, "start": "09:00", "end": "20:00"}, "mie": {"enabled": true, "start": "09:00", "end": "20:00"},
"thu": {"enabled": true, "start": "09:00", "end": "20:00"}, "jue": {"enabled": true, "start": "09:00", "end": "20:00"},
"fri": {"enabled": true, "start": "09:00", "end": "20:00"}, "vie": {"enabled": true, "start": "09:00", "end": "20:00"},
"sat": {"enabled": true, "start": "09:00", "end": "13:00"}, "sab": {"enabled": true, "start": "09:00", "end": "13:00"}
"sun": {"enabled": false}
} }
}'::jsonb, }'::jsonb,
delivery_zones = '{ delivery_zones = '{}'::jsonb
"caba": {
"barrios": ["Palermo", "Belgrano", "Recoleta", "Villa Crespo", "Almagro", "Caballito", "Núñez", "Colegiales", "Chacarita", "Las Cañitas"]
}
}'::jsonb
WHERE tenant_id = 'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid; WHERE tenant_id = 'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid;
-- 2) reply_templates: seed con DEFAULTS de replyTemplates.js para piaf -- 2) reply_templates: seed con DEFAULTS de replyTemplates.js para piaf

View File

@@ -0,0 +1,18 @@
-- migrate:up
-- Borrar columnas obsoletas de tenant_settings: ahora cada zona de delivery
-- tiene su propio costo, días y rango horario. Schedule.delivery también queda
-- obsoleto (sólo pickup hace sentido como horario único de la tienda física).
ALTER TABLE tenant_settings
DROP COLUMN IF EXISTS delivery_enabled,
DROP COLUMN IF EXISTS delivery_days,
DROP COLUMN IF EXISTS delivery_hours_start,
DROP COLUMN IF EXISTS delivery_hours_end,
DROP COLUMN IF EXISTS delivery_min_order;
UPDATE tenant_settings
SET schedule = (schedule - 'delivery')
WHERE schedule ? 'delivery';
-- migrate:down
-- noop: no preservamos los campos legacy.
SELECT 1;

View File

@@ -408,30 +408,6 @@ class SettingsCrud extends HTMLElement {
</div> </div>
</div> </div>
<!-- Delivery -->
<div class="panel">
<div class="panel-title">
<svg viewBox="0 0 24 24"><path d="M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z"/></svg>
Delivery (Envío a domicilio)
</div>
<div class="toggle-row">
<div class="toggle ${s.delivery_enabled ? "active" : ""}" id="deliveryToggle"></div>
<span class="toggle-label">Delivery habilitado</span>
</div>
<div class="schedule-grid" id="deliverySchedule">
${this.renderScheduleGrid("delivery", s.delivery_enabled)}
</div>
<div class="min-order-field">
<div class="field">
<label>Pedido mínimo para delivery ($)</label>
<input type="number" id="deliveryMinOrder" value="${s.delivery_min_order || 0}" min="0" step="100" style="width:150px;" />
</div>
</div>
</div>
<!-- Retiro en tienda --> <!-- Retiro en tienda -->
<div class="panel"> <div class="panel">
<div class="panel-title"> <div class="panel-title">
@@ -490,13 +466,6 @@ class SettingsCrud extends HTMLElement {
} }
setupEventListeners() { setupEventListeners() {
// Toggle delivery
const deliveryToggle = this.shadowRoot.getElementById("deliveryToggle");
deliveryToggle?.addEventListener("click", () => {
this.settings.delivery_enabled = !this.settings.delivery_enabled;
this.render();
});
// Toggle pickup // Toggle pickup
const pickupToggle = this.shadowRoot.getElementById("pickupToggle"); const pickupToggle = this.shadowRoot.getElementById("pickupToggle");
pickupToggle?.addEventListener("click", () => { pickupToggle?.addEventListener("click", () => {
@@ -673,23 +642,19 @@ class SettingsCrud extends HTMLElement {
} }
collectScheduleFromInputs() { collectScheduleFromInputs() {
const schedule = { delivery: {}, pickup: {} }; // Sólo pickup: el horario de delivery vive ahora dentro de cada zona.
const schedule = { pickup: {} };
for (const type of ["delivery", "pickup"]) { this.shadowRoot.querySelectorAll(`.hour-start[data-type="pickup"]`).forEach((input) => {
this.shadowRoot.querySelectorAll(`.hour-start[data-type="${type}"]`).forEach(input => {
const day = input.dataset.day; const day = input.dataset.day;
const endInput = this.shadowRoot.querySelector(`.hour-end[data-type="${type}"][data-day="${day}"]`); const endInput = this.shadowRoot.querySelector(`.hour-end[data-type="pickup"][data-day="${day}"]`);
const toggle = this.shadowRoot.querySelector(`.day-toggle[data-type="${type}"][data-day="${day}"]`); const toggle = this.shadowRoot.querySelector(`.day-toggle[data-type="pickup"][data-day="${day}"]`);
if (toggle?.classList.contains("active")) { if (toggle?.classList.contains("active")) {
schedule[type][day] = { schedule.pickup[day] = {
start: input.value.trim() || (type === "delivery" ? "09:00" : "08:00"), start: input.value.trim() || "08:00",
end: endInput?.value.trim() || (type === "delivery" ? "18:00" : "20:00"), end: endInput?.value.trim() || "20:00",
}; };
} }
}); });
}
return schedule; return schedule;
} }
@@ -727,9 +692,7 @@ class SettingsCrud extends HTMLElement {
bot_name: this.shadowRoot.getElementById("botName")?.value || "", bot_name: this.shadowRoot.getElementById("botName")?.value || "",
store_address: this.shadowRoot.getElementById("storeAddress")?.value || "", store_address: this.shadowRoot.getElementById("storeAddress")?.value || "",
store_phone: this.shadowRoot.getElementById("storePhone")?.value || "", store_phone: this.shadowRoot.getElementById("storePhone")?.value || "",
delivery_enabled: this.settings.delivery_enabled,
pickup_enabled: this.settings.pickup_enabled, pickup_enabled: this.settings.pickup_enabled,
delivery_min_order: parseFloat(this.shadowRoot.getElementById("deliveryMinOrder")?.value) || 0,
schedule, schedule,
delivery_zones, delivery_zones,
}; };

View File

@@ -37,46 +37,44 @@ const ZONE_PALETTE = [
"--chart-orange", "--chart-pink", "--chart-gray", "--chart-orange", "--chart-pink", "--chart-gray",
]; ];
let _libsPromise = null; let _scriptsPromise = null;
function ensureLeaflet() { function ensureLeafletScripts() {
if (window.L && window.L.PM) return Promise.resolve(); if (window.L && window.L.PM) return Promise.resolve();
if (_libsPromise) return _libsPromise; if (_scriptsPromise) return _scriptsPromise;
_libsPromise = (async () => { _scriptsPromise = (async () => {
if (!document.querySelector(`link[href="${LEAFLET_CSS}"]`)) { if (!window.L) await loadScript(LEAFLET_JS);
const l1 = document.createElement("link"); if (!window.L.PM) await loadScript(GEOMAN_JS);
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; return _scriptsPromise;
} }
function loadScript(src) { function loadScript(src) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) { const existing = document.querySelector(`script[src="${src}"]`);
resolve(); if (existing) {
if (existing.dataset.loaded === "1") { resolve(); return; }
existing.addEventListener("load", () => resolve());
existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)));
return; return;
} }
const s = document.createElement("script"); const s = document.createElement("script");
s.src = src; s.src = src;
s.async = true; s.async = true;
s.onload = () => resolve(); s.onload = () => { s.dataset.loaded = "1"; resolve(); };
s.onerror = () => reject(new Error(`Failed to load ${src}`)); s.onerror = () => reject(new Error(`Failed to load ${src}`));
document.head.appendChild(s); document.head.appendChild(s);
}); });
} }
function waitForCSS(linkEl, timeoutMs = 4000) {
if (linkEl.sheet) return Promise.resolve();
return new Promise((resolve) => {
const timer = setTimeout(resolve, timeoutMs);
linkEl.addEventListener("load", () => { clearTimeout(timer); resolve(); }, { once: true });
linkEl.addEventListener("error", () => { clearTimeout(timer); resolve(); }, { once: true });
});
}
function cssVar(name, fallback = "#0ea5e9") { function cssVar(name, fallback = "#0ea5e9") {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback; return v || fallback;
@@ -107,20 +105,48 @@ class ZoneMapEditor extends HTMLElement {
this.style.position = "relative"; this.style.position = "relative";
this.style.width = "100%"; this.style.width = "100%";
this.style.height = this.getAttribute("height") || "480px"; this.style.height = this.getAttribute("height") || "480px";
// El web component vive en light DOM dentro del shadow root del padre.
// Los <link> en document.head NO cruzan shadow boundaries, así que los
// anclamos como hijos del propio elemento (sí cruzan, porque slot
// composition trae nuestros children al árbol del padre con sus assets).
const linkLeaflet = document.createElement("link");
linkLeaflet.rel = "stylesheet";
linkLeaflet.href = LEAFLET_CSS;
this.appendChild(linkLeaflet);
const linkGeoman = document.createElement("link");
linkGeoman.rel = "stylesheet";
linkGeoman.href = GEOMAN_CSS;
this.appendChild(linkGeoman);
this._mapDiv = document.createElement("div"); this._mapDiv = document.createElement("div");
this._mapDiv.style.width = "100%"; this._mapDiv.style.width = "100%";
this._mapDiv.style.height = "100%"; this._mapDiv.style.height = "100%";
this._mapDiv.style.borderRadius = "var(--r-md, 10px)"; this._mapDiv.style.borderRadius = "var(--r-md, 10px)";
this._mapDiv.style.overflow = "hidden"; this._mapDiv.style.overflow = "hidden";
this._mapDiv.style.border = "1px solid var(--border, #e2e8f0)"; this._mapDiv.style.border = "1px solid var(--border, #e2e8f0)";
this._mapDiv.style.background = "#e2e8f0";
this.appendChild(this._mapDiv); this.appendChild(this._mapDiv);
ensureLeaflet().then(() => this._initMap()).catch((err) => { ensureLeafletScripts()
.then(() => waitForCSS(linkLeaflet))
.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>`; this._mapDiv.innerHTML = `<div style="padding:16px;color:var(--err);font-family:var(--font-sans);">No se pudo cargar el mapa: ${err.message}</div>`;
}); });
// Si el host estaba oculto al montar (router/visibility), Leaflet calcula
// 0×0 y los tiles no se piden. Observamos el resize para recalcular.
if (typeof ResizeObserver !== "undefined") {
this._ro = new ResizeObserver(() => {
if (this._map) this._map.invalidateSize();
});
this._ro.observe(this);
}
} }
disconnectedCallback() { disconnectedCallback() {
if (this._ro) { this._ro.disconnect(); this._ro = null; }
if (this._map) { if (this._map) {
this._map.remove(); this._map.remove();
this._map = null; this._map = null;
@@ -226,6 +252,10 @@ class ZoneMapEditor extends HTMLElement {
this._ready = true; this._ready = true;
this._renderLayers(); this._renderLayers();
// Forzar un invalidateSize después del primer paint por si el contenedor
// recién obtuvo tamaño (panel oculto inicial / tabs / etc).
requestAnimationFrame(() => this._map && this._map.invalidateSize());
setTimeout(() => this._map && this._map.invalidateSize(), 250);
} }
_renderLayers() { _renderLayers() {

View File

@@ -3,19 +3,20 @@ import { pool } from "../../shared/db/pool.js";
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Tenant Settings - CRUD // Tenant Settings - CRUD
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
//
// Modelo actual:
// - Datos del comercio: store_name, bot_name, store_address, store_phone.
// - Pickup (retiro en tienda): pickup_enabled + pickup_days CSV +
// pickup_hours_start/end TIME, y `schedule.pickup` JSONB para horario por día.
// - Delivery: TODO vive en `delivery_zones.zones[]` (polígonos GeoJSON con
// costo, días y rango horario por zona). El bot valida zona usando la
// ubicación que el cliente comparte por WhatsApp.
/**
* Obtiene la configuración del tenant
*/
export async function getSettings({ tenantId }) { export async function getSettings({ tenantId }) {
const sql = ` const sql = `
SELECT SELECT
id, tenant_id, id, tenant_id,
store_name, bot_name, store_address, store_phone, store_name, bot_name, store_address, store_phone,
delivery_enabled, delivery_days,
delivery_hours_start::text as delivery_hours_start,
delivery_hours_end::text as delivery_hours_end,
delivery_min_order,
pickup_enabled, pickup_days, pickup_enabled, pickup_days,
pickup_hours_start::text as pickup_hours_start, pickup_hours_start::text as pickup_hours_start,
pickup_hours_end::text as pickup_hours_end, pickup_hours_end::text as pickup_hours_end,
@@ -30,20 +31,12 @@ export async function getSettings({ tenantId }) {
return rows[0] || null; return rows[0] || null;
} }
/**
* Crea o actualiza la configuración del tenant (upsert)
*/
export async function upsertSettings({ tenantId, settings }) { export async function upsertSettings({ tenantId, settings }) {
const { const {
store_name, store_name,
bot_name, bot_name,
store_address, store_address,
store_phone, store_phone,
delivery_enabled,
delivery_days,
delivery_hours_start,
delivery_hours_end,
delivery_min_order,
pickup_enabled, pickup_enabled,
pickup_days, pickup_days,
pickup_hours_start, pickup_hours_start,
@@ -55,21 +48,15 @@ export async function upsertSettings({ tenantId, settings }) {
const sql = ` const sql = `
INSERT INTO tenant_settings ( INSERT INTO tenant_settings (
tenant_id, store_name, bot_name, store_address, store_phone, tenant_id, store_name, bot_name, store_address, store_phone,
delivery_enabled, delivery_days, delivery_hours_start, delivery_hours_end, delivery_min_order,
pickup_enabled, pickup_days, pickup_hours_start, pickup_hours_end, pickup_enabled, pickup_days, pickup_hours_start, pickup_hours_end,
schedule, delivery_zones schedule, delivery_zones
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (tenant_id) DO UPDATE SET ON CONFLICT (tenant_id) DO UPDATE SET
store_name = COALESCE(EXCLUDED.store_name, tenant_settings.store_name), store_name = COALESCE(EXCLUDED.store_name, tenant_settings.store_name),
bot_name = COALESCE(EXCLUDED.bot_name, tenant_settings.bot_name), bot_name = COALESCE(EXCLUDED.bot_name, tenant_settings.bot_name),
store_address = COALESCE(EXCLUDED.store_address, tenant_settings.store_address), store_address = COALESCE(EXCLUDED.store_address, tenant_settings.store_address),
store_phone = COALESCE(EXCLUDED.store_phone, tenant_settings.store_phone), store_phone = COALESCE(EXCLUDED.store_phone, tenant_settings.store_phone),
delivery_enabled = COALESCE(EXCLUDED.delivery_enabled, tenant_settings.delivery_enabled),
delivery_days = COALESCE(EXCLUDED.delivery_days, tenant_settings.delivery_days),
delivery_hours_start = COALESCE(EXCLUDED.delivery_hours_start, tenant_settings.delivery_hours_start),
delivery_hours_end = COALESCE(EXCLUDED.delivery_hours_end, tenant_settings.delivery_hours_end),
delivery_min_order = COALESCE(EXCLUDED.delivery_min_order, tenant_settings.delivery_min_order),
pickup_enabled = COALESCE(EXCLUDED.pickup_enabled, tenant_settings.pickup_enabled), pickup_enabled = COALESCE(EXCLUDED.pickup_enabled, tenant_settings.pickup_enabled),
pickup_days = COALESCE(EXCLUDED.pickup_days, tenant_settings.pickup_days), pickup_days = COALESCE(EXCLUDED.pickup_days, tenant_settings.pickup_days),
pickup_hours_start = COALESCE(EXCLUDED.pickup_hours_start, tenant_settings.pickup_hours_start), pickup_hours_start = COALESCE(EXCLUDED.pickup_hours_start, tenant_settings.pickup_hours_start),
@@ -80,10 +67,6 @@ export async function upsertSettings({ tenantId, settings }) {
RETURNING RETURNING
id, tenant_id, id, tenant_id,
store_name, bot_name, store_address, store_phone, store_name, bot_name, store_address, store_phone,
delivery_enabled, delivery_days,
delivery_hours_start::text as delivery_hours_start,
delivery_hours_end::text as delivery_hours_end,
delivery_min_order,
pickup_enabled, pickup_days, pickup_enabled, pickup_days,
pickup_hours_start::text as pickup_hours_start, pickup_hours_start::text as pickup_hours_start,
pickup_hours_end::text as pickup_hours_end, pickup_hours_end::text as pickup_hours_end,
@@ -98,11 +81,6 @@ export async function upsertSettings({ tenantId, settings }) {
bot_name || null, bot_name || null,
store_address || null, store_address || null,
store_phone || null, store_phone || null,
delivery_enabled ?? null,
delivery_days || null,
delivery_hours_start || null,
delivery_hours_end || null,
delivery_min_order ?? null,
pickup_enabled ?? null, pickup_enabled ?? null,
pickup_days || null, pickup_days || null,
pickup_hours_start || null, pickup_hours_start || null,
@@ -112,148 +90,101 @@ export async function upsertSettings({ tenantId, settings }) {
]; ];
const { rows } = await pool.query(sql, params); const { rows } = await pool.query(sql, params);
return rows[0]; return rows[0];
} }
/** /**
* Formatea horarios desde schedule JSONB para mostrar de forma natural * Formatea schedule.pickup ({ lun: { start, end } }) en prosa para mostrar al
* Agrupa días con mismos horarios: "Lun a Vie de 9 a 14hs, Sáb de 9 a 13hs" * cliente. Agrupa días con mismos horarios: "Lun a Vie de 9 a 14hs".
*/ */
function formatScheduleHours(scheduleType, enabled) { function formatScheduleHours(scheduleObj, enabled) {
if (!enabled || !scheduleType || typeof scheduleType !== "object") { if (!enabled || !scheduleObj || typeof scheduleObj !== "object") {
return enabled === false ? "No disponible" : ""; return enabled === false ? "No disponible" : "";
} }
const dayOrder = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"]; const dayOrder = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
const dayNames = { const dayNames = {
lun: "Lunes", mar: "Martes", mie: "Miércoles", lun: "Lunes", mar: "Martes", mie: "Miércoles",
jue: "Jueves", vie: "Viernes", sab: "Sábado", dom: "Domingo" jue: "Jueves", vie: "Viernes", sab: "Sábado", dom: "Domingo",
}; };
// Agrupar días por horario
const groups = {}; const groups = {};
for (const day of dayOrder) { for (const day of dayOrder) {
const slot = scheduleType[day]; const slot = scheduleObj[day];
if (!slot || !slot.start || !slot.end) continue; if (!slot || !slot.start || !slot.end) continue;
const key = `${slot.start}-${slot.end}`; const key = `${slot.start}-${slot.end}`;
if (!groups[key]) { if (!groups[key]) groups[key] = { start: slot.start, end: slot.end, days: [] };
groups[key] = { start: slot.start, end: slot.end, days: [] };
}
groups[key].days.push(day); groups[key].days.push(day);
} }
if (Object.keys(groups).length === 0) return ""; if (!Object.keys(groups).length) return "";
// Formatear cada grupo const parts = Object.values(groups).map((g) => {
const parts = Object.values(groups).map(g => {
const days = g.days; const days = g.days;
let dayStr; let dayStr;
// Detectar rangos consecutivos
if (days.length >= 3) { if (days.length >= 3) {
const indices = days.map(d => dayOrder.indexOf(d)); const indices = days.map((d) => dayOrder.indexOf(d));
const isConsecutive = indices.every((v, i, arr) => i === 0 || v === arr[i-1] + 1); const isConsecutive = indices.every((v, i, arr) => i === 0 || v === arr[i - 1] + 1);
if (isConsecutive) { dayStr = isConsecutive
dayStr = `${dayNames[days[0]]} a ${dayNames[days[days.length-1]]}`; ? `${dayNames[days[0]]} a ${dayNames[days[days.length - 1]]}`
} else { : days.map((d) => dayNames[d]).join(", ");
dayStr = days.map(d => dayNames[d]).join(", ");
}
} else if (days.length === 2) { } else if (days.length === 2) {
dayStr = `${dayNames[days[0]]} y ${dayNames[days[1]]}`; dayStr = `${dayNames[days[0]]} y ${dayNames[days[1]]}`;
} else { } else {
dayStr = dayNames[days[0]]; dayStr = dayNames[days[0]];
} }
return `${dayStr} de ${g.start.slice(0, 5)} a ${g.end.slice(0, 5)}`;
const startH = g.start.slice(0, 5);
const endH = g.end.slice(0, 5);
return `${dayStr} de ${startH} a ${endH}`;
}); });
return parts.join(", "); return parts.join(", ");
} }
function formatLegacyPickupHours(enabled, days, start, end) {
if (!enabled) return "No disponible";
if (!days || !start || !end) return "";
const daysFormatted = days.split(",").map((d) => d.trim()).join(", ");
return `${daysFormatted} de ${start.slice(0, 5)} a ${end.slice(0, 5)}`;
}
/** /**
* Obtiene la configuración formateada para usar en prompts (storeConfig) * Forma de la storeConfig que consume el agente y su workingMemory.
*/ */
export async function getStoreConfig({ tenantId }) { export async function getStoreConfig({ tenantId }) {
const settings = await getSettings({ tenantId }); const settings = await getSettings({ tenantId });
if (!settings) { if (!settings) {
// Valores por defecto si no hay configuración
return { return {
name: "la carnicería", name: "la carnicería",
botName: "Piaf", botName: "Piaf",
hours: "", hours: "",
address: "", address: "",
phone: "", phone: "",
deliveryHours: "",
pickupHours: "", pickupHours: "",
schedule: null, schedule: null,
delivery_zones: {},
}; };
} }
const schedule = settings.schedule || {}; const schedule = settings.schedule || {};
// Usar nuevo formato schedule si existe, sino legacy const pickupHours = schedule.pickup && Object.keys(schedule.pickup).length
let deliveryHours, pickupHours; ? formatScheduleHours(schedule.pickup, settings.pickup_enabled)
: formatLegacyPickupHours(
if (schedule.delivery && Object.keys(schedule.delivery).length > 0) {
deliveryHours = formatScheduleHours(schedule.delivery, settings.delivery_enabled);
} else {
// Legacy format
deliveryHours = formatLegacyHours(
settings.delivery_enabled,
settings.delivery_days,
settings.delivery_hours_start,
settings.delivery_hours_end
);
}
if (schedule.pickup && Object.keys(schedule.pickup).length > 0) {
pickupHours = formatScheduleHours(schedule.pickup, settings.pickup_enabled);
} else {
// Legacy format
pickupHours = formatLegacyHours(
settings.pickup_enabled, settings.pickup_enabled,
settings.pickup_days, settings.pickup_days,
settings.pickup_hours_start, settings.pickup_hours_start,
settings.pickup_hours_end settings.pickup_hours_end,
); );
}
// Combinar horarios para store_hours (usa pickup como horario de tienda)
let storeHours = "";
if (settings.pickup_enabled) {
storeHours = pickupHours;
}
return { return {
name: settings.store_name || "la carnicería", name: settings.store_name || "la carnicería",
botName: settings.bot_name || "Piaf", botName: settings.bot_name || "Piaf",
hours: storeHours, hours: settings.pickup_enabled ? pickupHours : "",
address: settings.store_address || "", address: settings.store_address || "",
phone: settings.store_phone || "", phone: settings.store_phone || "",
deliveryHours,
pickupHours, pickupHours,
deliveryEnabled: settings.delivery_enabled,
pickupEnabled: settings.pickup_enabled, pickupEnabled: settings.pickup_enabled,
schedule, schedule,
delivery_zones: settings.delivery_zones || {}, delivery_zones: settings.delivery_zones || {},
}; };
} }
/**
* Formatear horarios en formato legacy (días + rango único)
*/
function formatLegacyHours(enabled, days, start, end) {
if (!enabled) return "No disponible";
if (!days || !start || !end) return "";
const daysFormatted = days.split(",").map(d => d.trim()).join(", ");
const startFormatted = start?.slice(0, 5) || "";
const endFormatted = end?.slice(0, 5) || "";
return `${daysFormatted} de ${startFormatted} a ${endFormatted}`;
}

View File

@@ -1,63 +1,42 @@
import { getSettings, upsertSettings, getStoreConfig } from "../db/settingsRepo.js"; import { getSettings, upsertSettings, getStoreConfig } from "../db/settingsRepo.js";
// Días de la semana para validación
const VALID_DAYS = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"]; const VALID_DAYS = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
/** function defaultPickupSchedule() {
* Genera schedule por defecto con horarios uniformes const days = ["lun", "mar", "mie", "jue", "vie", "sab"];
*/
function createDefaultSchedule() {
const defaultDays = ["lun", "mar", "mie", "jue", "vie", "sab"];
const delivery = {};
const pickup = {}; const pickup = {};
for (const d of days) pickup[d] = { start: "08:00", end: "20:00" };
for (const day of defaultDays) { return { pickup };
delivery[day] = { start: "09:00", end: "18:00" };
pickup[day] = { start: "08:00", end: "20:00" };
}
return { delivery, pickup };
} }
/**
* Obtiene la configuración actual del tenant
*/
export async function handleGetSettings({ tenantId }) { export async function handleGetSettings({ tenantId }) {
const settings = await getSettings({ tenantId }); const settings = await getSettings({ tenantId });
// Si no hay configuración, devolver defaults
if (!settings) { if (!settings) {
return { return {
store_name: "Mi Negocio", store_name: "Mi Negocio",
bot_name: "Piaf", bot_name: "Piaf",
store_address: "", store_address: "",
store_phone: "", store_phone: "",
delivery_enabled: true,
delivery_days: "lun,mar,mie,jue,vie,sab",
delivery_hours_start: "09:00",
delivery_hours_end: "18:00",
delivery_min_order: 0,
pickup_enabled: true, pickup_enabled: true,
pickup_days: "lun,mar,mie,jue,vie,sab", pickup_days: "lun,mar,mie,jue,vie,sab",
pickup_hours_start: "08:00", pickup_hours_start: "08:00",
pickup_hours_end: "20:00", pickup_hours_end: "20:00",
schedule: createDefaultSchedule(), schedule: defaultPickupSchedule(),
delivery_zones: {}, delivery_zones: {},
is_default: true, is_default: true,
}; };
} }
// Si no tiene schedule, generar desde datos legacy // Si schedule está vacío pero tenemos los campos legacy de pickup, generar
// schedule.pickup para que la UI pueda editar el grid por día.
let schedule = settings.schedule; let schedule = settings.schedule;
if (!schedule || Object.keys(schedule).length === 0) { if (!schedule || !schedule.pickup || !Object.keys(schedule.pickup).length) {
schedule = buildScheduleFromLegacy(settings); schedule = buildPickupScheduleFromLegacy(settings);
} }
return { return {
...settings, ...settings,
// Formatear horarios TIME a HH:MM
delivery_hours_start: settings.delivery_hours_start?.slice(0, 5) || "09:00",
delivery_hours_end: settings.delivery_hours_end?.slice(0, 5) || "18:00",
pickup_hours_start: settings.pickup_hours_start?.slice(0, 5) || "08:00", pickup_hours_start: settings.pickup_hours_start?.slice(0, 5) || "08:00",
pickup_hours_end: settings.pickup_hours_end?.slice(0, 5) || "20:00", pickup_hours_end: settings.pickup_hours_end?.slice(0, 5) || "20:00",
schedule, schedule,
@@ -66,172 +45,96 @@ export async function handleGetSettings({ tenantId }) {
}; };
} }
/** function buildPickupScheduleFromLegacy(settings) {
* Construye schedule desde datos legacy const out = { pickup: {} };
*/ if (settings?.pickup_enabled && settings?.pickup_days) {
function buildScheduleFromLegacy(settings) { const days = settings.pickup_days.split(",").map((d) => d.trim());
const schedule = { delivery: {}, pickup: {} };
// Delivery
if (settings.delivery_enabled && settings.delivery_days) {
const days = settings.delivery_days.split(",").map(d => d.trim());
const start = settings.delivery_hours_start?.slice(0, 5) || "09:00";
const end = settings.delivery_hours_end?.slice(0, 5) || "18:00";
for (const day of days) {
if (VALID_DAYS.includes(day)) {
schedule.delivery[day] = { start, end };
}
}
}
// Pickup
if (settings.pickup_enabled && settings.pickup_days) {
const days = settings.pickup_days.split(",").map(d => d.trim());
const start = settings.pickup_hours_start?.slice(0, 5) || "08:00"; const start = settings.pickup_hours_start?.slice(0, 5) || "08:00";
const end = settings.pickup_hours_end?.slice(0, 5) || "20:00"; const end = settings.pickup_hours_end?.slice(0, 5) || "20:00";
for (const day of days) { for (const day of days) {
if (VALID_DAYS.includes(day)) { if (VALID_DAYS.includes(day)) out.pickup[day] = { start, end };
schedule.pickup[day] = { start, end };
} }
} }
} return out;
return schedule;
} }
/**
* Valida la estructura del schedule
*/
function validateSchedule(schedule) { function validateSchedule(schedule) {
if (!schedule || typeof schedule !== "object") return; if (!schedule || typeof schedule !== "object") return;
// Acepta HH:MM o HH:MM:SS (la BD puede devolver con segundos)
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/; const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/;
const pickup = schedule.pickup;
for (const type of ["delivery", "pickup"]) { if (!pickup || typeof pickup !== "object") return;
const typeSchedule = schedule[type]; for (const [day, slot] of Object.entries(pickup)) {
if (!typeSchedule || typeof typeSchedule !== "object") continue; if (!VALID_DAYS.includes(day)) throw new Error(`Invalid day in schedule.pickup: ${day}`);
if (slot === null) continue;
for (const [day, slot] of Object.entries(typeSchedule)) {
if (!VALID_DAYS.includes(day)) {
throw new Error(`Invalid day in schedule.${type}: ${day}`);
}
if (slot === null) continue; // null = no disponible
if (typeof slot !== "object" || !slot.start || !slot.end) { if (typeof slot !== "object" || !slot.start || !slot.end) {
throw new Error(`Invalid slot format for ${type}.${day}`); throw new Error(`Invalid slot format for pickup.${day}`);
}
if (!timeRegex.test(slot.start)) {
throw new Error(`Invalid start time for ${type}.${day}: ${slot.start}`);
}
if (!timeRegex.test(slot.end)) {
throw new Error(`Invalid end time for ${type}.${day}: ${slot.end}`);
}
} }
if (!timeRegex.test(slot.start)) throw new Error(`Invalid start time for pickup.${day}: ${slot.start}`);
if (!timeRegex.test(slot.end)) throw new Error(`Invalid end time for pickup.${day}: ${slot.end}`);
} }
} }
/** function syncPickupLegacyFromSchedule(settings) {
* Sincroniza campos legacy desde schedule const pickup = settings?.schedule?.pickup;
*/ if (!pickup) return;
function syncLegacyFromSchedule(settings) { const days = Object.keys(pickup).filter((d) => pickup[d] && pickup[d].start && pickup[d].end);
const schedule = settings.schedule; days.sort((a, b) => VALID_DAYS.indexOf(a) - VALID_DAYS.indexOf(b));
if (!schedule) return; if (days.length) {
settings.pickup_days = days.join(",");
// Sincronizar delivery const first = pickup[days[0]];
if (schedule.delivery) { settings.pickup_hours_start = first.start;
const deliveryDays = Object.keys(schedule.delivery).filter(d => schedule.delivery[d] !== null); settings.pickup_hours_end = first.end;
if (deliveryDays.length > 0) {
// Ordenar días
deliveryDays.sort((a, b) => VALID_DAYS.indexOf(a) - VALID_DAYS.indexOf(b));
settings.delivery_days = deliveryDays.join(",");
// Usar primer horario como legacy
const firstSlot = schedule.delivery[deliveryDays[0]];
if (firstSlot) {
settings.delivery_hours_start = firstSlot.start;
settings.delivery_hours_end = firstSlot.end;
}
} else {
settings.delivery_days = "";
}
}
// Sincronizar pickup
if (schedule.pickup) {
const pickupDays = Object.keys(schedule.pickup).filter(d => schedule.pickup[d] !== null);
if (pickupDays.length > 0) {
pickupDays.sort((a, b) => VALID_DAYS.indexOf(a) - VALID_DAYS.indexOf(b));
settings.pickup_days = pickupDays.join(",");
const firstSlot = schedule.pickup[pickupDays[0]];
if (firstSlot) {
settings.pickup_hours_start = firstSlot.start;
settings.pickup_hours_end = firstSlot.end;
}
} else { } else {
settings.pickup_days = ""; settings.pickup_days = "";
} }
}
function validateDeliveryZones(dz) {
if (!dz || typeof dz !== "object") return;
if (dz.zones && !Array.isArray(dz.zones)) {
throw new Error("delivery_zones.zones must be an array");
}
for (const z of dz.zones || []) {
if (!z?.id || !z?.name) throw new Error("Each zone needs id + name");
if (z.polygon && (z.polygon.type !== "Polygon" || !Array.isArray(z.polygon.coordinates))) {
throw new Error(`Invalid polygon GeoJSON for zone ${z.id}`);
}
if (Array.isArray(z.delivery_days)) {
for (const d of z.delivery_days) {
if (!VALID_DAYS.includes(d)) throw new Error(`Invalid delivery day in zone ${z.id}: ${d}`);
}
}
} }
} }
/**
* Guarda la configuración del tenant
*/
export async function handleSaveSettings({ tenantId, settings }) { export async function handleSaveSettings({ tenantId, settings }) {
// Validaciones básicas if (!settings.store_name?.trim()) throw new Error("store_name is required");
if (!settings.store_name?.trim()) { if (!settings.bot_name?.trim()) throw new Error("bot_name is required");
throw new Error("store_name is required");
}
if (!settings.bot_name?.trim()) {
throw new Error("bot_name is required");
}
// Validar schedule si viene
if (settings.schedule) { if (settings.schedule) {
validateSchedule(settings.schedule); validateSchedule(settings.schedule);
// Sincronizar campos legacy desde schedule syncPickupLegacyFromSchedule(settings);
syncLegacyFromSchedule(settings);
} else {
// Legacy: validar días individuales
if (settings.delivery_days) {
const days = settings.delivery_days.split(",").map(d => d.trim().toLowerCase());
for (const day of days) {
if (!VALID_DAYS.includes(day)) {
throw new Error(`Invalid delivery day: ${day}`);
} }
}
settings.delivery_days = days.join(","); if (settings.delivery_zones) {
validateDeliveryZones(settings.delivery_zones);
} }
if (settings.pickup_days) { if (settings.pickup_days) {
const days = settings.pickup_days.split(",").map(d => d.trim().toLowerCase()); const days = settings.pickup_days.split(",").map((d) => d.trim().toLowerCase());
for (const day of days) { for (const d of days) {
if (!VALID_DAYS.includes(day)) { if (!VALID_DAYS.includes(d)) throw new Error(`Invalid pickup day: ${d}`);
throw new Error(`Invalid pickup day: ${day}`);
}
} }
settings.pickup_days = days.join(","); settings.pickup_days = days.join(",");
} }
// Validar horarios legacy
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/; const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
if (settings.delivery_hours_start && !timeRegex.test(settings.delivery_hours_start)) {
throw new Error("Invalid delivery_hours_start format (use HH:MM)");
}
if (settings.delivery_hours_end && !timeRegex.test(settings.delivery_hours_end)) {
throw new Error("Invalid delivery_hours_end format (use HH:MM)");
}
if (settings.pickup_hours_start && !timeRegex.test(settings.pickup_hours_start)) { if (settings.pickup_hours_start && !timeRegex.test(settings.pickup_hours_start)) {
throw new Error("Invalid pickup_hours_start format (use HH:MM)"); throw new Error("Invalid pickup_hours_start format (use HH:MM)");
} }
if (settings.pickup_hours_end && !timeRegex.test(settings.pickup_hours_end)) { if (settings.pickup_hours_end && !timeRegex.test(settings.pickup_hours_end)) {
throw new Error("Invalid pickup_hours_end format (use HH:MM)"); throw new Error("Invalid pickup_hours_end format (use HH:MM)");
} }
}
const result = await upsertSettings({ tenantId, settings }); const result = await upsertSettings({ tenantId, settings });
@@ -239,8 +142,6 @@ export async function handleSaveSettings({ tenantId, settings }) {
ok: true, ok: true,
settings: { settings: {
...result, ...result,
delivery_hours_start: result.delivery_hours_start?.slice(0, 5),
delivery_hours_end: result.delivery_hours_end?.slice(0, 5),
pickup_hours_start: result.pickup_hours_start?.slice(0, 5), pickup_hours_start: result.pickup_hours_start?.slice(0, 5),
pickup_hours_end: result.pickup_hours_end?.slice(0, 5), pickup_hours_end: result.pickup_hours_end?.slice(0, 5),
delivery_zones: result.delivery_zones || {}, delivery_zones: result.delivery_zones || {},
@@ -249,9 +150,6 @@ export async function handleSaveSettings({ tenantId, settings }) {
}; };
} }
/**
* Obtiene el storeConfig formateado para prompts
*/
export async function handleGetStoreConfig({ tenantId }) { export async function handleGetStoreConfig({ tenantId }) {
return await getStoreConfig({ tenantId }); return await getStoreConfig({ tenantId });
} }