import { pool } from "./db/pool.js"; import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js"; import { debug as dbg } from "./debug.js"; async function fetchWoo({ url, method = "GET", body = null, timeout = 20000, headers = {} }) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(new Error("timeout")), timeout); try { const res = await fetch(url, { method, headers: { "Content-Type": "application/json", ...headers, }, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); const text = await res.text(); let parsed; try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; } if (!res.ok) { const err = new Error(`Woo HTTP ${res.status}`); err.status = res.status; err.body = parsed; err.url = url; err.method = method; throw err; } return parsed; } finally { clearTimeout(timer); } } async function getWooClient({ tenantId }) { const encryptionKey = process.env.APP_ENCRYPTION_KEY; if (!encryptionKey) throw new Error("APP_ENCRYPTION_KEY is required to decrypt Woo credentials"); const cfg = await getDecryptedTenantEcommerceConfig({ tenant_id: tenantId, provider: "woo", encryption_key: encryptionKey, }); if (!cfg) throw new Error("Woo config not found for tenant"); const consumerKey = cfg.consumer_key || process.env.WOO_CONSUMER_KEY || (() => { throw new Error("consumer_key not set"); })(); const consumerSecret = cfg.consumer_secret || process.env.WOO_CONSUMER_SECRET || (() => { throw new Error("consumer_secret not set"); })(); const base = cfg.base_url.replace(/\/+$/, ""); const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64"); return { base, authHeader: { Authorization: `Basic ${auth}` }, timeout: Math.max(cfg.timeout_ms ?? 20000, 20000), }; } function parsePrice(p) { if (p == null) return null; const n = Number(String(p).replace(",", ".")); 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) { const out = {}; if (!Array.isArray(attrs)) return out; for (const a of attrs) { const name = String(a?.name || "").trim().toLowerCase(); if (!name) continue; const options = Array.isArray(a?.options) ? a.options.map((v) => String(v).trim()).filter(Boolean) : []; const value = a?.option ? [String(a.option).trim()] : options; if (value.length) out[name] = value; } return out; } function normalizeWooProduct(p) { return { woo_id: p?.id, type: p?.type || "simple", parent_id: p?.parent_id || null, name: decodeHtmlEntities(p?.name || ""), slug: p?.slug || null, status: p?.status || null, catalog_visibility: p?.catalog_visibility || null, price_regular: parsePrice(p?.regular_price), price_sale: parsePrice(p?.sale_price), price_current: parsePrice(p?.price), stock_status: p?.stock_status || null, stock_qty: p?.stock_quantity != null ? Number(p.stock_quantity) : null, backorders: p?.backorders || null, categories: Array.isArray(p?.categories) ? p.categories.map((c) => c?.name || c?.slug).filter(Boolean) : [], tags: Array.isArray(p?.tags) ? p.tags.map((c) => c?.name || c?.slug).filter(Boolean) : [], attributes_normalized: normalizeAttributes(p?.attributes || []), date_modified: p?.date_modified || null, raw: p, }; } function snapshotRowToItem(row) { const categories = Array.isArray(row?.categories) ? row.categories : []; const attributes = row?.attributes_normalized && typeof row.attributes_normalized === "object" ? row.attributes_normalized : {}; const raw = row?.raw || {}; return { woo_product_id: row?.woo_id, 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, currency: null, type: row?.type || null, stock_status: row?.stock_status || null, stock_qty: row?.stock_qty ?? null, categories: categories.map((c) => ({ id: null, name: String(c), slug: String(c) })), attributes: Object.entries(attributes).map(([name, options]) => ({ name, options: Array.isArray(options) ? options : [String(options)], })), raw_price: { price: row?.price_current ?? null, regular_price: row?.price_regular ?? null, sale_price: row?.price_sale ?? null, price_html: null, }, sell_unit: raw?._sell_unit_override || null, source: "snapshot", }; } export async function insertSnapshotRun({ tenantId, source, total }) { const { rows } = await pool.query( `insert into woo_snapshot_runs (tenant_id, source, total_items) values ($1, $2, $3) returning id`, [tenantId, source, total || 0] ); return rows[0]?.id || null; } export async function searchSnapshotItems({ tenantId, q = "", limit = 12 }) { const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 12)); const query = String(q || "").trim(); if (!query) return { items: [], source: "snapshot" }; const like = `%${query}%`; const sql = ` select * from sellable_items where tenant_id=$1 and (name ilike $2 or coalesce(slug,'') ilike $2) order by updated_at desc limit $3 `; const { rows } = await pool.query(sql, [tenantId, like, lim]); return { items: rows.map(snapshotRowToItem), source: "snapshot" }; } export async function getSnapshotPriceByWooId({ tenantId, wooId }) { if (!wooId) return null; const sql = ` select price_current from woo_products_snapshot where tenant_id=$1 and woo_id=$2 limit 1 `; const { rows } = await pool.query(sql, [tenantId, wooId]); const price = rows[0]?.price_current; return price == null ? null : Number(price); } /** * Obtiene productos de sellable_items por sus woo_product_ids. * Usado para incluir productos encontrados vía aliases. */ export async function getSnapshotItemsByIds({ tenantId, wooProductIds }) { if (!Array.isArray(wooProductIds) || wooProductIds.length === 0) { return { items: [], source: "snapshot_by_id" }; } const ids = wooProductIds.map(id => Number(id)).filter(id => id > 0); if (ids.length === 0) return { items: [], source: "snapshot_by_id" }; const placeholders = ids.map((_, i) => `$${i + 2}`).join(","); const sql = ` select * from sellable_items where tenant_id=$1 and woo_id in (${placeholders}) `; const { rows } = await pool.query(sql, [tenantId, ...ids]); return { items: rows.map(snapshotRowToItem), source: "snapshot_by_id" }; } export async function upsertSnapshotItems({ tenantId, items, runId = null }) { const rows = Array.isArray(items) ? items : []; for (const item of rows) { const q = ` insert into woo_products_snapshot (tenant_id, woo_id, type, parent_id, name, slug, status, catalog_visibility, price_regular, price_sale, price_current, stock_status, stock_qty, backorders, categories, tags, attributes_normalized, date_modified, run_id, raw, updated_at) values ($1,$2,$3,$4,$5,$6,$7,$8, $9,$10,$11,$12,$13,$14, $15::jsonb,$16::jsonb,$17::jsonb,$18,$19,$20::jsonb,now()) on conflict (tenant_id, woo_id) do update set type = excluded.type, parent_id = excluded.parent_id, name = excluded.name, slug = excluded.slug, status = excluded.status, catalog_visibility = excluded.catalog_visibility, price_regular = excluded.price_regular, price_sale = excluded.price_sale, price_current = excluded.price_current, stock_status = excluded.stock_status, stock_qty = excluded.stock_qty, backorders = excluded.backorders, categories = excluded.categories, tags = excluded.tags, attributes_normalized = excluded.attributes_normalized, date_modified = excluded.date_modified, run_id = excluded.run_id, raw = excluded.raw, updated_at = now() `; await pool.query(q, [ tenantId, item.woo_id, item.type, item.parent_id, item.name, item.slug, item.status, item.catalog_visibility, item.price_regular, item.price_sale, item.price_current, item.stock_status, item.stock_qty, item.backorders, JSON.stringify(item.categories || []), JSON.stringify(item.tags || []), JSON.stringify(item.attributes_normalized || {}), item.date_modified, runId, JSON.stringify(item.raw || {}), ]); } } export async function deleteMissingItems({ tenantId, runId }) { if (!runId) return; await pool.query( `delete from woo_products_snapshot where tenant_id=$1 and coalesce(run_id,0) <> $2`, [tenantId, runId] ); } export async function refreshProductByWooId({ tenantId, wooId, parentId = null }) { const client = await getWooClient({ tenantId }); let url = `${client.base}/products/${encodeURIComponent(wooId)}`; if (parentId) { url = `${client.base}/products/${encodeURIComponent(parentId)}/variations/${encodeURIComponent(wooId)}`; } const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader }); if (dbg.wooHttp) console.log("[wooSnapshot] refresh", { wooId, parentId, type: data?.type }); const normalized = normalizeWooProduct(data); await upsertSnapshotItems({ tenantId, items: [normalized], runId: null }); return normalized; } // ───────────────────────────────────────────────────────────── // Sincronización completa con WooCommerce // ───────────────────────────────────────────────────────────── /** * Obtiene todos los productos de WooCommerce usando paginación */ export async function fetchAllWooProducts({ tenantId }) { const client = await getWooClient({ tenantId }); const allProducts = []; let page = 1; const perPage = 100; console.log("[wooSnapshot] fetchAllWooProducts starting..."); while (true) { const url = `${client.base}/products?per_page=${perPage}&page=${page}&status=any`; const data = await fetchWoo({ url, method: "GET", timeout: client.timeout * 2, // Más tiempo para listados grandes headers: client.authHeader }); if (!Array.isArray(data) || data.length === 0) { break; } allProducts.push(...data); console.log(`[wooSnapshot] fetchAllWooProducts page ${page}: ${data.length} products (total: ${allProducts.length})`); if (data.length < perPage) { break; // Última página } page++; } console.log(`[wooSnapshot] fetchAllWooProducts completed: ${allProducts.length} products`); return allProducts; } /** * Sincroniza todos los productos desde WooCommerce (reemplaza snapshot local) */ export async function syncFromWoo({ tenantId }) { console.log("[wooSnapshot] syncFromWoo starting..."); const t0 = Date.now(); // 1. Obtener todos los productos de Woo const wooProducts = await fetchAllWooProducts({ tenantId }); if (wooProducts.length === 0) { console.log("[wooSnapshot] syncFromWoo: no products found in Woo"); return { ok: true, synced: 0, ms: Date.now() - t0 }; } // 2. Crear run para tracking const runId = await insertSnapshotRun({ tenantId, source: "sync_from_woo", total: wooProducts.length }); // 3. Normalizar e insertar productos const normalized = wooProducts.map(normalizeWooProduct); await upsertSnapshotItems({ tenantId, items: normalized, runId }); // 4. Eliminar productos que ya no existen en Woo await deleteMissingItems({ tenantId, runId }); const ms = Date.now() - t0; console.log(`[wooSnapshot] syncFromWoo completed: ${wooProducts.length} products in ${ms}ms`); return { ok: true, synced: wooProducts.length, ms }; } /** * Pushea cambios de un producto a WooCommerce */ export async function pushProductToWoo({ tenantId, wooProductId, categories, sellUnit }) { const client = await getWooClient({ tenantId }); const url = `${client.base}/products/${encodeURIComponent(wooProductId)}`; // Construir payload de actualización const updatePayload = {}; // Actualizar categorías si se proporcionaron if (categories && Array.isArray(categories) && categories.length > 0) { // Primero obtener las categorías existentes en Woo para mapear nombres a IDs const existingCats = await fetchWooCategoriesByNames({ tenantId, names: categories }); if (existingCats.length > 0) { updatePayload.categories = existingCats.map(c => ({ id: c.id })); } } // Actualizar meta de unidad de venta if (sellUnit) { updatePayload.meta_data = [ { key: "_sell_unit_override", value: sellUnit } ]; } // Solo hacer request si hay algo que actualizar if (Object.keys(updatePayload).length === 0) { return { ok: true, woo_product_id: wooProductId, updated: false }; } try { const data = await fetchWoo({ url, method: "PUT", body: updatePayload, timeout: client.timeout, headers: client.authHeader, }); if (dbg.wooHttp) console.log("[wooSnapshot] pushProductToWoo", { wooProductId, updated: true }); return { ok: true, woo_product_id: wooProductId, updated: true, data }; } catch (err) { console.error("[wooSnapshot] pushProductToWoo error:", err.message); throw err; } } /** * Obtiene categorías de WooCommerce por nombres */ async function fetchWooCategoriesByNames({ tenantId, names }) { if (!names || names.length === 0) return []; const client = await getWooClient({ tenantId }); const allCategories = []; let page = 1; // Obtener todas las categorías de Woo (usualmente son pocas) while (true) { const url = `${client.base}/products/categories?per_page=100&page=${page}`; const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader, }); if (!Array.isArray(data) || data.length === 0) break; allCategories.push(...data); if (data.length < 100) break; page++; } // Normalizar nombres - soportar formato jerárquico "Parent > Child" // Extraer TODAS las partes del path para asignar la jerarquía completa const normalizedNames = []; for (const n of names) { const full = String(n).toLowerCase().trim(); // Si tiene formato jerárquico "Parent > Child > Grandchild", extraer cada parte if (full.includes(' > ')) { const parts = full.split(' > ').map(p => p.trim()).filter(Boolean); for (const part of parts) { if (!normalizedNames.includes(part)) { normalizedNames.push(part); } } } else { // Categoría simple (sin jerarquía) if (!normalizedNames.includes(full)) { normalizedNames.push(full); } } } // Filtrar las que coinciden con los nombres buscados return allCategories.filter(c => normalizedNames.includes(String(c.name).toLowerCase().trim()) || normalizedNames.includes(String(c.slug).toLowerCase().trim()) ); }