Files
botino/src/modules/shared/wooSnapshot.js

502 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = {
"&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;
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())
);
}