502 lines
18 KiB
JavaScript
502 lines
18 KiB
JavaScript
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())
|
||
);
|
||
}
|
||
|