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:
@@ -240,10 +240,7 @@ export async function getStoreConfig({ tenantId }) {
|
||||
deliveryEnabled: settings.delivery_enabled,
|
||||
pickupEnabled: settings.pickup_enabled,
|
||||
schedule,
|
||||
// Campos legacy para compatibilidad
|
||||
delivery_days: settings.delivery_days,
|
||||
delivery_hours_start: settings.delivery_hours_start,
|
||||
delivery_hours_end: settings.delivery_hours_end,
|
||||
delivery_zones: settings.delivery_zones || {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export async function handleEvolutionWebhook(body) {
|
||||
from: parsed.chat_id.replace("@s.whatsapp.net", ""),
|
||||
displayName: parsed.from_name || null,
|
||||
text: parsed.text,
|
||||
inboundLocation: parsed.location || null,
|
||||
provider: "evolution",
|
||||
message_id: parsed.message_id || crypto.randomUUID(),
|
||||
meta: { pushName: parsed.from_name, ts: parsed.ts, source: parsed.source },
|
||||
|
||||
@@ -3,11 +3,18 @@ import { processMessage } from "../../2-identity/services/pipeline.js";
|
||||
import { getTenantId } from "../../shared/tenant.js";
|
||||
|
||||
export async function handleSimSend(body) {
|
||||
const { chat_id, from_phone, text } = body || {};
|
||||
if (!chat_id || !from_phone || !text) {
|
||||
return { status: 400, payload: { ok: false, error: "chat_id, from_phone, text are required" } };
|
||||
const { chat_id, from_phone, text, location } = body || {};
|
||||
if (!chat_id || !from_phone || (!text && !location)) {
|
||||
return { status: 400, payload: { ok: false, error: "chat_id, from_phone, and text or location are required" } };
|
||||
}
|
||||
|
||||
// Aceptar location share desde el simulator. Mismo formato que el parser de
|
||||
// Evolution: { lat, lng, label? }.
|
||||
const inboundLocation =
|
||||
location && typeof location.lat === "number" && typeof location.lng === "number"
|
||||
? { lat: location.lat, lng: location.lng, label: location.label || null }
|
||||
: null;
|
||||
|
||||
const provider = "sim";
|
||||
const message_id = crypto.randomUUID();
|
||||
const tenantId = getTenantId();
|
||||
@@ -16,7 +23,8 @@ export async function handleSimSend(body) {
|
||||
tenantId,
|
||||
chat_id,
|
||||
from: from_phone,
|
||||
text,
|
||||
text: text || "",
|
||||
inboundLocation,
|
||||
provider,
|
||||
message_id,
|
||||
});
|
||||
|
||||
@@ -34,8 +34,19 @@ export function parseEvolutionWebhook(reqBody) {
|
||||
(typeof msg.extendedTextMessage?.text === "string" && msg.extendedTextMessage.text) ||
|
||||
"";
|
||||
|
||||
// extract location share (WhatsApp pin). Evolution wraps Baileys formats:
|
||||
// - locationMessage: { degreesLatitude, degreesLongitude, name?, address? }
|
||||
// - liveLocationMessage: { degreesLatitude, degreesLongitude }
|
||||
const loc = msg.locationMessage || msg.liveLocationMessage || null;
|
||||
const lat = loc?.degreesLatitude;
|
||||
const lng = loc?.degreesLongitude;
|
||||
const location =
|
||||
typeof lat === "number" && typeof lng === "number"
|
||||
? { lat, lng, label: loc?.name || loc?.address || null }
|
||||
: null;
|
||||
|
||||
const cleanText = String(text).trim();
|
||||
if (!cleanText) return { ok: false, reason: "empty_text" };
|
||||
if (!cleanText && !location) return { ok: false, reason: "empty_message" };
|
||||
|
||||
// metadata
|
||||
const pushName = data.pushName || null;
|
||||
@@ -48,6 +59,7 @@ export function parseEvolutionWebhook(reqBody) {
|
||||
chat_id: remoteJid,
|
||||
message_id: messageId || null,
|
||||
text: cleanText,
|
||||
location,
|
||||
from_name: pushName,
|
||||
message_type: messageType || null,
|
||||
ts,
|
||||
|
||||
@@ -121,6 +121,7 @@ export async function processMessage({
|
||||
message_id,
|
||||
displayName = null,
|
||||
meta = null,
|
||||
inboundLocation = null,
|
||||
}) {
|
||||
const { started_at, mark, msBetween } = makePerf();
|
||||
const prev = await touchConversationState({ tenant_id: tenantId, wa_chat_id: chat_id });
|
||||
@@ -190,6 +191,22 @@ let externalCustomerId = await getExternalCustomerIdByChat({
|
||||
_reset_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Si llegó una ubicación compartida (WhatsApp pin), guardarla en pending
|
||||
// para que el agente la lea via working_memory y matchee zona en set_address.
|
||||
if (inboundLocation && typeof inboundLocation.lat === "number" && typeof inboundLocation.lng === "number") {
|
||||
const baseOrder = reducedContext.order && typeof reducedContext.order === "object" ? reducedContext.order : {};
|
||||
const merged = { ...baseOrder };
|
||||
if (!Array.isArray(merged.cart)) merged.cart = [];
|
||||
if (!Array.isArray(merged.pending)) merged.pending = [];
|
||||
merged.pending_location = {
|
||||
lat: inboundLocation.lat,
|
||||
lng: inboundLocation.lng,
|
||||
label: inboundLocation.label || null,
|
||||
received_at: new Date().toISOString(),
|
||||
};
|
||||
reducedContext.order = merged;
|
||||
}
|
||||
let decision;
|
||||
let plan;
|
||||
let llmMeta;
|
||||
|
||||
@@ -321,6 +321,8 @@ function toBasketItem(item) {
|
||||
function buildContextPatch(ctx) {
|
||||
const order = ctx.order || createEmptyOrder();
|
||||
return {
|
||||
// Persist the full order object so pending_location/matched_zone/delivery_window
|
||||
// sobrevivan turno a turno (migrateOldContext devuelve ctx.order tal cual).
|
||||
order,
|
||||
order_basket: { items: (order.cart || []).map(toBasketItem) },
|
||||
pending_items: (order.pending || []).map((p) => ({
|
||||
|
||||
@@ -30,7 +30,8 @@ REGLAS DURAS:
|
||||
llamá escalate_to_human.
|
||||
|
||||
CÓMO PROCESAS UN MENSAJE (user message viene como JSON con working_memory):
|
||||
- Releé order.cart, order.pending, last_shown_options, fsm_state, paused_until.
|
||||
- Releé order.cart, order.pending, last_shown_options, fsm_state, paused_until,
|
||||
order.pending_location, order.matched_zone, order.delivery_window.
|
||||
- preparsed: tiene cantidades parseadas (ej: "media docena" → 6 unit; "1/4 kg"
|
||||
→ 0.25 kg). Confiá en eso si su confidence ≥ 0.85.
|
||||
- Si user dice "el segundo", "ese", "el primero", "el de arriba", resolvé
|
||||
@@ -65,9 +66,30 @@ ORDEN DE TOOLS EN UN TURNO TÍPICO:
|
||||
3. Si devolvió varios candidatos → NO sigas buscando: usá say para pedir que
|
||||
elija entre los top 3-5 (numerados). El sistema guarda last_shown_options.
|
||||
4. (opcional) add_to_cart / set_quantity / select_candidate / set_shipping /
|
||||
set_address / confirm_order / remove_from_cart / pause / escalate_to_human.
|
||||
set_address / set_delivery_window / confirm_order / remove_from_cart /
|
||||
pause / escalate_to_human.
|
||||
5. say SIEMPRE como último tool del turno. Sin say no hay respuesta.
|
||||
|
||||
ENVÍO Y ZONAS:
|
||||
- Si store.delivery.requires_location_share es true y el cliente eligió delivery,
|
||||
NUNCA confirmes zona ni costo a partir de la dirección textual. Necesitamos
|
||||
la ubicación compartida (pin/location share) por WhatsApp.
|
||||
- Cuando el cliente pide envío: llamá set_shipping(method="delivery"). Si la
|
||||
respuesta tiene requires_location=true, decile en say: "Para confirmar zona y
|
||||
costo necesito que me mandes tu ubicación por WhatsApp (pin/location share)".
|
||||
- Cuando llegue la ubicación, working_memory.order.pending_location va a tener
|
||||
lat/lng. Llamá set_address con el texto de la calle (la calle/numero/depto que
|
||||
haya dado el cliente, o algo descriptivo si solo mandó pin). Si match → te
|
||||
devuelve matched_zone con costo/días/horas. Comunicá eso y pedí día y hora.
|
||||
- Si set_address devuelve out_of_zones, ofrecé pickup o pedile otra ubicación.
|
||||
- Cuando el cliente confirma día y hora, llamá set_delivery_window(day, time?).
|
||||
Días: lun/mar/mie/jue/vie/sab/dom. Hora opcional (HH:MM 24h). Confirmá lo
|
||||
registrado en say antes de llamar a confirm_order.
|
||||
- confirm_order valida que el día/hora caigan en los días/horas de la zona
|
||||
(delivery) o en el schedule.pickup (pickup). Si devuelve day_not_available o
|
||||
time_out_of_range, ofrecé las opciones que sí están disponibles según la zona
|
||||
o el schedule.
|
||||
|
||||
LIMITES TÉCNICOS:
|
||||
- Tenés un máximo de 10 tool calls por turno. No los gastes en búsquedas
|
||||
redundantes — si una query ya devolvió X candidatos, NO la repitas.
|
||||
|
||||
@@ -1,12 +1,46 @@
|
||||
/**
|
||||
* confirm_order — emite create_order si hay cart + shipping completo.
|
||||
*
|
||||
* Validaciones extra (cuando hay schedule/zonas configuradas):
|
||||
* - delivery: día y hora del delivery_window deben caer en zone.delivery_days
|
||||
* y zone.delivery_hours.
|
||||
* - pickup: ídem contra schedule.pickup[day].
|
||||
*
|
||||
* Si no hay delivery_window seteado, confirmamos igual y dejamos que el
|
||||
* comercio coordine día/hora aparte.
|
||||
*/
|
||||
|
||||
import { hasCartItems, hasShippingInfo } from "../../fsm.js";
|
||||
|
||||
function isHHMMInRange(time, start, end) {
|
||||
if (!time || !start || !end) return true;
|
||||
const t = String(time).slice(0, 5);
|
||||
const a = String(start).slice(0, 5);
|
||||
const b = String(end).slice(0, 5);
|
||||
return t >= a && t <= b;
|
||||
}
|
||||
|
||||
function isDayInList(day, list) {
|
||||
if (!Array.isArray(list) || !list.length) return true;
|
||||
return list.includes(day);
|
||||
}
|
||||
|
||||
function formatPickupDays(schedule) {
|
||||
if (!schedule || typeof schedule !== "object") return "";
|
||||
const entries = Object.entries(schedule).filter(
|
||||
([, v]) => v && v.enabled !== false && v.start && v.end
|
||||
);
|
||||
if (!entries.length) return "";
|
||||
return entries.map(([k, v]) => `${k} ${v.start.slice(0, 5)}-${v.end.slice(0, 5)}`).join(", ");
|
||||
}
|
||||
|
||||
export async function confirmOrderTool(_args, ctx) {
|
||||
if (!hasCartItems(ctx.order)) {
|
||||
return { ok: false, error: "empty_cart", hint: "Pedile al cliente que agregue productos antes de confirmar." };
|
||||
return {
|
||||
ok: false,
|
||||
error: "empty_cart",
|
||||
hint: "Pedile al cliente que agregue productos antes de confirmar.",
|
||||
};
|
||||
}
|
||||
if (!hasShippingInfo(ctx.order)) {
|
||||
return {
|
||||
@@ -18,15 +52,83 @@ export async function confirmOrderTool(_args, ctx) {
|
||||
: "Falta dirección. Llamá set_address.",
|
||||
};
|
||||
}
|
||||
|
||||
const win = ctx.order.delivery_window || null;
|
||||
|
||||
if (ctx.order.is_delivery) {
|
||||
const z = ctx.order.matched_zone;
|
||||
// Si hay zonas configuradas pero no se matcheó zona aún, bloquear.
|
||||
const zonesConfigured = (ctx.storeConfig?.delivery_zones?.zones || []).some(
|
||||
(zo) => zo?.enabled !== false
|
||||
);
|
||||
if (zonesConfigured && !z) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "zone_unverified",
|
||||
hint:
|
||||
"Falta verificar zona. Pedile la ubicación por WhatsApp y llamá set_address antes de confirmar.",
|
||||
};
|
||||
}
|
||||
|
||||
if (z && win) {
|
||||
if (!isDayInList(win.day, z.delivery_days)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "day_not_available",
|
||||
hint: `La zona ${z.name} entrega ${(z.delivery_days || []).join("/")}. Pedile otro día.`,
|
||||
allowed_days: z.delivery_days || [],
|
||||
};
|
||||
}
|
||||
if (z.delivery_hours && !isHHMMInRange(win.time, z.delivery_hours.start, z.delivery_hours.end)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "time_out_of_range",
|
||||
hint: `La zona ${z.name} entrega entre ${z.delivery_hours.start.slice(0, 5)} y ${z.delivery_hours.end.slice(0, 5)}. Pedile otro horario.`,
|
||||
allowed_range: z.delivery_hours,
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Pickup
|
||||
const schedule = ctx.storeConfig?.schedule?.pickup || null;
|
||||
if (schedule && win) {
|
||||
const slot = schedule[win.day];
|
||||
if (!slot || slot.enabled === false || !slot.start || !slot.end) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "day_not_available_pickup",
|
||||
hint: `Ese día la tienda no abre. ${formatPickupDays(schedule)}.`,
|
||||
};
|
||||
}
|
||||
if (win.time && !isHHMMInRange(win.time, slot.start, slot.end)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "time_out_of_range_pickup",
|
||||
hint: `Ese día abrimos ${slot.start.slice(0, 5)}-${slot.end.slice(0, 5)}. Pedile otro horario.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotencia: si ya existe create_order encolado, no duplicar
|
||||
const already = ctx.pending_actions.some((a) => a.type === "create_order");
|
||||
if (!already) {
|
||||
ctx.pending_actions.push({ type: "create_order", payload: { source: "wa_bot" } });
|
||||
ctx.pending_actions.push({
|
||||
type: "create_order",
|
||||
payload: {
|
||||
source: "wa_bot",
|
||||
delivery_window: win,
|
||||
matched_zone: ctx.order.matched_zone || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
cart_size: (ctx.order.cart || []).length,
|
||||
is_delivery: !!ctx.order.is_delivery,
|
||||
address: ctx.order.shipping_address || null,
|
||||
delivery_window: win,
|
||||
matched_zone: ctx.order.matched_zone || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { selectCandidateTool } from "./selectCandidate.js";
|
||||
import { removeFromCartTool } from "./removeFromCart.js";
|
||||
import { setShippingTool } from "./setShipping.js";
|
||||
import { setAddressTool } from "./setAddress.js";
|
||||
import { setDeliveryWindowTool } from "./setDeliveryWindow.js";
|
||||
import { confirmOrderTool } from "./confirmOrder.js";
|
||||
import { pauseTool } from "./pause.js";
|
||||
import { escalateToHumanTool } from "./escalateToHuman.js";
|
||||
@@ -37,6 +38,7 @@ const TOOLS = {
|
||||
remove_from_cart: removeFromCartTool,
|
||||
set_shipping: setShippingTool,
|
||||
set_address: setAddressTool,
|
||||
set_delivery_window: setDeliveryWindowTool,
|
||||
confirm_order: confirmOrderTool,
|
||||
pause: pauseTool,
|
||||
escalate_to_human: escalateToHumanTool,
|
||||
|
||||
@@ -109,13 +109,39 @@ export const TOOL_SCHEMAS = [
|
||||
type: "function",
|
||||
function: {
|
||||
name: "set_address",
|
||||
description: "Setea la dirección de entrega y valida zona. Si está fuera de zona devuelve error.",
|
||||
description:
|
||||
"Registra la dirección de entrega (texto, como label) y matchea zona usando la ubicación compartida " +
|
||||
"por WhatsApp (working_memory.order.pending_location). Si no hay ubicación compartida, devuelve " +
|
||||
"need_location: tenés que pedirle al cliente que mande el pin por WhatsApp.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["text"],
|
||||
properties: {
|
||||
text: { type: "string", minLength: 5 },
|
||||
text: {
|
||||
type: "string",
|
||||
minLength: 3,
|
||||
description: "Dirección textual (calle, número, depto, referencias). Sirve como label para el repartidor.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "set_delivery_window",
|
||||
description:
|
||||
"Registra el día/horario que pidió el cliente para entrega o retiro. " +
|
||||
"El día debe ser uno de lun/mar/mie/jue/vie/sab/dom. Hora opcional (HH:MM). " +
|
||||
"confirm_order va a validar después contra la zona o el horario de pickup.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["day"],
|
||||
properties: {
|
||||
day: { type: "string", enum: ["lun", "mar", "mie", "jue", "vie", "sab", "dom"] },
|
||||
time: { type: "string", pattern: "^\\d{2}:\\d{2}$", description: "HH:MM (24h). Opcional." },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,36 +1,80 @@
|
||||
/**
|
||||
* set_address — fija dirección y valida zona.
|
||||
* set_address — fija dirección (texto) y matchea zona usando la ubicación
|
||||
* compartida por WhatsApp (pending_location). Sin location, no se valida
|
||||
* zona y se pide al cliente que mande el pin.
|
||||
*/
|
||||
|
||||
import { checkAddressInZone } from "../../storeContext.js";
|
||||
import { findZoneForPoint } from "../../lib/geo.js";
|
||||
|
||||
export async function setAddressTool(args, ctx) {
|
||||
const { text } = args;
|
||||
const { text } = args || {};
|
||||
const address = String(text || "").trim();
|
||||
if (address.length < 5) return { ok: false, error: "address_too_short" };
|
||||
if (address.length < 3) return { ok: false, error: "address_too_short" };
|
||||
|
||||
// Validar zona si hay zonas cargadas
|
||||
const zoneCheck = checkAddressInZone({
|
||||
address,
|
||||
storeConfig: ctx.storeConfig,
|
||||
});
|
||||
if (!zoneCheck.inZone) {
|
||||
const allZones = ctx.storeConfig?.delivery_zones?.zones || [];
|
||||
const enabled = allZones.filter((z) => z?.enabled !== false);
|
||||
|
||||
// Sin zonas configuradas, aceptamos la dirección sin validar zona.
|
||||
if (!enabled.length) {
|
||||
ctx.order = {
|
||||
...ctx.order,
|
||||
shipping_address: address,
|
||||
is_delivery: ctx.order.is_delivery == null ? true : ctx.order.is_delivery,
|
||||
matched_zone: null,
|
||||
};
|
||||
return { ok: true, address, in_zone: true, reason: "no_zones_configured" };
|
||||
}
|
||||
|
||||
const loc = ctx.order?.pending_location;
|
||||
if (!loc || typeof loc.lat !== "number" || typeof loc.lng !== "number") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "out_of_zone",
|
||||
reason: zoneCheck.reason,
|
||||
available_zones: zoneCheck.zones,
|
||||
hint: "Pedile al cliente otra dirección o ofrecele pickup.",
|
||||
error: "need_location",
|
||||
hint:
|
||||
"Pedile al cliente que te mande su ubicación por WhatsApp (pin/location share) " +
|
||||
"para validar zona y costo. Sin ubicación no podemos confirmar envío.",
|
||||
available_zones: enabled.map((z) => ({
|
||||
name: z.name,
|
||||
delivery_cost: z.delivery_cost ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Si no había is_delivery seteado, asumir delivery=true (le dieron dirección)
|
||||
const is_delivery = ctx.order.is_delivery == null ? true : ctx.order.is_delivery;
|
||||
ctx.order = { ...ctx.order, shipping_address: address, is_delivery };
|
||||
const matched = findZoneForPoint(loc.lng, loc.lat, enabled);
|
||||
if (!matched) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "out_of_zones",
|
||||
hint:
|
||||
"La ubicación que mandó está fuera de las zonas que cubre la carnicería. " +
|
||||
"Ofrecé pickup o pedile otra ubicación dentro de las zonas habilitadas.",
|
||||
available_zones: enabled.map((z) => ({
|
||||
name: z.name,
|
||||
delivery_cost: z.delivery_cost ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const zoneSummary = {
|
||||
id: matched.id,
|
||||
name: matched.name,
|
||||
delivery_cost: matched.delivery_cost ?? null,
|
||||
delivery_days: Array.isArray(matched.delivery_days) ? matched.delivery_days : [],
|
||||
delivery_hours: matched.delivery_hours || null,
|
||||
min_order_amount: matched.min_order_amount ?? 0,
|
||||
};
|
||||
|
||||
ctx.order = {
|
||||
...ctx.order,
|
||||
shipping_address: address,
|
||||
is_delivery: ctx.order.is_delivery == null ? true : ctx.order.is_delivery,
|
||||
matched_zone: zoneSummary,
|
||||
};
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
address,
|
||||
in_zone: true,
|
||||
matched_zone: zoneCheck.matched || null,
|
||||
matched_zone: zoneSummary,
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
const { method } = args;
|
||||
const { method } = args || {};
|
||||
if (method !== "delivery" && method !== "pickup") {
|
||||
return { ok: false, error: "invalid_method" };
|
||||
}
|
||||
|
||||
ctx.order = { ...ctx.order, is_delivery: method === "delivery" };
|
||||
return { ok: true, method, requires_address: method === "delivery" && !ctx.order.shipping_address };
|
||||
|
||||
if (method === "pickup") {
|
||||
return {
|
||||
ok: true,
|
||||
method,
|
||||
requires_address: false,
|
||||
requires_location: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Delivery
|
||||
const zones = ctx.storeConfig?.delivery_zones?.zones || [];
|
||||
const enabledZones = zones.filter((z) => z?.enabled !== false);
|
||||
const hasZones = enabledZones.length > 0;
|
||||
const hasLocation = !!ctx.order?.pending_location?.lat;
|
||||
const hasMatchedZone = !!ctx.order?.matched_zone;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
method,
|
||||
requires_address: !ctx.order.shipping_address,
|
||||
requires_location: hasZones && !hasLocation && !hasMatchedZone,
|
||||
available_zones: hasZones
|
||||
? enabledZones.map((z) => ({
|
||||
name: z.name,
|
||||
delivery_cost: z.delivery_cost ?? null,
|
||||
delivery_days: z.delivery_days || [],
|
||||
delivery_hours: z.delivery_hours || null,
|
||||
}))
|
||||
: [],
|
||||
hint:
|
||||
hasZones && !hasLocation
|
||||
? "Pedile al cliente que te mande su ubicación por WhatsApp (pin/location share) para validar zona y costo de envío."
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { parseQuantity } from "./quantityParser.js";
|
||||
import { buildStoreContextVars } from "../storeContext.js";
|
||||
import { buildStoreContextVars, buildZonesForLLM } from "../storeContext.js";
|
||||
|
||||
const HISTORY_MAX = 8;
|
||||
const HISTORY_CHAR_CAP = 200;
|
||||
@@ -90,14 +90,22 @@ export function buildWorkingMemory({
|
||||
|
||||
const preparsed = parseQuantity(text || "");
|
||||
|
||||
const zones = buildZonesForLLM(storeConfig.delivery_zones);
|
||||
const pickupSchedule = storeConfig.schedule?.pickup || null;
|
||||
|
||||
return {
|
||||
now: nowIso(),
|
||||
store: {
|
||||
name: storeVars.store_name || "la carnicería",
|
||||
hours_today: storeVars.store_hours_today || "consultar",
|
||||
delivery: {
|
||||
available_now: storeVars.delivery_available_now || "",
|
||||
zones, // [{id,name,delivery_cost,delivery_days,delivery_hours,min_order_amount}]
|
||||
zones_summary: storeVars.delivery_zones_summary || "",
|
||||
requires_location_share: zones.length > 0, // hint para el LLM
|
||||
},
|
||||
pickup: {
|
||||
schedule: pickupSchedule, // { lun:{start,end,enabled}, mar:..., ... } o null
|
||||
hours_today: storeVars.pickup_hours_today || "",
|
||||
},
|
||||
},
|
||||
fsm_state: prev_state || "IDLE",
|
||||
@@ -107,6 +115,9 @@ export function buildWorkingMemory({
|
||||
is_delivery: order.is_delivery ?? null,
|
||||
shipping_address: order.shipping_address ?? null,
|
||||
woo_order_id: order.woo_order_id ?? null,
|
||||
pending_location: order.pending_location || null, // { lat, lng, label?, received_at }
|
||||
matched_zone: order.matched_zone || null, // resumen de zona matched (set por set_address)
|
||||
delivery_window: order.delivery_window || null, // { day, time } elegido por cliente
|
||||
},
|
||||
last_shown_options,
|
||||
paused_until: order.paused_until ?? null,
|
||||
|
||||
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
|
||||
shipping_address: null,
|
||||
woo_order_id: null,
|
||||
pending_location: null, // { lat, lng, label?, received_at } — última ubicación compartida por WhatsApp
|
||||
matched_zone: null, // resumen de zona matched (id, name, cost, days, hours)
|
||||
delivery_window: null, // { day: "lun"|..., time: "HH:MM" } seleccionado por el cliente
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +1,40 @@
|
||||
/**
|
||||
* Store Context - Helpers para inyectar info de la tienda en respuestas.
|
||||
* Store Context — helpers para inyectar info de la tienda en el agente.
|
||||
*
|
||||
* Estos helpers leen del storeConfig ya cargado por getStoreConfig (en
|
||||
* pipeline / turnEngine) y producen variables consumibles por reply templates
|
||||
* (renderReply via {{var}}) y por el reply rewriter (como hint contextual).
|
||||
*
|
||||
* Filosofía: cuando los datos no están cargados, las vars resuelven a strings
|
||||
* vacíos / hints neutros. Los templates están diseñados para tolerar ausencia.
|
||||
* Schema canónico de delivery_zones (JSONB en tenant_settings):
|
||||
* {
|
||||
* zones: [
|
||||
* {
|
||||
* id: "centro",
|
||||
* name: "Centro / Microcentro",
|
||||
* polygon: { type: "Polygon", coordinates: [[[lng,lat], ...]] },
|
||||
* delivery_cost: 1500,
|
||||
* delivery_days: ["lun","mar","mie","jue","vie","sab"],
|
||||
* delivery_hours: { start: "10:00", end: "20:00" },
|
||||
* min_order_amount: 0,
|
||||
* enabled: true
|
||||
* }
|
||||
* ],
|
||||
* default_center: [lng, lat] // opcional, para el editor
|
||||
* }
|
||||
*/
|
||||
|
||||
const DAY_KEYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
||||
const DAY_KEYS_LONG = ["lunes", "martes", "miercoles", "jueves", "viernes", "sabado", "domingo"];
|
||||
import { findZoneForPoint } from "./lib/geo.js";
|
||||
|
||||
/**
|
||||
* Devuelve la clave de día (mon..sun) para hoy.
|
||||
*/
|
||||
function todayKey() {
|
||||
// Date.getDay(): 0 = domingo, 1 = lunes
|
||||
const d = new Date().getDay();
|
||||
// mapear a mon..sun
|
||||
return DAY_KEYS[(d + 6) % 7];
|
||||
const DAY_KEYS_SHORT = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
|
||||
const DAY_NAMES = {
|
||||
lun: "Lunes", mar: "Martes", mie: "Miércoles",
|
||||
jue: "Jueves", vie: "Viernes", sab: "Sábado", dom: "Domingo",
|
||||
};
|
||||
|
||||
function todayShortKey() {
|
||||
// Date.getDay(): 0=Dom..6=Sab → mapeamos a lun..dom (0=lun)
|
||||
return DAY_KEYS_SHORT[(new Date().getDay() + 6) % 7];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae el horario (string) de un day-key del schedule jsonb.
|
||||
* Formato esperado: { mon: { start: '09:00', end: '14:00', enabled: true }, ... }
|
||||
* Tolera variantes (lunes, monday, etc.) y formatos planos.
|
||||
*/
|
||||
function pickDaySlot(scheduleObj, dayIdx) {
|
||||
function pickDaySlot(scheduleObj, dayKey) {
|
||||
if (!scheduleObj || typeof scheduleObj !== "object") return null;
|
||||
const keys = [DAY_KEYS[dayIdx], DAY_KEYS_LONG[dayIdx]];
|
||||
for (const k of keys) {
|
||||
if (scheduleObj[k]) return scheduleObj[k];
|
||||
}
|
||||
return null;
|
||||
return scheduleObj[dayKey] || null;
|
||||
}
|
||||
|
||||
function formatDaySlot(slot) {
|
||||
@@ -45,104 +46,114 @@ function formatDaySlot(slot) {
|
||||
return `${start} a ${end}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumen de zonas de delivery: lista de barrios o "Consultar zonas".
|
||||
*/
|
||||
/**
|
||||
* Lista plana de barrios (lowercase) habilitados para delivery.
|
||||
*/
|
||||
function getDeliveryZoneNames(deliveryZones) {
|
||||
function getZones(deliveryZones) {
|
||||
if (!deliveryZones || typeof deliveryZones !== "object") return [];
|
||||
const names = [];
|
||||
if (Array.isArray(deliveryZones.zones)) {
|
||||
for (const z of deliveryZones.zones) if (z?.name) names.push(z.name);
|
||||
} else if (deliveryZones.caba?.barrios) {
|
||||
if (Array.isArray(deliveryZones.caba.barrios)) names.push(...deliveryZones.caba.barrios);
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(deliveryZones)) {
|
||||
if (v === true) names.push(k);
|
||||
else if (v?.name) names.push(v.name);
|
||||
}
|
||||
}
|
||||
return names.map((n) => String(n).toLowerCase().trim()).filter(Boolean);
|
||||
if (!Array.isArray(deliveryZones.zones)) return [];
|
||||
return deliveryZones.zones.filter((z) => z && z.enabled !== false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si un texto de dirección menciona un barrio en zona.
|
||||
* - Sin zonas configuradas: retorna `{ inZone: true, reason: "no_zones_configured" }`
|
||||
* (no bloqueamos hasta que el comercio cargue las zonas).
|
||||
* - Con zonas: matching simple por inclusion del barrio en el texto (lowercase, sin tildes).
|
||||
* Devuelve los nombres de zonas habilitadas (sin polygon, para UI/log).
|
||||
*/
|
||||
export function checkAddressInZone({ address, storeConfig }) {
|
||||
const zones = getDeliveryZoneNames(storeConfig?.delivery_zones);
|
||||
if (!zones.length) {
|
||||
export function getDeliveryZoneNames(deliveryZones) {
|
||||
return getZones(deliveryZones).map((z) => String(z.name || z.id || "").trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si una ubicación (lat/lng) cae dentro de alguna zona habilitada.
|
||||
*
|
||||
* @param {Object} args
|
||||
* @param {{lat:number, lng:number}} [args.location]
|
||||
* @param {Object} args.storeConfig
|
||||
* @returns {{inZone:boolean, reason:string, matched_zone?:Object, zones:Array}}
|
||||
*/
|
||||
export function checkAddressInZone({ location, storeConfig }) {
|
||||
const allZones = getZones(storeConfig?.delivery_zones);
|
||||
if (!allZones.length) {
|
||||
return { inZone: true, reason: "no_zones_configured", zones: [] };
|
||||
}
|
||||
const norm = String(address || "")
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.trim();
|
||||
if (!norm) return { inZone: false, reason: "empty_address", zones };
|
||||
const match = zones.find((z) => {
|
||||
const zNorm = z.normalize("NFD").replace(/[̀-ͯ]/g, "");
|
||||
return norm.includes(zNorm);
|
||||
});
|
||||
if (match) return { inZone: true, reason: "matched", matched: match, zones };
|
||||
return { inZone: false, reason: "not_in_zone", zones };
|
||||
if (!location || typeof location.lat !== "number" || typeof location.lng !== "number") {
|
||||
return {
|
||||
inZone: false,
|
||||
reason: "need_location",
|
||||
zones: allZones.map(zoneSummary),
|
||||
};
|
||||
}
|
||||
const matched = findZoneForPoint(location.lng, location.lat, allZones);
|
||||
if (matched) {
|
||||
return { inZone: true, reason: "matched", matched_zone: zoneSummary(matched), zones: allZones.map(zoneSummary) };
|
||||
}
|
||||
return { inZone: false, reason: "out_of_zones", zones: allZones.map(zoneSummary) };
|
||||
}
|
||||
|
||||
function summarizeDeliveryZones(deliveryZones) {
|
||||
if (!deliveryZones || typeof deliveryZones !== "object") return "";
|
||||
const names = [];
|
||||
// Soporta varios formatos:
|
||||
// 1) { caba: { barrios: ["Palermo", "Belgrano"] } }
|
||||
// 2) { zones: [{ name }] }
|
||||
// 3) { palermo: true, belgrano: true } (flat)
|
||||
if (Array.isArray(deliveryZones.zones)) {
|
||||
for (const z of deliveryZones.zones) if (z?.name) names.push(z.name);
|
||||
} else if (deliveryZones.caba?.barrios) {
|
||||
if (Array.isArray(deliveryZones.caba.barrios)) names.push(...deliveryZones.caba.barrios);
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(deliveryZones)) {
|
||||
if (v === true) names.push(k);
|
||||
else if (v?.name) names.push(v.name);
|
||||
}
|
||||
}
|
||||
if (!names.length) return "";
|
||||
if (names.length <= 5) return names.join(", ");
|
||||
return `${names.slice(0, 5).join(", ")} y otros`;
|
||||
function zoneSummary(z) {
|
||||
return {
|
||||
id: z.id,
|
||||
name: z.name,
|
||||
delivery_cost: z.delivery_cost ?? null,
|
||||
delivery_days: Array.isArray(z.delivery_days) ? z.delivery_days : [],
|
||||
delivery_hours: z.delivery_hours || null,
|
||||
min_order_amount: z.min_order_amount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye variables de contexto de tienda para usar en reply templates.
|
||||
* Cuando los datos no están, las vars vienen vacías — los templates las
|
||||
* absorben sin romper.
|
||||
*
|
||||
* @param {Object} storeConfig - resultado de getStoreConfig({ tenantId })
|
||||
* @returns {Object} vars para applyVariables / renderReply
|
||||
* Formato compacto que ve el LLM en working_memory.store.delivery.zones[].
|
||||
* Sin polygon (no le sirve al modelo), sí con costos/días/horas.
|
||||
*/
|
||||
export function buildZonesForLLM(deliveryZones) {
|
||||
return getZones(deliveryZones).map(zoneSummary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumen humano para system/replies cuando el LLM lo necesita en prosa.
|
||||
* Ejemplo: "Centro ($1500, lun-sab 10-20h), Palermo ($2000, mar/jue 11-19h)".
|
||||
*/
|
||||
export function summarizeDeliveryZones(deliveryZones) {
|
||||
const zones = getZones(deliveryZones);
|
||||
if (!zones.length) return "";
|
||||
return zones.map((z) => {
|
||||
const cost = z.delivery_cost != null ? `$${Number(z.delivery_cost).toLocaleString("es-AR")}` : "";
|
||||
const days = formatDaysList(z.delivery_days);
|
||||
const hours = z.delivery_hours?.start && z.delivery_hours?.end
|
||||
? `${z.delivery_hours.start.slice(0, 5)}-${z.delivery_hours.end.slice(0, 5)}h`
|
||||
: "";
|
||||
const tail = [cost, days, hours].filter(Boolean).join(" ");
|
||||
return tail ? `${z.name} (${tail})` : z.name;
|
||||
}).join(", ");
|
||||
}
|
||||
|
||||
function formatDaysList(days) {
|
||||
if (!Array.isArray(days) || !days.length) return "";
|
||||
// Detectar rango consecutivo lun-sab etc.
|
||||
const idx = days.map((d) => DAY_KEYS_SHORT.indexOf(d)).filter((i) => i >= 0).sort((a, b) => a - b);
|
||||
if (idx.length >= 3) {
|
||||
const isContiguous = idx.every((v, i, arr) => i === 0 || v === arr[i - 1] + 1);
|
||||
if (isContiguous) return `${DAY_KEYS_SHORT[idx[0]]}-${DAY_KEYS_SHORT[idx[idx.length - 1]]}`;
|
||||
}
|
||||
return idx.map((i) => DAY_KEYS_SHORT[i]).join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye variables de contexto de tienda. El bot ya no usa templates de
|
||||
* texto, pero workingMemory sigue tomando algunos de estos campos.
|
||||
*/
|
||||
export function buildStoreContextVars(storeConfig = {}) {
|
||||
const dayIdx = (new Date().getDay() + 6) % 7; // 0=lunes
|
||||
const dayKey = todayShortKey();
|
||||
const sched = storeConfig.schedule || {};
|
||||
|
||||
const deliverySlot = pickDaySlot(sched.delivery, dayIdx);
|
||||
const pickupSlot = pickDaySlot(sched.pickup, dayIdx);
|
||||
|
||||
const storeHoursToday = formatDaySlot(deliverySlot) || formatDaySlot(pickupSlot) || "";
|
||||
const deliveryAvailableNow = deliverySlot ? "sí" : (storeConfig.deliveryEnabled === false ? "no" : "");
|
||||
const deliveryZonesSummary = summarizeDeliveryZones(storeConfig.delivery_zones);
|
||||
const pickupSlot = pickDaySlot(sched.pickup, dayKey);
|
||||
const storeHoursToday = formatDaySlot(pickupSlot) || "";
|
||||
|
||||
return {
|
||||
store_name: storeConfig.name || "",
|
||||
bot_name: storeConfig.botName || "",
|
||||
store_address: storeConfig.address || "",
|
||||
store_phone: storeConfig.phone || "",
|
||||
store_hours: storeConfig.hours || "",
|
||||
store_hours_today: storeHoursToday,
|
||||
delivery_hours: storeConfig.deliveryHours || "",
|
||||
pickup_hours: storeConfig.pickupHours || "",
|
||||
delivery_available_now: deliveryAvailableNow,
|
||||
delivery_zones_summary: deliveryZonesSummary,
|
||||
pickup_hours_today: formatDaySlot(pickupSlot) || "",
|
||||
delivery_zones_summary: summarizeDeliveryZones(storeConfig.delivery_zones),
|
||||
};
|
||||
}
|
||||
|
||||
export const __test__ = { todayShortKey, pickDaySlot, formatDaySlot, formatDaysList, DAY_NAMES };
|
||||
|
||||
Reference in New Issue
Block a user