corregidos bugs de: ret, vs delivery, efectivo vs link, charsets, price query

This commit is contained in:
Lucas Tettamanti
2026-01-26 23:27:47 -03:00
parent 53293ce9b3
commit 493f26af17
13 changed files with 757 additions and 193 deletions

View File

@@ -1,7 +0,0 @@
{"location":"pipeline.js:398","message":"upsertConversationState - context being saved","data":{"nextState":"CART","intent":"add_to_cart","orderPendingCount":0,"orderCartCount":1,"pendingItemsFromPatch":0,"pendingQueries":[]},"timestamp":1769399756809,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"pipeline.js:398","message":"upsertConversationState - context being saved","data":{"nextState":"CART","intent":"add_to_cart","orderPendingCount":1,"orderCartCount":1,"pendingItemsFromPatch":1,"pendingQueries":["chota"]},"timestamp":1769399763739,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"cartHelpers.js:320","message":"takeover - order before/after pending removal","data":{"pendingItemId":"pending_1769399763727_6f1m2r","pendingItemQuery":"chota","originalPendingCount":1,"newPendingCount":0,"originalPendingIds":["pending_1769399763727_6f1m2r"],"newPendingIds":[]},"timestamp":1769399769529,"sessionId":"debug-session","hypothesisId":"C"}
{"location":"pipeline.js:398","message":"upsertConversationState - context being saved","data":{"nextState":"CART","intent":"human_takeover","orderPendingCount":0,"orderCartCount":1,"pendingItemsFromPatch":0,"pendingQueries":[]},"timestamp":1769399769547,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"takeovers.handler.js:173","message":"respond - state read from DB","data":{"state":"CART","orderPendingCount":0,"orderCartCount":1,"pendingQueries":[]},"timestamp":1769399811578,"sessionId":"debug-session","hypothesisId":"B"}
{"location":"takeovers.handler.js:195","message":"respond - order being saved (with cart items)","data":{"orderPendingCount":0,"orderCartCount":2,"pendingQueries":[]},"timestamp":1769399811579,"sessionId":"debug-session","hypothesisId":"B-save"}
{"location":"pipeline.js:398","message":"upsertConversationState - context being saved","data":{"nextState":"CART","intent":"view_cart","orderPendingCount":0,"orderCartCount":2,"pendingItemsFromPatch":0,"pendingQueries":[]},"timestamp":1769399826724,"sessionId":"debug-session","hypothesisId":"A"}

View File

@@ -0,0 +1,44 @@
-- migrate:up
-- Agregar columna schedule JSONB para horarios flexibles por día
-- Estructura: { "delivery": { "lun": { "start": "09:00", "end": "14:00" }, ... }, "pickup": { ... } }
ALTER TABLE tenant_settings
ADD COLUMN IF NOT EXISTS schedule JSONB DEFAULT '{}';
-- Migrar datos existentes al nuevo formato
UPDATE tenant_settings
SET schedule = jsonb_build_object(
'delivery', CASE
WHEN delivery_enabled AND delivery_days IS NOT NULL THEN (
SELECT jsonb_object_agg(
day,
jsonb_build_object(
'start', COALESCE(delivery_hours_start::text, '09:00'),
'end', COALESCE(delivery_hours_end::text, '18:00')
)
)
FROM unnest(string_to_array(delivery_days, ',')) AS day
WHERE day IS NOT NULL AND day != ''
)
ELSE '{}'::jsonb
END,
'pickup', CASE
WHEN pickup_enabled AND pickup_days IS NOT NULL THEN (
SELECT jsonb_object_agg(
day,
jsonb_build_object(
'start', COALESCE(pickup_hours_start::text, '08:00'),
'end', COALESCE(pickup_hours_end::text, '20:00')
)
)
FROM unnest(string_to_array(pickup_days, ',')) AS day
WHERE day IS NOT NULL AND day != ''
)
ELSE '{}'::jsonb
END
)
WHERE schedule = '{}' OR schedule IS NULL;
-- migrate:down
ALTER TABLE tenant_settings DROP COLUMN IF EXISTS schedule;

View File

