corregidos bugs de: ret, vs delivery, efectivo vs link, charsets, price query
This commit is contained in:
@@ -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"}
|
|
||||||
44
db/migrations/20260126100000_schedule_jsonb.sql
Normal file
44
db/migrations/20260126100000_schedule_jsonb.sql
Normal 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;
|
||||||
@@ -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();
|
this.loadOrders();
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
this._unsubRouter?.();
|
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() {
|
async loadOrders() {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { api } from "../lib/api.js";
|
import { api } from "../lib/api.js";
|
||||||
|
|
||||||
const DAYS = [
|
const DAYS = [
|
||||||
{ id: "lun", label: "Lun" },
|
{ id: "lun", label: "Lunes", short: "Lun" },
|
||||||
{ id: "mar", label: "Mar" },
|
{ id: "mar", label: "Martes", short: "Mar" },
|
||||||
{ id: "mie", label: "Mié" },
|
{ id: "mie", label: "Miércoles", short: "Mié" },
|
||||||
{ id: "jue", label: "Jue" },
|
{ id: "jue", label: "Jueves", short: "Jue" },
|
||||||
{ id: "vie", label: "Vie" },
|
{ id: "vie", label: "Viernes", short: "Vie" },
|
||||||
{ id: "sab", label: "Sáb" },
|
{ id: "sab", label: "Sábado", short: "Sáb" },
|
||||||
{ id: "dom", label: "Dom" },
|
{ id: "dom", label: "Domingo", short: "Dom" },
|
||||||
];
|
];
|
||||||
|
|
||||||
class SettingsCrud extends HTMLElement {
|
class SettingsCrud extends HTMLElement {
|
||||||
@@ -50,11 +50,11 @@ class SettingsCrud extends HTMLElement {
|
|||||||
button.secondary { background:#253245; }
|
button.secondary { background:#253245; }
|
||||||
button.secondary:hover { background:#2d3e52; }
|
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 {
|
.toggle {
|
||||||
position:relative; width:48px; height:26px;
|
position:relative; width:48px; height:26px;
|
||||||
background:#253245; border-radius:13px; cursor:pointer;
|
background:#253245; border-radius:13px; cursor:pointer;
|
||||||
transition:background .2s;
|
transition:background .2s; flex-shrink:0;
|
||||||
}
|
}
|
||||||
.toggle.active { background:#1f6feb; }
|
.toggle.active { background:#1f6feb; }
|
||||||
.toggle::after {
|
.toggle::after {
|
||||||
@@ -65,20 +65,38 @@ class SettingsCrud extends HTMLElement {
|
|||||||
.toggle.active::after { transform:translateX(22px); }
|
.toggle.active::after { transform:translateX(22px); }
|
||||||
.toggle-label { font-size:14px; color:#e7eef7; }
|
.toggle-label { font-size:14px; color:#e7eef7; }
|
||||||
|
|
||||||
.days-selector { display:flex; gap:6px; flex-wrap:wrap; }
|
/* Schedule grid */
|
||||||
.day-btn {
|
.schedule-grid { display:flex; flex-direction:column; gap:8px; }
|
||||||
padding:8px 12px; border-radius:6px; font-size:12px; font-weight:600;
|
.schedule-row {
|
||||||
background:#253245; color:#8aa0b5; border:none; cursor:pointer;
|
display:grid;
|
||||||
transition:all .15s;
|
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; }
|
.schedule-row.disabled { opacity:0.4; }
|
||||||
.day-btn.selected { background:#1f6feb; color:#fff; }
|
.day-label { font-size:13px; color:#e7eef7; font-weight:500; }
|
||||||
|
.day-toggle {
|
||||||
.hours-row { display:flex; align-items:center; gap:12px; margin-top:12px; }
|
width:32px; height:18px; background:#253245; border-radius:9px;
|
||||||
.hours-row input { width:90px; text-align:center; font-family:monospace; font-size:15px; letter-spacing:1px; }
|
cursor:pointer; position:relative; transition:background .2s;
|
||||||
.hours-row input::placeholder { color:#6c7a89; }
|
}
|
||||||
.hours-row span { color:#8aa0b5; }
|
.day-toggle.active { background:#2ecc71; }
|
||||||
.hours-row .hour-hint { font-size:11px; color:#6c7a89; margin-left:8px; }
|
.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; }
|
.actions { display:flex; gap:12px; margin-top:24px; }
|
||||||
.loading { text-align:center; padding:60px; color:#8aa0b5; }
|
.loading { text-align:center; padding:60px; color:#8aa0b5; }
|
||||||
@@ -93,6 +111,8 @@ class SettingsCrud extends HTMLElement {
|
|||||||
color:#e74c3c; padding:12px 16px; border-radius:8px;
|
color:#e74c3c; padding:12px 16px; border-radius:8px;
|
||||||
margin-bottom:16px; font-size:14px;
|
margin-bottom:16px; font-size:14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.min-order-field { margin-top:16px; padding-top:16px; border-top:1px solid #1e2a3a; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -114,6 +134,10 @@ class SettingsCrud extends HTMLElement {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this.settings = await api.getSettings();
|
this.settings = await api.getSettings();
|
||||||
|
// Asegurar que schedule existe
|
||||||
|
if (!this.settings.schedule) {
|
||||||
|
this.settings.schedule = { delivery: {}, pickup: {} };
|
||||||
|
}
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.render();
|
this.render();
|
||||||
} catch (e) {
|
} 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() {
|
render() {
|
||||||
const content = this.shadowRoot.getElementById("content");
|
const content = this.shadowRoot.getElementById("content");
|
||||||
|
|
||||||
@@ -137,8 +215,6 @@ class SettingsCrud extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const s = this.settings;
|
const s = this.settings;
|
||||||
const deliveryDays = (s.delivery_days || "").split(",").filter(d => d);
|
|
||||||
const pickupDays = (s.pickup_days || "").split(",").filter(d => d);
|
|
||||||
|
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<!-- Info del Negocio -->
|
<!-- Info del Negocio -->
|
||||||
@@ -185,26 +261,13 @@ class SettingsCrud extends HTMLElement {
|
|||||||
<span class="toggle-label">Delivery habilitado</span>
|
<span class="toggle-label">Delivery habilitado</span>
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label>Días disponibles</label>
|
<label>Pedido mínimo para delivery ($)</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>
|
|
||||||
<input type="number" id="deliveryMinOrder" value="${s.delivery_min_order || 0}" min="0" step="100" style="width:150px;" />
|
<input type="number" id="deliveryMinOrder" value="${s.delivery_min_order || 0}" min="0" step="100" style="width:150px;" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -222,23 +285,8 @@ class SettingsCrud extends HTMLElement {
|
|||||||
<span class="toggle-label">Retiro en tienda habilitado</span>
|
<span class="toggle-label">Retiro en tienda habilitado</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="pickupOptions" style="${s.pickup_enabled ? "" : "opacity:0.5;pointer-events:none;"}">
|
<div class="schedule-grid" id="pickupSchedule">
|
||||||
<div class="field">
|
${this.renderScheduleGrid("pickup", s.pickup_enabled)}
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -266,36 +314,43 @@ class SettingsCrud extends HTMLElement {
|
|||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delivery days
|
// Day toggles
|
||||||
this.shadowRoot.querySelectorAll("#deliveryDays .day-btn").forEach(btn => {
|
this.shadowRoot.querySelectorAll(".day-toggle").forEach(toggle => {
|
||||||
btn.addEventListener("click", () => {
|
toggle.addEventListener("click", () => {
|
||||||
const day = btn.dataset.day;
|
const type = toggle.dataset.type;
|
||||||
let days = (this.settings.delivery_days || "").split(",").filter(d => d);
|
const day = toggle.dataset.day;
|
||||||
if (days.includes(day)) {
|
const currentSlot = this.getScheduleSlot(type, day);
|
||||||
days = days.filter(d => d !== day);
|
|
||||||
|
if (currentSlot) {
|
||||||
|
// Desactivar día
|
||||||
|
this.setScheduleSlot(type, day, null);
|
||||||
} else {
|
} 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();
|
this.render();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pickup days
|
// Hour inputs - update on blur
|
||||||
this.shadowRoot.querySelectorAll("#pickupDays .day-btn").forEach(btn => {
|
this.shadowRoot.querySelectorAll(".hour-start, .hour-end").forEach(input => {
|
||||||
btn.addEventListener("click", () => {
|
input.addEventListener("blur", () => {
|
||||||
const day = btn.dataset.day;
|
const type = input.dataset.type;
|
||||||
let days = (this.settings.pickup_days || "").split(",").filter(d => d);
|
const day = input.dataset.day;
|
||||||
if (days.includes(day)) {
|
const isStart = input.classList.contains("hour-start");
|
||||||
days = days.filter(d => d !== day);
|
|
||||||
|
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 {
|
} 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.setScheduleSlot(type, day, slot);
|
||||||
this.settings.pickup_days = days.join(",");
|
|
||||||
this.render();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -306,25 +361,43 @@ class SettingsCrud extends HTMLElement {
|
|||||||
this.shadowRoot.getElementById("resetBtn")?.addEventListener("click", () => this.load());
|
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() {
|
async save() {
|
||||||
// Collect form data BEFORE re-rendering
|
// Collect schedule from inputs
|
||||||
|
const schedule = this.collectScheduleFromInputs();
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
store_name: this.shadowRoot.getElementById("storeName")?.value || "",
|
store_name: this.shadowRoot.getElementById("storeName")?.value || "",
|
||||||
bot_name: this.shadowRoot.getElementById("botName")?.value || "",
|
bot_name: this.shadowRoot.getElementById("botName")?.value || "",
|
||||||
store_address: this.shadowRoot.getElementById("storeAddress")?.value || "",
|
store_address: this.shadowRoot.getElementById("storeAddress")?.value || "",
|
||||||
store_phone: this.shadowRoot.getElementById("storePhone")?.value || "",
|
store_phone: this.shadowRoot.getElementById("storePhone")?.value || "",
|
||||||
delivery_enabled: this.settings.delivery_enabled,
|
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_enabled: this.settings.pickup_enabled,
|
||||||
pickup_days: this.settings.pickup_days,
|
delivery_min_order: parseFloat(this.shadowRoot.getElementById("deliveryMinOrder")?.value) || 0,
|
||||||
pickup_hours_start: this.shadowRoot.getElementById("pickupStart")?.value || "08:00",
|
schedule,
|
||||||
pickup_hours_end: this.shadowRoot.getElementById("pickupEnd")?.value || "20:00",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update settings with form values so they persist through render
|
// Update settings with form values
|
||||||
this.settings = { ...this.settings, ...data };
|
this.settings = { ...this.settings, ...data };
|
||||||
|
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export function connectSSE() {
|
|||||||
es.addEventListener("conversation.upsert", (e) => emit("conversation:upsert", JSON.parse(e.data)));
|
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("run.created", (e) => emit("run:created", JSON.parse(e.data)));
|
||||||
es.addEventListener("takeover.created", (e) => emit("takeover: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 });
|
es.onerror = () => emit("sse:status", { ok: false });
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export async function getSettings({ tenantId }) {
|
|||||||
pickup_enabled, pickup_days,
|
pickup_enabled, pickup_days,
|
||||||
pickup_hours_start::text as pickup_hours_start,
|
pickup_hours_start::text as pickup_hours_start,
|
||||||
pickup_hours_end::text as pickup_hours_end,
|
pickup_hours_end::text as pickup_hours_end,
|
||||||
|
schedule,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM tenant_settings
|
FROM tenant_settings
|
||||||
WHERE tenant_id = $1
|
WHERE tenant_id = $1
|
||||||
@@ -46,15 +47,17 @@ export async function upsertSettings({ tenantId, settings }) {
|
|||||||
pickup_days,
|
pickup_days,
|
||||||
pickup_hours_start,
|
pickup_hours_start,
|
||||||
pickup_hours_end,
|
pickup_hours_end,
|
||||||
|
schedule,
|
||||||
} = settings;
|
} = settings;
|
||||||
|
|
||||||
const sql = `
|
const sql = `
|
||||||
INSERT INTO tenant_settings (
|
INSERT INTO tenant_settings (
|
||||||
tenant_id, store_name, bot_name, store_address, store_phone,
|
tenant_id, store_name, bot_name, store_address, store_phone,
|
||||||
delivery_enabled, delivery_days, delivery_hours_start, delivery_hours_end, delivery_min_order,
|
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
|
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||||
store_name = COALESCE(EXCLUDED.store_name, tenant_settings.store_name),
|
store_name = COALESCE(EXCLUDED.store_name, tenant_settings.store_name),
|
||||||
bot_name = COALESCE(EXCLUDED.bot_name, tenant_settings.bot_name),
|
bot_name = COALESCE(EXCLUDED.bot_name, tenant_settings.bot_name),
|
||||||
@@ -69,6 +72,7 @@ export async function upsertSettings({ tenantId, settings }) {
|
|||||||
pickup_days = COALESCE(EXCLUDED.pickup_days, tenant_settings.pickup_days),
|
pickup_days = COALESCE(EXCLUDED.pickup_days, tenant_settings.pickup_days),
|
||||||
pickup_hours_start = COALESCE(EXCLUDED.pickup_hours_start, tenant_settings.pickup_hours_start),
|
pickup_hours_start = COALESCE(EXCLUDED.pickup_hours_start, tenant_settings.pickup_hours_start),
|
||||||
pickup_hours_end = COALESCE(EXCLUDED.pickup_hours_end, tenant_settings.pickup_hours_end),
|
pickup_hours_end = COALESCE(EXCLUDED.pickup_hours_end, tenant_settings.pickup_hours_end),
|
||||||
|
schedule = COALESCE(EXCLUDED.schedule, tenant_settings.schedule),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING
|
RETURNING
|
||||||
id, tenant_id,
|
id, tenant_id,
|
||||||
@@ -80,6 +84,7 @@ export async function upsertSettings({ tenantId, settings }) {
|
|||||||
pickup_enabled, pickup_days,
|
pickup_enabled, pickup_days,
|
||||||
pickup_hours_start::text as pickup_hours_start,
|
pickup_hours_start::text as pickup_hours_start,
|
||||||
pickup_hours_end::text as pickup_hours_end,
|
pickup_hours_end::text as pickup_hours_end,
|
||||||
|
schedule,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -98,6 +103,7 @@ export async function upsertSettings({ tenantId, settings }) {
|
|||||||
pickup_days || null,
|
pickup_days || null,
|
||||||
pickup_hours_start || null,
|
pickup_hours_start || null,
|
||||||
pickup_hours_end || null,
|
pickup_hours_end || null,
|
||||||
|
schedule ? JSON.stringify(schedule) : null,
|
||||||
];
|
];
|
||||||
|
|
||||||
const { rows } = await pool.query(sql, params);
|
const { rows } = await pool.query(sql, params);
|
||||||
@@ -105,6 +111,64 @@ export async function upsertSettings({ tenantId, settings }) {
|
|||||||
return rows[0];
|
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)
|
* Obtiene la configuración formateada para usar en prompts (storeConfig)
|
||||||
*/
|
*/
|
||||||
@@ -121,39 +185,43 @@ export async function getStoreConfig({ tenantId }) {
|
|||||||
phone: "",
|
phone: "",
|
||||||
deliveryHours: "",
|
deliveryHours: "",
|
||||||
pickupHours: "",
|
pickupHours: "",
|
||||||
|
schedule: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Formatear horarios para mostrar
|
const schedule = settings.schedule || {};
|
||||||
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 deliveryHours = formatHours(
|
// Usar nuevo formato schedule si existe, sino legacy
|
||||||
settings.delivery_enabled,
|
let deliveryHours, pickupHours;
|
||||||
settings.delivery_days,
|
|
||||||
settings.delivery_hours_start,
|
if (schedule.delivery && Object.keys(schedule.delivery).length > 0) {
|
||||||
settings.delivery_hours_end
|
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(
|
if (schedule.pickup && Object.keys(schedule.pickup).length > 0) {
|
||||||
settings.pickup_enabled,
|
pickupHours = formatScheduleHours(schedule.pickup, settings.pickup_enabled);
|
||||||
settings.pickup_days,
|
} else {
|
||||||
settings.pickup_hours_start,
|
// Legacy format
|
||||||
settings.pickup_hours_end
|
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 = "";
|
let storeHours = "";
|
||||||
if (settings.pickup_enabled && settings.pickup_days) {
|
if (settings.pickup_enabled) {
|
||||||
storeHours = `${settings.pickup_days.split(",").join(", ")} ${settings.pickup_hours_start?.slice(0,5) || ""}-${settings.pickup_hours_end?.slice(0,5) || ""}`;
|
storeHours = pickupHours;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -166,5 +234,24 @@ export async function getStoreConfig({ tenantId }) {
|
|||||||
pickupHours,
|
pickupHours,
|
||||||
deliveryEnabled: settings.delivery_enabled,
|
deliveryEnabled: settings.delivery_enabled,
|
||||||
pickupEnabled: settings.pickup_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}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,22 @@ import { getSettings, upsertSettings, getStoreConfig } from "../db/settingsRepo.
|
|||||||
// Días de la semana para validación
|
// Días de la semana para validación
|
||||||
const VALID_DAYS = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
|
const VALID_DAYS = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* 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_days: "lun,mar,mie,jue,vie,sab",
|
||||||
pickup_hours_start: "08:00",
|
pickup_hours_start: "08:00",
|
||||||
pickup_hours_end: "20:00",
|
pickup_hours_end: "20:00",
|
||||||
|
schedule: createDefaultSchedule(),
|
||||||
is_default: true,
|
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 {
|
return {
|
||||||
...settings,
|
...settings,
|
||||||
// Formatear horarios TIME a HH:MM
|
// 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",
|
delivery_hours_end: settings.delivery_hours_end?.slice(0, 5) || "18:00",
|
||||||
pickup_hours_start: settings.pickup_hours_start?.slice(0, 5) || "08:00",
|
pickup_hours_start: settings.pickup_hours_start?.slice(0, 5) || "08:00",
|
||||||
pickup_hours_end: settings.pickup_hours_end?.slice(0, 5) || "20:00",
|
pickup_hours_end: settings.pickup_hours_end?.slice(0, 5) || "20:00",
|
||||||
|
schedule,
|
||||||
is_default: false,
|
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
|
* Guarda la configuración del tenant
|
||||||
*/
|
*/
|
||||||
@@ -52,41 +186,48 @@ export async function handleSaveSettings({ tenantId, settings }) {
|
|||||||
throw new Error("bot_name is required");
|
throw new Error("bot_name is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validar días
|
// Validar schedule si viene
|
||||||
if (settings.delivery_days) {
|
if (settings.schedule) {
|
||||||
const days = settings.delivery_days.split(",").map(d => d.trim().toLowerCase());
|
validateSchedule(settings.schedule);
|
||||||
for (const day of days) {
|
// Sincronizar campos legacy desde schedule
|
||||||
if (!VALID_DAYS.includes(day)) {
|
syncLegacyFromSchedule(settings);
|
||||||
throw new Error(`Invalid delivery day: ${day}`);
|
} 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) {
|
if (settings.pickup_days) {
|
||||||
const days = settings.pickup_days.split(",").map(d => d.trim().toLowerCase());
|
const days = settings.pickup_days.split(",").map(d => d.trim().toLowerCase());
|
||||||
for (const day of days) {
|
for (const day of days) {
|
||||||
if (!VALID_DAYS.includes(day)) {
|
if (!VALID_DAYS.includes(day)) {
|
||||||
throw new Error(`Invalid pickup day: ${day}`);
|
throw new Error(`Invalid pickup day: ${day}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
settings.pickup_days = days.join(",");
|
||||||
}
|
}
|
||||||
settings.pickup_days = days.join(",");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validar horarios
|
// Validar horarios legacy
|
||||||
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
|
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
|
||||||
|
|
||||||
if (settings.delivery_hours_start && !timeRegex.test(settings.delivery_hours_start)) {
|
if (settings.delivery_hours_start && !timeRegex.test(settings.delivery_hours_start)) {
|
||||||
throw new Error("Invalid delivery_hours_start format (use HH:MM)");
|
throw new Error("Invalid delivery_hours_start format (use HH:MM)");
|
||||||
}
|
}
|
||||||
if (settings.delivery_hours_end && !timeRegex.test(settings.delivery_hours_end)) {
|
if (settings.delivery_hours_end && !timeRegex.test(settings.delivery_hours_end)) {
|
||||||
throw new Error("Invalid delivery_hours_end format (use HH:MM)");
|
throw new Error("Invalid delivery_hours_end format (use HH:MM)");
|
||||||
}
|
}
|
||||||
if (settings.pickup_hours_start && !timeRegex.test(settings.pickup_hours_start)) {
|
if (settings.pickup_hours_start && !timeRegex.test(settings.pickup_hours_start)) {
|
||||||
throw new Error("Invalid pickup_hours_start format (use HH:MM)");
|
throw new Error("Invalid pickup_hours_start format (use HH:MM)");
|
||||||
}
|
}
|
||||||
if (settings.pickup_hours_end && !timeRegex.test(settings.pickup_hours_end)) {
|
if (settings.pickup_hours_end && !timeRegex.test(settings.pickup_hours_end)) {
|
||||||
throw new Error("Invalid pickup_hours_end format (use HH:MM)");
|
throw new Error("Invalid pickup_hours_end format (use HH:MM)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await upsertSettings({ tenantId, settings });
|
const result = await upsertSettings({ tenantId, settings });
|
||||||
|
|||||||
@@ -258,16 +258,27 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
|||||||
...baseAddress,
|
...baseAddress,
|
||||||
phone: baseAddress.phone || phoneFromWa,
|
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({
|
const order = await createOrder({
|
||||||
tenantId,
|
tenantId,
|
||||||
wooCustomerId: externalCustomerId,
|
wooCustomerId: externalCustomerId,
|
||||||
basket: basketToUse,
|
basket: basketToUse,
|
||||||
address: addressWithPhone,
|
address: addressWithPhone,
|
||||||
|
shippingMethod,
|
||||||
|
paymentMethod,
|
||||||
run_id: null,
|
run_id: null,
|
||||||
});
|
});
|
||||||
actionPatch.woo_order_id = order?.id || null;
|
actionPatch.woo_order_id = order?.id || null;
|
||||||
actionPatch.order_total = calcOrderTotal(order);
|
actionPatch.order_total = calcOrderTotal(order);
|
||||||
newTools.push({ type: "create_order", ok: true, order_id: order?.id || null });
|
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") {
|
} else if (act.type === "update_order") {
|
||||||
const baseAddrUpd = reducedContext?.delivery_address || reducedContext?.address || {};
|
const baseAddrUpd = reducedContext?.delivery_address || reducedContext?.address || {};
|
||||||
const phoneFromWaUpd = from?.replace(/@.*$/, "")?.replace(/[^0-9]/g, "") || "";
|
const phoneFromWaUpd = from?.replace(/@.*$/, "")?.replace(/[^0-9]/g, "") || "";
|
||||||
|
|||||||
@@ -333,13 +333,103 @@ async function handleRecommendIntent({ tenantId, text, nlu, currentOrder, audit
|
|||||||
return null;
|
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
|
* Maneja price_query
|
||||||
*/
|
*/
|
||||||
async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) {
|
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);
|
const productQueries = extractProductQueries(nlu);
|
||||||
|
|
||||||
if (productQueries.length === 0) {
|
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 {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
reply: "¿De qué producto querés saber el precio?",
|
reply: "¿De qué producto querés saber el precio?",
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ function processStrongMatch({ updatedOrder, pendingItem, best, audit }) {
|
|||||||
const displayUnit = normalizeUnit(best.sell_unit) || inferDefaultUnit({ name: best.name, categories: best.categories });
|
const displayUnit = normalizeUnit(best.sell_unit) || inferDefaultUnit({ name: best.name, categories: best.categories });
|
||||||
const hasQty = pendingItem.requested_qty != null && pendingItem.requested_qty > 0;
|
const hasQty = pendingItem.requested_qty != null && pendingItem.requested_qty > 0;
|
||||||
const needsQuantity = displayUnit !== "unit" && !hasQty;
|
const needsQuantity = displayUnit !== "unit" && !hasQty;
|
||||||
|
|
||||||
const autoSelectedOrder = updatePendingItem(updatedOrder, pendingItem.id, {
|
const autoSelectedOrder = updatePendingItem(updatedOrder, pendingItem.id, {
|
||||||
selected_woo_id: best.woo_product_id,
|
selected_woo_id: best.woo_product_id,
|
||||||
selected_name: best.name,
|
selected_name: best.name,
|
||||||
|
|||||||
@@ -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) {
|
function isPickupInfoQuestion(text) {
|
||||||
if (!daysStr) return null;
|
const t = String(text || "").toLowerCase();
|
||||||
|
const patterns = [
|
||||||
const dayMap = {
|
/horario.*(retir|buscar|pasar)/i,
|
||||||
"lun": "Lunes", "mar": "Martes", "mie": "Miércoles", "mié": "Miércoles",
|
/cu[aá]ndo.*(retir|buscar|pasar)/i,
|
||||||
"jue": "Jueves", "vie": "Viernes", "sab": "Sábado", "sáb": "Sábado", "dom": "Domingo",
|
/a\s+qu[eé]\s+hora.*(retir|buscar)/i,
|
||||||
};
|
/d[ií]as?.*(retir|buscar)/i,
|
||||||
|
];
|
||||||
const days = daysStr.split(",").map(d => d.trim().toLowerCase());
|
return patterns.some(p => p.test(t));
|
||||||
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}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,19 +63,17 @@ export async function handleWaitingState({ tenantId, text, nlu, order, audit, st
|
|||||||
|
|
||||||
// Preguntas sobre horarios/días de entrega
|
// Preguntas sobre horarios/días de entrega
|
||||||
if (isDeliveryInfoQuestion(text)) {
|
if (isDeliveryInfoQuestion(text)) {
|
||||||
const deliveryDays = formatDeliveryDays(storeConfig.delivery_days);
|
// Usar deliveryHours que ya viene formateado desde getStoreConfig
|
||||||
const startHour = storeConfig.delivery_hours_start?.slice(0, 5);
|
// (agrupa días con mismos horarios: "Lunes a Viernes de 9:00 a 14:00, Sábado de 9:00 a 13:00")
|
||||||
const endHour = storeConfig.delivery_hours_end?.slice(0, 5);
|
const deliveryHours = storeConfig.deliveryHours;
|
||||||
|
|
||||||
let reply = "";
|
let reply = "";
|
||||||
if (deliveryDays) {
|
if (deliveryHours && deliveryHours !== "No disponible") {
|
||||||
reply = `Hacemos entregas los días ${deliveryDays}`;
|
reply = `Hacemos entregas: ${deliveryHours}. `;
|
||||||
if (startHour && endHour) {
|
} else if (storeConfig.deliveryEnabled === false) {
|
||||||
reply += ` de ${startHour} a ${endHour}`;
|
reply = "Por el momento no ofrecemos delivery. ";
|
||||||
}
|
|
||||||
reply += ". ";
|
|
||||||
} else {
|
} 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.";
|
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
|
// Default
|
||||||
const reply = currentOrder.payment_type === "link"
|
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."
|
? "Tu pedido está en proceso. Avisame si necesitás algo más o esperá la confirmación de pago."
|
||||||
|
|||||||
@@ -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"}`;
|
const lockKey = `${tenantId}:${wooCustomerId || "anon"}`;
|
||||||
return withLock(lockKey, async () => {
|
return withLock(lockKey, async () => {
|
||||||
const client = await getWooClient({ tenantId });
|
const client = await getWooClient({ tenantId });
|
||||||
@@ -200,9 +200,15 @@ export async function createOrder({ tenantId, wooCustomerId, basket, address, ru
|
|||||||
customer_id: wooCustomerId || undefined,
|
customer_id: wooCustomerId || undefined,
|
||||||
line_items: lineItems,
|
line_items: lineItems,
|
||||||
...(addr ? { billing: addr, shipping: addr } : {}),
|
...(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: [
|
meta_data: [
|
||||||
{ key: "source", value: "whatsapp" },
|
{ key: "source", value: "whatsapp" },
|
||||||
...(run_id ? [{ key: "run_id", value: run_id }] : []),
|
...(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`;
|
const url = `${client.base}/orders`;
|
||||||
@@ -313,17 +319,34 @@ export async function listRecentOrders({ tenantId, limit = 20 }) {
|
|||||||
const source = sourceMeta?.value || "web";
|
const source = sourceMeta?.value || "web";
|
||||||
|
|
||||||
// Método de envío (shipping)
|
// 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 shippingLines = order.shipping_lines || [];
|
||||||
const shippingMethod = shippingLines[0]?.method_title || shippingLines[0]?.method_id || null;
|
const wooShippingMethod = shippingLines[0]?.method_title || shippingLines[0]?.method_id || null;
|
||||||
const isDelivery = shippingMethod ?
|
// 3. Usar metadata si existe, sino WooCommerce
|
||||||
!shippingMethod.toLowerCase().includes("retiro") &&
|
const shippingMethod = metaShippingMethod || wooShippingMethod;
|
||||||
!shippingMethod.toLowerCase().includes("pickup") &&
|
// 4. Determinar isDelivery
|
||||||
!shippingMethod.toLowerCase().includes("local") : false;
|
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
|
// 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 paymentMethod = order.payment_method || null;
|
||||||
const paymentMethodTitle = order.payment_method_title || 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("efectivo") ||
|
||||||
paymentMethodTitle?.toLowerCase().includes("cash");
|
paymentMethodTitle?.toLowerCase().includes("cash");
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,51 @@ function parsePrice(p) {
|
|||||||
return Number.isFinite(n) ? n : null;
|
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 = {
|
||||||
|
"&": "&", "<": "<", ">": ">", """: '"', "'": "'", "'": "'",
|
||||||
|
" ": " ", "¡": "¡", "¢": "¢", "£": "£", "¤": "¤",
|
||||||
|
"¥": "¥", "¦": "¦", "§": "§", "¨": "¨", "©": "©",
|
||||||
|
"ª": "ª", "«": "«", "¬": "¬", "­": "\u00AD", "®": "®",
|
||||||
|
"¯": "¯", "°": "°", "±": "±", "²": "²", "³": "³",
|
||||||
|
"´": "´", "µ": "µ", "¶": "¶", "·": "·", "¸": "¸",
|
||||||
|
"¹": "¹", "º": "º", "»": "»", "¼": "¼", "½": "½",
|
||||||
|
"¾": "¾", "¿": "¿", "À": "À", "Á": "Á", "Â": "Â",
|
||||||
|
"Ã": "Ã", "Ä": "Ä", "Å": "Å", "Æ": "Æ", "Ç": "Ç",
|
||||||
|
"È": "È", "É": "É", "Ê": "Ê", "Ë": "Ë", "Ì": "Ì",
|
||||||
|
"Í": "Í", "Î": "Î", "Ï": "Ï", "Ð": "Ð", "Ñ": "Ñ",
|
||||||
|
"Ò": "Ò", "Ó": "Ó", "Ô": "Ô", "Õ": "Õ", "Ö": "Ö",
|
||||||
|
"×": "×", "Ø": "Ø", "Ù": "Ù", "Ú": "Ú", "Û": "Û",
|
||||||
|
"Ü": "Ü", "Ý": "Ý", "Þ": "Þ", "ß": "ß", "à": "à",
|
||||||
|
"á": "á", "â": "â", "ã": "ã", "ä": "ä", "å": "å",
|
||||||
|
"æ": "æ", "ç": "ç", "è": "è", "é": "é", "ê": "ê",
|
||||||
|
"ë": "ë", "ì": "ì", "í": "í", "î": "î", "ï": "ï",
|
||||||
|
"ð": "ð", "ñ": "ñ", "ò": "ò", "ó": "ó", "ô": "ô",
|
||||||
|
"õ": "õ", "ö": "ö", "÷": "÷", "ø": "ø", "ù": "ù",
|
||||||
|
"ú": "ú", "û": "û", "ü": "ü", "ý": "ý", "þ": "þ",
|
||||||
|
"ÿ": "ÿ",
|
||||||
|
};
|
||||||
|
|
||||||
|
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 ({ o {)
|
||||||
|
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) {
|
function normalizeAttributes(attrs) {
|
||||||
const out = {};
|
const out = {};
|
||||||
if (!Array.isArray(attrs)) return out;
|
if (!Array.isArray(attrs)) return out;
|
||||||
@@ -90,7 +135,7 @@ function normalizeWooProduct(p) {
|
|||||||
woo_id: p?.id,
|
woo_id: p?.id,
|
||||||
type: p?.type || "simple",
|
type: p?.type || "simple",
|
||||||
parent_id: p?.parent_id || null,
|
parent_id: p?.parent_id || null,
|
||||||
name: p?.name || "",
|
name: decodeHtmlEntities(p?.name || ""),
|
||||||
slug: p?.slug || null,
|
slug: p?.slug || null,
|
||||||
status: p?.status || null,
|
status: p?.status || null,
|
||||||
catalog_visibility: p?.catalog_visibility || null,
|
catalog_visibility: p?.catalog_visibility || null,
|
||||||
@@ -114,7 +159,7 @@ function snapshotRowToItem(row) {
|
|||||||
const raw = row?.raw || {};
|
const raw = row?.raw || {};
|
||||||
return {
|
return {
|
||||||
woo_product_id: row?.woo_id,
|
woo_product_id: row?.woo_id,
|
||||||
name: row?.name || "",
|
name: decodeHtmlEntities(row?.name || ""),
|
||||||
sku: raw?.SKU || raw?.sku || row?.slug || null,
|
sku: raw?.SKU || raw?.sku || row?.slug || null,
|
||||||
slug: row?.slug || null,
|
slug: row?.slug || null,
|
||||||
price: row?.price_current != null ? Number(row.price_current) : null,
|
price: row?.price_current != null ? Number(row.price_current) : null,
|
||||||
|
|||||||
Reference in New Issue
Block a user