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

@@ -3,19 +3,20 @@ import { pool } from "../../shared/db/pool.js";
// ─────────────────────────────────────────────────────────────
// 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 }) {
const sql = `
SELECT
SELECT
id, tenant_id,
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_hours_start::text as pickup_hours_start,
pickup_hours_end::text as pickup_hours_end,
@@ -30,20 +31,12 @@ export async function getSettings({ tenantId }) {
return rows[0] || null;
}
/**
* Crea o actualiza la configuración del tenant (upsert)
*/
export async function upsertSettings({ tenantId, settings }) {
const {
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,
@@ -55,21 +48,15 @@ export async function upsertSettings({ tenantId, settings }) {
const sql = `
INSERT INTO tenant_settings (
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,
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
store_name = COALESCE(EXCLUDED.store_name, tenant_settings.store_name),
bot_name = COALESCE(EXCLUDED.bot_name, tenant_settings.bot_name),
store_address = COALESCE(EXCLUDED.store_address, tenant_settings.store_address),
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_days = COALESCE(EXCLUDED.pickup_days, tenant_settings.pickup_days),
pickup_hours_start = COALESCE(EXCLUDED.pickup_hours_start, tenant_settings.pickup_hours_start),
@@ -77,13 +64,9 @@ export async function upsertSettings({ tenantId, settings }) {
schedule = COALESCE(EXCLUDED.schedule, tenant_settings.schedule),
delivery_zones = COALESCE(EXCLUDED.delivery_zones, tenant_settings.delivery_zones),
updated_at = NOW()
RETURNING
RETURNING
id, tenant_id,
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_hours_start::text as pickup_hours_start,
pickup_hours_end::text as pickup_hours_end,
@@ -98,11 +81,6 @@ export async function upsertSettings({ tenantId, settings }) {
bot_name || null,
store_address || 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_days || null,
pickup_hours_start || null,
@@ -110,150 +88,103 @@ export async function upsertSettings({ tenantId, settings }) {
schedule ? JSON.stringify(schedule) : null,
delivery_zones ? JSON.stringify(delivery_zones) : null,
];
const { rows } = await pool.query(sql, params);
const { rows } = await pool.query(sql, params);
return rows[0];
}
/**
* Formatea horarios desde schedule JSONB para mostrar de forma natural
* Agrupa días con mismos horarios: "Lun a Vie de 9 a 14hs, Sáb de 9 a 13hs"
* Formatea schedule.pickup ({ lun: { start, end } }) en prosa para mostrar al
* cliente. Agrupa días con mismos horarios: "Lun a Vie de 9 a 14hs".
*/
function formatScheduleHours(scheduleType, enabled) {
if (!enabled || !scheduleType || typeof scheduleType !== "object") {
function formatScheduleHours(scheduleObj, enabled) {
if (!enabled || !scheduleObj || typeof scheduleObj !== "object") {
return enabled === false ? "No disponible" : "";
}
const dayOrder = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
const dayNames = {
lun: "Lunes", mar: "Martes", mie: "Miércoles",
jue: "Jueves", vie: "Viernes", sab: "Sábado", dom: "Domingo"
lun: "Lunes", mar: "Martes", mie: "Miércoles",
jue: "Jueves", vie: "Viernes", sab: "Sábado", dom: "Domingo",
};
// Agrupar días por horario
const groups = {};
for (const day of dayOrder) {
const slot = scheduleType[day];
const slot = scheduleObj[day];
if (!slot || !slot.start || !slot.end) continue;
const key = `${slot.start}-${slot.end}`;
if (!groups[key]) {
groups[key] = { start: slot.start, end: slot.end, days: [] };
}
if (!groups[key]) groups[key] = { start: slot.start, end: slot.end, days: [] };
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;
let dayStr;
// Detectar rangos consecutivos
if (days.length >= 3) {
const indices = days.map(d => dayOrder.indexOf(d));
const isConsecutive = indices.every((v, i, arr) => i === 0 || v === arr[i-1] + 1);
if (isConsecutive) {
dayStr = `${dayNames[days[0]]} a ${dayNames[days[days.length-1]]}`;
} else {
dayStr = days.map(d => dayNames[d]).join(", ");
}
const indices = days.map((d) => dayOrder.indexOf(d));
const isConsecutive = indices.every((v, i, arr) => i === 0 || v === arr[i - 1] + 1);
dayStr = isConsecutive
? `${dayNames[days[0]]} a ${dayNames[days[days.length - 1]]}`
: days.map((d) => dayNames[d]).join(", ");
} else if (days.length === 2) {
dayStr = `${dayNames[days[0]]} y ${dayNames[days[1]]}`;
} else {
dayStr = dayNames[days[0]];
}
const startH = g.start.slice(0, 5);
const endH = g.end.slice(0, 5);
return `${dayStr} de ${startH} a ${endH}`;
return `${dayStr} de ${g.start.slice(0, 5)} a ${g.end.slice(0, 5)}`;
});
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 }) {
const settings = await getSettings({ tenantId });
if (!settings) {
// Valores por defecto si no hay configuración
return {
name: "la carnicería",
botName: "Piaf",
hours: "",
address: "",
phone: "",
deliveryHours: "",
pickupHours: "",
schedule: null,
delivery_zones: {},
};
}
const schedule = settings.schedule || {};
// Usar nuevo formato schedule si existe, sino legacy
let deliveryHours, pickupHours;
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_days,
settings.pickup_hours_start,
settings.pickup_hours_end
);
}
// Combinar horarios para store_hours (usa pickup como horario de tienda)
let storeHours = "";
if (settings.pickup_enabled) {
storeHours = pickupHours;
}
const pickupHours = schedule.pickup && Object.keys(schedule.pickup).length
? formatScheduleHours(schedule.pickup, settings.pickup_enabled)
: formatLegacyPickupHours(
settings.pickup_enabled,
settings.pickup_days,
settings.pickup_hours_start,
settings.pickup_hours_end,
);
return {
name: settings.store_name || "la carnicería",
botName: settings.bot_name || "Piaf",
hours: storeHours,
hours: settings.pickup_enabled ? pickupHours : "",
address: settings.store_address || "",
phone: settings.store_phone || "",
deliveryHours,
pickupHours,
deliveryEnabled: settings.delivery_enabled,
pickupEnabled: settings.pickup_enabled,
schedule,
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}`;
}