@@ -247,11 +247,50 @@ class OrdersCrud extends HTMLElement {
}
});
// Escuchar nuevos pedidos para actualizar automáticamente
// Usa retry con backoff porque WooCommerce puede tardar en devolver el pedido recién creado
this._unsubOrderCreated = on("order:created", ({ order_id }) => {
console.log("[orders-crud] order:created received, order_id:", order_id);
this.refreshWithRetry(order_id);
});
this.loadOrders();
}
disconnectedCallback() {
this._unsubRouter?.();
this._unsubOrderCreated?.();
}
/**
* Refresca la lista de pedidos con retry si el pedido esperado no aparece
* WooCommerce puede tardar en devolver un pedido recién creado
*/
async refreshWithRetry(expectedOrderId, attempt = 1) {
const maxAttempts = 3;
const delays = [500, 2000, 4000]; // ms entre intentos
// Esperar antes de refrescar
if (attempt > 1) {
await new Promise(r => setTimeout(r, delays[attempt - 1] || 2000));
} else {
// Primer intento: pequeño delay para dar tiempo a WooCommerce
await new Promise(r => setTimeout(r, 500));
}
await this.loadOrders();
// Verificar si el pedido esperado apareció
if (expectedOrderId) {
const found = this.orders.find(o => o.id === expectedOrderId);
if (!found && attempt < maxAttempts) {
console.log(`[orders-crud] order ${expectedOrderId} not found, retry ${attempt + 1}/${maxAttempts}...`);
return this.refreshWithRetry(expectedOrderId, attempt + 1);
}
if (found) {
console.log(`[orders-crud] order ${expectedOrderId} found on attempt ${attempt}`);
}
}
}
async loadOrders() {

View File

@@ -1,13 +1,13 @@
import { api } from "../lib/api.js";
const DAYS = [
{ id: "lun", label: "Lun" },
{ id: "mar", label: "Mar" },
{ id: "mie", label: "Mié" },
{ id: "jue", label: "Jue" },
{ id: "vie", label: "Vie" },
{ id: "sab", label: "Sáb" },
{ id: "dom", label: "Dom" },
{ id: "lun", label: "Lunes", short: "Lun" },
{ id: "mar", label: "Martes", short: "Mar" },
{ id: "mie", label: "Miércoles", short: "Mié" },
{ id: "jue", label: "Jueves", short: "Jue" },
{ id: "vie", label: "Viernes", short: "Vie" },
{ id: "sab", label: "Sábado", short: "Sáb" },
{ id: "dom", label: "Domingo", short: "Dom" },
];
class SettingsCrud extends HTMLElement {
@@ -50,11 +50,11 @@ class SettingsCrud extends HTMLElement {
button.secondary { background:#253245; }
button.secondary:hover { background:#2d3e52; }
.toggle-row { display:flex; align-items:center; gap:12px; margin-bottom:12px; }
.toggle-row { display:flex; align-items:center; gap:12px; margin-bottom:16px; }
.toggle {
position:relative; width:48px; height:26px;
background:#253245; border-radius:13px; cursor:pointer;
transition:background .2s;
transition:background .2s; flex-shrink:0;
}
.toggle.active { background:#1f6feb; }
.toggle::after {
@@ -65,20 +65,38 @@ class SettingsCrud extends HTMLElement {
.toggle.active::after { transform:translateX(22px); }
.toggle-label { font-size:14px; color:#e7eef7; }
.days-selector { display:flex; gap:6px; flex-wrap:wrap; }
.day-btn {
padding:8px 12px; border-radius:6px; font-size:12px; font-weight:600;
background:#253245; color:#8aa0b5; border:none; cursor:pointer;
transition:all .15s;
/* Schedule grid */
.schedule-grid { display:flex; flex-direction:column; gap:8px; }
.schedule-row {
display:grid;
grid-template-columns:90px 32px 1fr;
gap:12px;
align-items:center;
padding:8px 12px;
background:#0f1520;
border-radius:8px;
border:1px solid #1e2a3a;
}
.day-btn:hover { background:#2d3e52; color:#e7eef7; }
.day-btn.selected { background:#1f6feb; color:#fff; }
.hours-row { display:flex; align-items:center; gap:12px; margin-top:12px; }
.hours-row input { width:90px; text-align:center; font-family:monospace; font-size:15px; letter-spacing:1px; }
.hours-row input::placeholder { color:#6c7a89; }
.hours-row span { color:#8aa0b5; }
.hours-row .hour-hint { font-size:11px; color:#6c7a89; margin-left:8px; }
.schedule-row.disabled { opacity:0.4; }
.day-label { font-size:13px; color:#e7eef7; font-weight:500; }
.day-toggle {
width:32px; height:18px; background:#253245; border-radius:9px;
cursor:pointer; position:relative; transition:background .2s;
}
.day-toggle.active { background:#2ecc71; }
.day-toggle::after {
content:''; position:absolute; top:2px; left:2px;
width:14px; height:14px; background:#fff; border-radius:50%;
transition:transform .2s;
}
.day-toggle.active::after { transform:translateX(14px); }
.hours-inputs { display:flex; align-items:center; gap:8px; }
.hours-inputs input {
width:70px; text-align:center; font-family:monospace;
font-size:13px; padding:6px 8px; letter-spacing:1px;
}
.hours-inputs span { color:#6c7a89; font-size:12px; }
.hours-inputs.disabled input { opacity:0.4; pointer-events:none; }
.actions { display:flex; gap:12px; margin-top:24px; }
.loading { text-align:center; padding:60px; color:#8aa0b5; }
@@ -93,6 +111,8 @@ class SettingsCrud extends HTMLElement {
color:#e74c3c; padding:12px 16px; border-radius:8px;
margin-bottom:16px; font-size:14px;
}
.min-order-field { margin-top:16px; padding-top:16px; border-top:1px solid #1e2a3a; }
</style>
<div class="container">
@@ -114,6 +134,10 @@ class SettingsCrud extends HTMLElement {
try {
this.settings = await api.getSettings();
// Asegurar que schedule existe
if (!this.settings.schedule) {
this.settings.schedule = { delivery: {}, pickup: {} };
}
this.loading = false;
this.render();
} catch (e) {
@@ -123,6 +147,60 @@ class SettingsCrud extends HTMLElement {
}
}
getScheduleSlot(type, dayId) {
return this.settings?.schedule?.[type]?.[dayId] || null;
}
setScheduleSlot(type, dayId, slot) {
if (!this.settings.schedule) {
this.settings.schedule = { delivery: {}, pickup: {} };
}
if (!this.settings.schedule[type]) {
this.settings.schedule[type] = {};
}
this.settings.schedule[type][dayId] = slot;
}
renderScheduleGrid(type, enabled) {
const defaultStart = type === "delivery" ? "09:00" : "08:00";
const defaultEnd = type === "delivery" ? "18:00" : "20:00";
return DAYS.map(day => {
const slot = this.getScheduleSlot(type, day.id);
const isActive = slot !== null && slot !== undefined;
const start = slot?.start || defaultStart;
const end = slot?.end || defaultEnd;
return `
<div class="schedule-row ${!enabled ? 'disabled' : ''}">
<span class="day-label">${day.label}</span>
<div class="day-toggle ${isActive ? 'active' : ''}"
data-type="${type}" data-day="${day.id}"
${!enabled ? 'style="pointer-events:none"' : ''}></div>
<div class="hours-inputs ${!isActive ? 'disabled' : ''}">
<input type="text"
class="hour-start"
data-type="${type}"
data-day="${day.id}"
value="${start}"
placeholder="09:00"
maxlength="5"
${!enabled || !isActive ? 'disabled' : ''} />
<span>a</span>
<input type="text"
class="hour-end"
data-type="${type}"
data-day="${day.id}"
value="${end}"
placeholder="18:00"
maxlength="5"
${!enabled || !isActive ? 'disabled' : ''} />
</div>
</div>
`;
}).join("");
}
render() {
const content = this.shadowRoot.getElementById("content");
@@ -137,8 +215,6 @@ class SettingsCrud extends HTMLElement {
}
const s = this.settings;
const deliveryDays = (s.delivery_days || "").split(",").filter(d => d);
const pickupDays = (s.pickup_days || "").split(",").filter(d => d);
content.innerHTML = `
<!-- Info del Negocio -->
@@ -185,26 +261,13 @@ class SettingsCrud extends HTMLElement {
<span class="toggle-label">Delivery habilitado</span>
</div>
<div id="deliveryOptions" style="${s.delivery_enabled ? "" : "opacity:0.5;pointer-events:none;"}">
<div class="schedule-grid" id="deliverySchedule">
${this.renderScheduleGrid("delivery", s.delivery_enabled)}
</div>
<div class="min-order-field">
<div class="field">
<label>Días disponibles</label>
<div class="days-selector" id="deliveryDays">
${DAYS.map(d => `
<button type="button" class="day-btn ${deliveryDays.includes(d.id) ? "selected" : ""}" data-day="${d.id}">${d.label}</button>
`).join("")}
</div>
</div>
<div class="hours-row">
<span>Horario:</span>
<input type="text" id="deliveryStart" value="${s.delivery_hours_start || "09:00"}" placeholder="09:00" pattern="([01]?[0-9]|2[0-3]):[0-5][0-9]" maxlength="5" />
<span>a</span>
<input type="text" id="deliveryEnd" value="${s.delivery_hours_end || "18:00"}" placeholder="18:00" pattern="([01]?[0-9]|2[0-3]):[0-5][0-9]" maxlength="5" />
<span class="hour-hint">(formato 24hs)</span>
</div>
<div class="field" style="margin-top:12px;">
<label>Pedido mínimo ($)</label>
<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>
@@ -222,23 +285,8 @@ class SettingsCrud extends HTMLElement {
<span class="toggle-label">Retiro en tienda habilitado</span>
</div>
<div id="pickupOptions" style="${s.pickup_enabled ? "" : "opacity:0.5;pointer-events:none;"}">
<div class="field">
<label>Días disponibles</label>
<div class="days-selector" id="pickupDays">
${DAYS.map(d => `
<button type="button" class="day-btn ${pickupDays.includes(d.id) ? "selected" : ""}" data-day="${d.id}">${d.label}</button>
`).join("")}
</div>
</div>
<div class="hours-row">
<span>Horario:</span>
<input type="text" id="pickupStart" value="${s.pickup_hours_start || "08:00"}" placeholder="08:00" pattern="([01]?[0-9]|2[0-3]):[0-5][0-9]" maxlength="5" />
<span>a</span>
<input type="text" id="pickupEnd" value="${s.pickup_hours_end || "20:00"}" placeholder="20:00" pattern="([01]?[0-9]|2[0-3]):[0-5][0-9]" maxlength="5" />
<span class="hour-hint">(formato 24hs)</span>
</div>
<div class="schedule-grid" id="pickupSchedule">
${this.renderScheduleGrid("pickup", s.pickup_enabled)}
</div>
</div>
@@ -266,36 +314,43 @@ class SettingsCrud extends HTMLElement {
this.render();
});
// Delivery days
this.shadowRoot.querySelectorAll("#deliveryDays .day-btn").forEach(btn => {
btn.addEventListener("click", () => {
const day = btn.dataset.day;
let days = (this.settings.delivery_days || "").split(",").filter(d => d);
if (days.includes(day)) {
days = days.filter(d => d !== day);
// Day toggles
this.shadowRoot.querySelectorAll(".day-toggle").forEach(toggle => {
toggle.addEventListener("click", () => {
const type = toggle.dataset.type;
const day = toggle.dataset.day;
const currentSlot = this.getScheduleSlot(type, day);
if (currentSlot) {
// Desactivar día
this.setScheduleSlot(type, day, null);
} else {
days.push(day);
// Activar día con horarios default
const defaultStart = type === "delivery" ? "09:00" : "08:00";
const defaultEnd = type === "delivery" ? "18:00" : "20:00";
this.setScheduleSlot(type, day, { start: defaultStart, end: defaultEnd });
}
// Ordenar días
days.sort((a, b) => DAYS.findIndex(d => d.id === a) - DAYS.findIndex(d => d.id === b));
this.settings.delivery_days = days.join(",");
this.render();
});
});
// Pickup days
this.shadowRoot.querySelectorAll("#pickupDays .day-btn").forEach(btn => {
btn.addEventListener("click", () => {
const day = btn.dataset.day;
let days = (this.settings.pickup_days || "").split(",").filter(d => d);
if (days.includes(day)) {
days = days.filter(d => d !== day);
// Hour inputs - update on blur
this.shadowRoot.querySelectorAll(".hour-start, .hour-end").forEach(input => {
input.addEventListener("blur", () => {
const type = input.dataset.type;
const day = input.dataset.day;
const isStart = input.classList.contains("hour-start");
const slot = this.getScheduleSlot(type, day);
if (!slot) return;
const value = input.value.trim();
if (isStart) {
slot.start = value || (type === "delivery" ? "09:00" : "08:00");
} else {
days.push(day);
slot.end = value || (type === "delivery" ? "18:00" : "20:00");
}
days.sort((a, b) => DAYS.findIndex(d => d.id === a) - DAYS.findIndex(d => d.id === b));
this.settings.pickup_days = days.join(",");
this.render();
this.setScheduleSlot(type, day, slot);
});
});
@@ -306,25 +361,43 @@ class SettingsCrud extends HTMLElement {
this.shadowRoot.getElementById("resetBtn")?.addEventListener("click", () => this.load());
}
collectScheduleFromInputs() {
const schedule = { delivery: {}, pickup: {} };
for (const type of ["delivery", "pickup"]) {
this.shadowRoot.querySelectorAll(`.hour-start[data-type="${type}"]`).forEach(input => {
const day = input.dataset.day;
const endInput = this.shadowRoot.querySelector(`.hour-end[data-type="${type}"][data-day="${day}"]`);
const toggle = this.shadowRoot.querySelector(`.day-toggle[data-type="${type}"][data-day="${day}"]`);
if (toggle?.classList.contains("active")) {
schedule[type][day] = {
start: input.value.trim() || (type === "delivery" ? "09:00" : "08:00"),
end: endInput?.value.trim() || (type === "delivery" ? "18:00" : "20:00"),
};
}
});
}
return schedule;
}
async save() {
// Collect form data BEFORE re-rendering
// Collect schedule from inputs
const schedule = this.collectScheduleFromInputs();
const data = {
store_name: this.shadowRoot.getElementById("storeName")?.value || "",
bot_name: this.shadowRoot.getElementById("botName")?.value || "",
store_address: this.shadowRoot.getElementById("storeAddress")?.value || "",
store_phone: this.shadowRoot.getElementById("storePhone")?.value || "",
delivery_enabled: this.settings.delivery_enabled,
delivery_days: this.settings.delivery_days,
delivery_hours_start: this.shadowRoot.getElementById("deliveryStart")?.value || "09:00",
delivery_hours_end: this.shadowRoot.getElementById("deliveryEnd")?.value || "18:00",
delivery_min_order: parseFloat(this.shadowRoot.getElementById("deliveryMinOrder")?.value) || 0,
pickup_enabled: this.settings.pickup_enabled,
pickup_days: this.settings.pickup_days,
pickup_hours_start: this.shadowRoot.getElementById("pickupStart")?.value || "08:00",
pickup_hours_end: this.shadowRoot.getElementById("pickupEnd")?.value || "20:00",
delivery_min_order: parseFloat(this.shadowRoot.getElementById("deliveryMinOrder")?.value) || 0,
schedule,
};
// Update settings with form values so they persist through render
// Update settings with form values
this.settings = { ...this.settings, ...data };
this.saving = true;

View File

@@ -7,6 +7,7 @@ export function connectSSE() {
es.addEventListener("conversation.upsert", (e) => emit("conversation:upsert", JSON.parse(e.data)));
es.addEventListener("run.created", (e) => emit("run:created", JSON.parse(e.data)));
es.addEventListener("takeover.created", (e) => emit("takeover:created", JSON.parse(e.data)));
es.addEventListener("order.created", (e) => emit("order:created", JSON.parse(e.data)));
es.onerror = () => emit("sse:status", { ok: false });

View File

@@ -19,6 +19,7 @@ export async function getSettings({ tenantId }) {
pickup_enabled, pickup_days,
pickup_hours_start::text as pickup_hours_start,
pickup_hours_end::text as pickup_hours_end,
schedule,
created_at, updated_at
FROM tenant_settings
WHERE tenant_id = $1
@@ -46,15 +47,17 @@ export async function upsertSettings({ tenantId, settings }) {
pickup_days,
pickup_hours_start,
pickup_hours_end,
schedule,
} = 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
pickup_enabled, pickup_days, pickup_hours_start, pickup_hours_end,
schedule
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
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),
@@ -69,6 +72,7 @@ export async function upsertSettings({ tenantId, settings }) {
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_end = COALESCE(EXCLUDED.pickup_hours_end, tenant_settings.pickup_hours_end),
schedule = COALESCE(EXCLUDED.schedule, tenant_settings.schedule),
updated_at = NOW()
RETURNING
id, tenant_id,
@@ -80,6 +84,7 @@ export async function upsertSettings({ tenantId, settings }) {
pickup_enabled, pickup_days,
pickup_hours_start::text as pickup_hours_start,
pickup_hours_end::text as pickup_hours_end,
schedule,
created_at, updated_at
`;
@@ -98,6 +103,7 @@ export async function upsertSettings({ tenantId, settings }) {
pickup_days || null,
pickup_hours_start || null,
pickup_hours_end || null,
schedule ? JSON.stringify(schedule) : null,
];
const { rows } = await pool.query(sql, params);
@@ -105,6 +111,64 @@ export async function upsertSettings({ tenantId, settings }) {
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"
*/
function formatScheduleHours(scheduleType, enabled) {
if (!enabled || !scheduleType || typeof scheduleType !== "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"
};
// Agrupar días por horario
const groups = {};
for (const day of dayOrder) {
const slot = scheduleType[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: [] };
}
groups[key].days.push(day);
}
if (Object.keys(groups).length === 0) return "";
// Formatear cada grupo
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(", ");
}
} 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 parts.join(", ");
}
/**
* Obtiene la configuración formateada para usar en prompts (storeConfig)
*/
@@ -121,39 +185,43 @@ export async function getStoreConfig({ tenantId }) {
phone: "",
deliveryHours: "",
pickupHours: "",
schedule: null,
};
}
// Formatear horarios para mostrar
const formatHours = (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}`;
};
const schedule = settings.schedule || {};
const deliveryHours = formatHours(
settings.delivery_enabled,
settings.delivery_days,
settings.delivery_hours_start,
settings.delivery_hours_end
);
// 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
);
}
const pickupHours = formatHours(
settings.pickup_enabled,
settings.pickup_days,
settings.pickup_hours_start,
settings.pickup_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
// Combinar horarios para store_hours (usa pickup como horario de tienda)
let storeHours = "";
if (settings.pickup_enabled && settings.pickup_days) {
storeHours = `${settings.pickup_days.split(",").join(", ")} ${settings.pickup_hours_start?.slice(0,5) || ""}-${settings.pickup_hours_end?.slice(0,5) || ""}`;
if (settings.pickup_enabled) {
storeHours = pickupHours;
}
return {
@@ -166,5 +234,24 @@ export async function getStoreConfig({ tenantId }) {
pickupHours,
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,
};
}
/**
* 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

@@ -3,6 +3,22 @@ import { getSettings, upsertSettings, getStoreConfig } from "../db/settingsRepo.
// Días de la semana para validación
const VALID_DAYS = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
/**
* Genera schedule por defecto con horarios uniformes
*/
function createDefaultSchedule() {
const defaultDays = ["lun", "mar", "mie", "jue", "vie", "sab"];
const delivery = {};
const pickup = {};
for (const day of defaultDays) {
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
*/
@@ -25,10 +41,17 @@ export async function handleGetSettings({ tenantId }) {
pickup_days: "lun,mar,mie,jue,vie,sab",
pickup_hours_start: "08:00",
pickup_hours_end: "20:00",
schedule: createDefaultSchedule(),
is_default: true,
};
}
// Si no tiene schedule, generar desde datos legacy
let schedule = settings.schedule;
if (!schedule || Object.keys(schedule).length === 0) {
schedule = buildScheduleFromLegacy(settings);
}
return {
...settings,
// Formatear horarios TIME a HH:MM
@@ -36,10 +59,121 @@ export async function handleGetSettings({ tenantId }) {
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_end: settings.pickup_hours_end?.slice(0, 5) || "20:00",
schedule,
is_default: false,
};
}
/**
* Construye schedule desde datos legacy
*/
function buildScheduleFromLegacy(settings) {
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 end = settings.pickup_hours_end?.slice(0, 5) || "20:00";
for (const day of days) {
if (VALID_DAYS.includes(day)) {
schedule.pickup[day] = { start, end };
}
}
}
return schedule;
}
/**
* Valida la estructura del schedule
*/
function validateSchedule(schedule) {
if (!schedule || typeof schedule !== "object") return;
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
for (const type of ["delivery", "pickup"]) {
const typeSchedule = schedule[type];
if (!typeSchedule || typeof typeSchedule !== "object") 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) {
throw new Error(`Invalid slot format for ${type}.${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}`);
}
}
}
}
/**
* Sincroniza campos legacy desde schedule
*/
function syncLegacyFromSchedule(settings) {
const schedule = settings.schedule;
if (!schedule) return;
// Sincronizar delivery
if (schedule.delivery) {
const deliveryDays = Object.keys(schedule.delivery).filter(d => schedule.delivery[d] !== null);
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 {
settings.pickup_days = "";
}
}
}
/**
* Guarda la configuración del tenant
*/
@@ -52,41 +186,48 @@ export async function handleSaveSettings({ tenantId, settings }) {
throw new Error("bot_name is required");
}
// Validar días
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}`);
// Validar schedule si viene
if (settings.schedule) {
validateSchedule(settings.schedule);
// Sincronizar campos legacy desde schedule
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(",");
}
settings.delivery_days = days.join(",");
}
if (settings.pickup_days) {
const days = settings.pickup_days.split(",").map(d => d.trim().toLowerCase());
for (const day of days) {
if (!VALID_DAYS.includes(day)) {
throw new Error(`Invalid pickup day: ${day}`);
if (settings.pickup_days) {
const days = settings.pickup_days.split(",").map(d => d.trim().toLowerCase());
for (const day of days) {
if (!VALID_DAYS.includes(day)) {
throw new Error(`Invalid pickup day: ${day}`);
}
}
settings.pickup_days = days.join(",");
}
settings.pickup_days = days.join(",");
}
// Validar horarios
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)) {
throw new Error("Invalid pickup_hours_start format (use HH:MM)");
}
if (settings.pickup_hours_end && !timeRegex.test(settings.pickup_hours_end)) {
throw new Error("Invalid pickup_hours_end format (use HH:MM)");
// Validar horarios legacy
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)) {
throw new Error("Invalid pickup_hours_start format (use HH:MM)");
}
if (settings.pickup_hours_end && !timeRegex.test(settings.pickup_hours_end)) {
throw new Error("Invalid pickup_hours_end format (use HH:MM)");
}
}
const result = await upsertSettings({ tenantId, settings });

View File

@@ -258,16 +258,27 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
...baseAddress,
phone: baseAddress.phone || phoneFromWa,
};
// Obtener shipping_method y payment_method del contexto (preferir decision que es el resultado del turn)
const shippingMethod = decision?.context_patch?.shipping_method || reducedContext?.shipping_method || null;
const paymentMethod = decision?.context_patch?.payment_method || reducedContext?.payment_method || null;
const order = await createOrder({
tenantId,
wooCustomerId: externalCustomerId,
basket: basketToUse,
address: addressWithPhone,
shippingMethod,
paymentMethod,
run_id: null,
});
actionPatch.woo_order_id = order?.id || null;
actionPatch.order_total = calcOrderTotal(order);
newTools.push({ type: "create_order", ok: true, order_id: order?.id || null });
// Notificar via SSE para actualizar la pantalla de pedidos
sseSend("order.created", {
order_id: order?.id,
chat_id,
shipping_method: shippingMethod,
});
} else if (act.type === "update_order") {
const baseAddrUpd = reducedContext?.delivery_address || reducedContext?.address || {};
const phoneFromWaUpd = from?.replace(/@.*$/, "")?.replace(/[^0-9]/g, "") || "";

View File

@@ -333,13 +333,103 @@ async function handleRecommendIntent({ tenantId, text, nlu, currentOrder, audit
return null;
}
/**
* Detecta si el usuario pregunta por el total del carrito actual
*/
function isCartTotalQuery(nlu) {
const query = nlu?.entities?.product_query || "";
const q = query.trim().toLowerCase();
// Patrones que indican consulta sobre el carrito actual
const cartKeywords = [
"todo", "el total", "total", "mi pedido", "el pedido", "precio total",
"lo que tengo", "lo que llevo", "lo que estoy pidiendo", "lo que pedí",
"en el carrito", "del carrito", "mi carrito", "el carrito",
"lo que voy", "hasta ahora", "hasta el momento",
];
// Si la query contiene alguna de estas frases, es consulta del carrito
if (cartKeywords.some(kw => q.includes(kw))) {
return true;
}
// Patrones regex adicionales
const patterns = [
/^cu[aá]nto (es|sale|cuesta|est[aá])/i,
/^precio/i,
];
return patterns.some(p => p.test(q));
}
/**
* Maneja price_query
*/
async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) {
// Si pregunta por el total del carrito
if (isCartTotalQuery(nlu) || !nlu?.entities?.product_query) {
const cartItems = currentOrder?.cart || [];
if (cartItems.length === 0) {
return {
plan: {
reply: "Tu carrito está vacío. ¿Qué te gustaría agregar?",
next_state: ConversationState.CART,
intent: "price_query",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// Calcular y mostrar el total
let total = 0;
const lines = cartItems.map(item => {
const itemTotal = (item.price || 0) * (item.qty || 0);
total += itemTotal;
const unitStr = item.unit === "unit" ? "" : " kg";
return `${item.name}: ${item.qty}${unitStr} x $${item.price} = $${itemTotal.toLocaleString("es-AR")}`;
});
const reply = `Tu pedido actual:\n\n${lines.join("\n")}\n\n💰 Total: $${total.toLocaleString("es-AR")}\n\n¿Algo más?`;
return {
plan: {
reply,
next_state: ConversationState.CART,
intent: "price_query",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
const productQueries = extractProductQueries(nlu);
if (productQueries.length === 0) {
// Si no hay query pero hay carrito, mostrar el carrito
const cartItems = currentOrder?.cart || [];
if (cartItems.length > 0) {
let total = 0;
const lines = cartItems.map(item => {
const itemTotal = (item.price || 0) * (item.qty || 0);
total += itemTotal;
const unitStr = item.unit === "unit" ? "" : " kg";
return `${item.name}: ${item.qty}${unitStr} x $${item.price} = $${itemTotal.toLocaleString("es-AR")}`;
});
const reply = `Tu pedido actual:\n\n${lines.join("\n")}\n\n💰 Total: $${total.toLocaleString("es-AR")}\n\n¿Querés saber el precio de algún producto específico?`;
return {
plan: {
reply,
next_state: ConversationState.CART,
intent: "price_query",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
return {
plan: {
reply: "¿De qué producto querés saber el precio?",

View File

@@ -354,7 +354,7 @@ function processStrongMatch({ updatedOrder, pendingItem, best, audit }) {
const displayUnit = normalizeUnit(best.sell_unit) || inferDefaultUnit({ name: best.name, categories: best.categories });
const hasQty = pendingItem.requested_qty != null && pendingItem.requested_qty > 0;
const needsQuantity = displayUnit !== "unit" && !hasQty;
const autoSelectedOrder = updatePendingItem(updatedOrder, pendingItem.id, {
selected_woo_id: best.woo_product_id,
selected_name: best.name,

View File

@@ -25,25 +25,17 @@ function isDeliveryInfoQuestion(text) {
}
/**
* Formatea los días de entrega para mostrar
* Detecta si el usuario pregunta por horarios de retiro
*/
function formatDeliveryDays(daysStr) {
if (!daysStr) return null;
const dayMap = {
"lun": "Lunes", "mar": "Martes", "mie": "Miércoles", "mié": "Miércoles",
"jue": "Jueves", "vie": "Viernes", "sab": "Sábado", "sáb": "Sábado", "dom": "Domingo",
};
const days = daysStr.split(",").map(d => d.trim().toLowerCase());
const formatted = days.map(d => dayMap[d] || d).filter(Boolean);
if (formatted.length === 0) return null;
if (formatted.length === 1) return formatted[0];
// "Lunes, Martes, Miércoles y Jueves"
const last = formatted.pop();
return `${formatted.join(", ")} y ${last}`;
function isPickupInfoQuestion(text) {
const t = String(text || "").toLowerCase();
const patterns = [
/horario.*(retir|buscar|pasar)/i,
/cu[aá]ndo.*(retir|buscar|pasar)/i,
/a\s+qu[eé]\s+hora.*(retir|buscar)/i,
/d[ií]as?.*(retir|buscar)/i,
];
return patterns.some(p => p.test(t));
}
/**
@@ -71,19 +63,17 @@ export async function handleWaitingState({ tenantId, text, nlu, order, audit, st
// Preguntas sobre horarios/días de entrega
if (isDeliveryInfoQuestion(text)) {
const deliveryDays = formatDeliveryDays(storeConfig.delivery_days);
const startHour = storeConfig.delivery_hours_start?.slice(0, 5);
const endHour = storeConfig.delivery_hours_end?.slice(0, 5);
// Usar deliveryHours que ya viene formateado desde getStoreConfig
// (agrupa días con mismos horarios: "Lunes a Viernes de 9:00 a 14:00, Sábado de 9:00 a 13:00")
const deliveryHours = storeConfig.deliveryHours;
let reply = "";
if (deliveryDays) {
reply = `Hacemos entregas los días ${deliveryDays}`;
if (startHour && endHour) {
reply += ` de ${startHour} a ${endHour}`;
}
reply += ". ";
if (deliveryHours && deliveryHours !== "No disponible") {
reply = `Hacemos entregas: ${deliveryHours}. `;
} else if (storeConfig.deliveryEnabled === false) {
reply = "Por el momento no ofrecemos delivery. ";
} else {
reply = "Todavía no tengo configurado los días de entrega. ";
reply = "Todavía no tengo configurados los horarios de entrega. ";
}
reply += "Tu pedido ya está en proceso, avisame cualquier cosa.";
@@ -100,6 +90,33 @@ export async function handleWaitingState({ tenantId, text, nlu, order, audit, st
};
}
// Preguntas sobre horarios de retiro
if (isPickupInfoQuestion(text)) {
const pickupHours = storeConfig.pickupHours;
let reply = "";
if (pickupHours && pickupHours !== "No disponible") {
reply = `Podés retirar: ${pickupHours}. `;
} else if (storeConfig.pickupEnabled === false) {
reply = "Por el momento no ofrecemos retiro en tienda. ";
} else {
reply = "Todavía no tengo configurados los horarios de retiro. ";
}
reply += "Tu pedido ya está en proceso, avisame cualquier cosa.";
return {
plan: {
reply,
next_state: ConversationState.WAITING_WEBHOOKS,
intent: "pickup_info",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// Default
const reply = currentOrder.payment_type === "link"
? "Tu pedido está en proceso. Avisame si necesitás algo más o esperá la confirmación de pago."

View File

@@ -188,7 +188,7 @@ function mapAddress(address) {
};
}
export async function createOrder({ tenantId, wooCustomerId, basket, address, run_id }) {
export async function createOrder({ tenantId, wooCustomerId, basket, address, shippingMethod, paymentMethod, run_id }) {
const lockKey = `${tenantId}:${wooCustomerId || "anon"}`;
return withLock(lockKey, async () => {
const client = await getWooClient({ tenantId });
@@ -200,9 +200,15 @@ export async function createOrder({ tenantId, wooCustomerId, basket, address, ru
customer_id: wooCustomerId || undefined,
line_items: lineItems,
...(addr ? { billing: addr, shipping: addr } : {}),
// Si es cash, usar payment_method "cod" (Cash On Delivery) de WooCommerce
...(paymentMethod === "cash" ? { payment_method: "cod", payment_method_title: "Efectivo" } : {}),
meta_data: [
{ key: "source", value: "whatsapp" },
...(run_id ? [{ key: "run_id", value: run_id }] : []),
// Guardar shipping_method como metadata para poder leerlo después
...(shippingMethod ? [{ key: "shipping_method", value: shippingMethod }] : []),
// Guardar payment_method como metadata también
...(paymentMethod ? [{ key: "payment_method_wa", value: paymentMethod }] : []),
],
};
const url = `${client.base}/orders`;
@@ -313,17 +319,34 @@ export async function listRecentOrders({ tenantId, limit = 20 }) {
const source = sourceMeta?.value || "web";
// Método de envío (shipping)
// 1. Primero intentar leer de metadata (para pedidos de WhatsApp)
const metaShippingMethod = order.meta_data?.find(m => m.key === "shipping_method")?.value || null;
// 2. Fallback a shipping_lines de WooCommerce (para pedidos web)
const shippingLines = order.shipping_lines || [];
const shippingMethod = shippingLines[0]?.method_title || shippingLines[0]?.method_id || null;
const isDelivery = shippingMethod ?
!shippingMethod.toLowerCase().includes("retiro") &&
!shippingMethod.toLowerCase().includes("pickup") &&
!shippingMethod.toLowerCase().includes("local") : false;
const wooShippingMethod = shippingLines[0]?.method_title || shippingLines[0]?.method_id || null;
// 3. Usar metadata si existe, sino WooCommerce
const shippingMethod = metaShippingMethod || wooShippingMethod;
// 4. Determinar isDelivery
let isDelivery = false;
if (metaShippingMethod) {
// Si viene de metadata, "delivery" = true, "pickup" = false
isDelivery = metaShippingMethod === "delivery";
} else if (wooShippingMethod) {
// Si viene de WooCommerce, detectar por nombre
isDelivery = !wooShippingMethod.toLowerCase().includes("retiro") &&
!wooShippingMethod.toLowerCase().includes("pickup") &&
!wooShippingMethod.toLowerCase().includes("local");
}
// Método de pago
// 1. Primero intentar leer de metadata (para pedidos de WhatsApp)
const metaPaymentMethod = order.meta_data?.find(m => m.key === "payment_method_wa")?.value || null;
// 2. Luego de los campos estándar de WooCommerce
const paymentMethod = order.payment_method || null;
const paymentMethodTitle = order.payment_method_title || null;
const isCash = paymentMethod === "cod" ||
// 3. Determinar si es cash
const isCash = metaPaymentMethod === "cash" ||
paymentMethod === "cod" ||
paymentMethodTitle?.toLowerCase().includes("efectivo") ||
paymentMethodTitle?.toLowerCase().includes("cash");

View File

@@ -72,6 +72,51 @@ function parsePrice(p) {
return Number.isFinite(n) ? n : null;
}
/**
* Decodifica HTML entities comunes (WooCommerce las usa en nombres de productos)
*/
function decodeHtmlEntities(str) {
if (!str || typeof str !== "string") return str;
// Solo procesar si hay entidades
if (!str.includes("&")) return str;
const entities = {
"&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&#39;": "'", "&apos;": "'",
"&nbsp;": " ", "&iexcl;": "¡", "&cent;": "¢", "&pound;": "£", "&curren;": "¤",
"&yen;": "¥", "&brvbar;": "¦", "&sect;": "§", "&uml;": "¨", "&copy;": "©",
"&ordf;": "ª", "&laquo;": "«", "&not;": "¬", "&shy;": "\u00AD", "&reg;": "®",
"&macr;": "¯", "&deg;": "°", "&plusmn;": "±", "&sup2;": "²", "&sup3;": "³",
"&acute;": "´", "&micro;": "µ", "&para;": "¶", "&middot;": "·", "&cedil;": "¸",
"&sup1;": "¹", "&ordm;": "º", "&raquo;": "»", "&frac14;": "¼", "&frac12;": "½",
"&frac34;": "¾", "&iquest;": "¿", "&Agrave;": "À", "&Aacute;": "Á", "&Acirc;": "Â",
"&Atilde;": "Ã", "&Auml;": "Ä", "&Aring;": "Å", "&AElig;": "Æ", "&Ccedil;": "Ç",
"&Egrave;": "È", "&Eacute;": "É", "&Ecirc;": "Ê", "&Euml;": "Ë", "&Igrave;": "Ì",
"&Iacute;": "Í", "&Icirc;": "Î", "&Iuml;": "Ï", "&ETH;": "Ð", "&Ntilde;": "Ñ",
"&Ograve;": "Ò", "&Oacute;": "Ó", "&Ocirc;": "Ô", "&Otilde;": "Õ", "&Ouml;": "Ö",
"&times;": "×", "&Oslash;": "Ø", "&Ugrave;": "Ù", "&Uacute;": "Ú", "&Ucirc;": "Û",
"&Uuml;": "Ü", "&Yacute;": "Ý", "&THORN;": "Þ", "&szlig;": "ß", "&agrave;": "à",
"&aacute;": "á", "&acirc;": "â", "&atilde;": "ã", "&auml;": "ä", "&aring;": "å",
"&aelig;": "æ", "&ccedil;": "ç", "&egrave;": "è", "&eacute;": "é", "&ecirc;": "ê",
"&euml;": "ë", "&igrave;": "ì", "&iacute;": "í", "&icirc;": "î", "&iuml;": "ï",
"&eth;": "ð", "&ntilde;": "ñ", "&ograve;": "ò", "&oacute;": "ó", "&ocirc;": "ô",
"&otilde;": "õ", "&ouml;": "ö", "&divide;": "÷", "&oslash;": "ø", "&ugrave;": "ù",
"&uacute;": "ú", "&ucirc;": "û", "&uuml;": "ü", "&yacute;": "ý", "&thorn;": "þ",
"&yuml;": "ÿ",
};
let result = str;
// Reemplazar entities nombradas usando iteración (más robusto que regex)
for (const [entity, char] of Object.entries(entities)) {
if (result.includes(entity)) {
result = result.split(entity).join(char);
}
}
// Reemplazar entities numéricas (&#123; o &#x7B;)
result = result.replace(/&#(\d+);/g, (_, num) => String.fromCharCode(parseInt(num, 10)));
result = result.replace(/&#x([0-9a-fA-F]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
return result;
}
function normalizeAttributes(attrs) {
const out = {};
if (!Array.isArray(attrs)) return out;
@@ -90,7 +135,7 @@ function normalizeWooProduct(p) {
woo_id: p?.id,
type: p?.type || "simple",
parent_id: p?.parent_id || null,
name: p?.name || "",
name: decodeHtmlEntities(p?.name || ""),
slug: p?.slug || null,
status: p?.status || null,
catalog_visibility: p?.catalog_visibility || null,
@@ -114,7 +159,7 @@ function snapshotRowToItem(row) {
const raw = row?.raw || {};
return {
woo_product_id: row?.woo_id,
name: row?.name || "",
name: decodeHtmlEntities(row?.name || ""),
sku: raw?.SKU || raw?.sku || row?.slug || null,
slug: row?.slug || null,
price: row?.price_current != null ? Number(row.price_current) : null,