/** * Migración directa de pedidos WooCommerce (MySQL) a cache local (PostgreSQL) * * WooCommerce 8.x+ usa HPOS (High Performance Order Storage) * * Uso: * node scripts/migrate-woo-orders.mjs [--tenant-id=xxx] [--batch-size=500] [--dry-run] * * Requiere en .env: * WOO_MYSQL_HOST, WOO_MYSQL_PORT, WOO_MYSQL_USER, WOO_MYSQL_PASSWORD, WOO_MYSQL_DATABASE * WOO_TABLE_PREFIX (default: wp_) * DATABASE_URL (PostgreSQL) */ import mysql from "mysql2/promise"; import pg from "pg"; import "dotenv/config"; const { Pool } = pg; // --- Configuración --- const TENANT_ID = process.argv.find(a => a.startsWith("--tenant-id="))?.split("=")[1] || process.env.DEFAULT_TENANT_ID || "eb71b9a7-9ccf-430e-9b25-951a0c589c0f"; // tenant de piaf const BATCH_SIZE = parseInt(process.argv.find(a => a.startsWith("--batch-size="))?.split("=")[1] || "500", 10); const DRY_RUN = process.argv.includes("--dry-run"); const TABLE_PREFIX = process.env.WOO_TABLE_PREFIX || "wp_"; // --- Conexiones --- let mysqlConn; let pgPool; async function connect() { console.log("[migrate] Conectando a MySQL..."); mysqlConn = await mysql.createConnection({ host: process.env.WOO_MYSQL_HOST, port: parseInt(process.env.WOO_MYSQL_PORT || "3306", 10), user: process.env.WOO_MYSQL_USER, password: process.env.WOO_MYSQL_PASSWORD, database: process.env.WOO_MYSQL_DATABASE, rowsAsArray: false, }); console.log("[migrate] MySQL conectado"); console.log("[migrate] Conectando a PostgreSQL..."); pgPool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5, }); await pgPool.query("SELECT 1"); console.log("[migrate] PostgreSQL conectado"); } async function disconnect() { if (mysqlConn) await mysqlConn.end(); if (pgPool) await pgPool.end(); } // --- Query principal de pedidos (HPOS) --- function buildOrdersQuery() { return ` SELECT o.id as order_id, o.status, o.currency, o.total_amount as total, o.date_created_gmt as date_created, o.date_paid_gmt as date_paid, o.payment_method, o.payment_method_title, -- Billing ba.first_name as billing_first_name, ba.last_name as billing_last_name, ba.address_1 as billing_address_1, ba.city as billing_city, ba.state as billing_state, ba.postcode as billing_postcode, ba.phone as billing_phone, ba.email as billing_email, -- Shipping sa.first_name as shipping_first_name, sa.last_name as shipping_last_name, sa.address_1 as shipping_address_1, sa.address_2 as shipping_address_2, sa.city as shipping_city, sa.state as shipping_state, sa.postcode as shipping_postcode FROM ${TABLE_PREFIX}wc_orders o LEFT JOIN ${TABLE_PREFIX}wc_order_addresses ba ON ba.order_id = o.id AND ba.address_type = 'billing' LEFT JOIN ${TABLE_PREFIX}wc_order_addresses sa ON sa.order_id = o.id AND sa.address_type = 'shipping' WHERE o.type = 'shop_order' ORDER BY o.id ASC `; } // --- Query de items por pedido --- async function getOrderItems(orderId) { const [items] = await mysqlConn.query(` SELECT oi.order_item_id, oi.order_item_name as product_name, MAX(CASE WHEN oim.meta_key = '_product_id' THEN oim.meta_value END) as product_id, MAX(CASE WHEN oim.meta_key = '_variation_id' THEN oim.meta_value END) as variation_id, MAX(CASE WHEN oim.meta_key = '_qty' THEN oim.meta_value END) as quantity, MAX(CASE WHEN oim.meta_key = '_line_total' THEN oim.meta_value END) as line_total, MAX(CASE WHEN oim.meta_key = '_line_subtotal' THEN oim.meta_value END) as line_subtotal, MAX(CASE WHEN oim.meta_key = 'unit' THEN oim.meta_value END) as unit, MAX(CASE WHEN oim.meta_key = 'weight_g' THEN oim.meta_value END) as weight_g FROM ${TABLE_PREFIX}woocommerce_order_items oi LEFT JOIN ${TABLE_PREFIX}woocommerce_order_itemmeta oim ON oim.order_item_id = oi.order_item_id WHERE oi.order_id = ? AND oi.order_item_type = 'line_item' GROUP BY oi.order_item_id, oi.order_item_name `, [orderId]); return items; } // --- Query de metadata por pedido (source, shipping_method, etc) --- async function getOrderMeta(orderId) { const [rows] = await mysqlConn.query(` SELECT meta_key, meta_value FROM ${TABLE_PREFIX}wc_orders_meta WHERE order_id = ? AND meta_key IN ('source', 'shipping_method', 'payment_method_wa', 'run_id') `, [orderId]); const meta = {}; for (const row of rows) { meta[row.meta_key] = row.meta_value; } return meta; } // --- Detectar source y flags --- function detectOrderFlags(order, meta) { // Source const source = meta.source || "web"; // isDelivery const shippingMethod = meta.shipping_method || ""; const isDelivery = shippingMethod === "delivery" || (!shippingMethod.toLowerCase().includes("retiro") && !shippingMethod.toLowerCase().includes("pickup") && !shippingMethod.toLowerCase().includes("local") && order.shipping_address_1); // Si tiene dirección de envío // isCash const metaPayment = meta.payment_method_wa || ""; const isCash = metaPayment === "cash" || order.payment_method === "cod" || (order.payment_method_title || "").toLowerCase().includes("efectivo"); return { source, isDelivery, isCash }; } // --- Detectar sell_unit del item --- function detectSellUnit(item) { if (item.unit === "g" || item.unit === "kg") return "kg"; if (item.unit === "unit") return "unit"; if (item.weight_g) return "kg"; const name = (item.product_name || "").toLowerCase(); if (name.includes(" kg") || name.includes("kilo")) return "kg"; return "unit"; } // --- Insert en PostgreSQL (batch con transacción) --- async function insertOrderBatch(orders) { if (DRY_RUN || orders.length === 0) return; const client = await pgPool.connect(); try { await client.query("BEGIN"); for (const order of orders) { // Upsert pedido await client.query(` INSERT INTO woo_orders_cache ( tenant_id, woo_order_id, status, total, currency, date_created, date_paid, source, is_delivery, is_cash, customer_name, customer_phone, customer_email, shipping_address_1, shipping_address_2, shipping_city, shipping_state, shipping_postcode, shipping_country, billing_address_1, billing_city, billing_state, billing_postcode, raw, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, NOW() ) ON CONFLICT (tenant_id, woo_order_id) DO UPDATE SET status = EXCLUDED.status, total = EXCLUDED.total, date_paid = EXCLUDED.date_paid, source = EXCLUDED.source, is_delivery = EXCLUDED.is_delivery, is_cash = EXCLUDED.is_cash, updated_at = NOW() `, [ TENANT_ID, order.order_id, order.status?.replace("wc-", "") || "pending", parseFloat(order.total) || 0, order.currency || "ARS", order.date_created, order.date_paid, order.source, order.isDelivery, order.isCash, `${order.billing_first_name || ""} ${order.billing_last_name || ""}`.trim(), order.billing_phone, order.billing_email, order.shipping_address_1, order.shipping_address_2, order.shipping_city, order.shipping_state, order.shipping_postcode, "AR", order.billing_address_1, order.billing_city, order.billing_state, order.billing_postcode, JSON.stringify({}), // raw simplificado para ahorrar espacio ]); // Delete + insert items await client.query( `DELETE FROM woo_order_items WHERE tenant_id = $1 AND woo_order_id = $2`, [TENANT_ID, order.order_id] ); if (order.items && order.items.length > 0) { const itemValues = order.items.map(it => [ TENANT_ID, order.order_id, it.product_id || it.variation_id, it.product_name, null, // sku parseFloat(it.quantity) || 0, it.line_subtotal ? parseFloat(it.line_subtotal) / (parseFloat(it.quantity) || 1) : null, parseFloat(it.line_total) || 0, detectSellUnit(it), ]); for (const vals of itemValues) { await client.query(` INSERT INTO woo_order_items ( tenant_id, woo_order_id, woo_product_id, product_name, sku, quantity, unit_price, line_total, sell_unit ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) `, vals); } } } await client.query("COMMIT"); } catch (err) { await client.query("ROLLBACK"); throw err; } finally { client.release(); } } // --- Main --- async function main() { console.log("=".repeat(60)); console.log("[migrate] Migración WooCommerce (MySQL) -> PostgreSQL"); console.log(`[migrate] Tenant: ${TENANT_ID}`); console.log(`[migrate] Batch size: ${BATCH_SIZE}`); console.log(`[migrate] Table prefix: ${TABLE_PREFIX}`); console.log(`[migrate] Dry run: ${DRY_RUN}`); console.log("=".repeat(60)); await connect(); // Contar total de pedidos const [[{ total }]] = await mysqlConn.query(` SELECT COUNT(*) as total FROM ${TABLE_PREFIX}wc_orders WHERE type = 'shop_order' `); console.log(`[migrate] Total pedidos en WooCommerce: ${total}`); // Limpiar cache existente si no es dry run if (!DRY_RUN) { console.log("[migrate] Limpiando cache existente..."); await pgPool.query(`DELETE FROM woo_order_items WHERE tenant_id = $1`, [TENANT_ID]); await pgPool.query(`DELETE FROM woo_orders_cache WHERE tenant_id = $1`, [TENANT_ID]); console.log("[migrate] Cache limpiado"); } // Query de pedidos console.log("[migrate] Iniciando migración..."); const [ordersRows] = await mysqlConn.query(buildOrdersQuery()); let count = 0; let batch = []; const startTime = Date.now(); for (const row of ordersRows) { // Obtener items y metadata const [items, meta] = await Promise.all([ getOrderItems(row.order_id), getOrderMeta(row.order_id), ]); const flags = detectOrderFlags(row, meta); batch.push({ ...row, ...flags, items, }); count++; // Insert batch if (batch.length >= BATCH_SIZE) { await insertOrderBatch(batch); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); const rate = (count / elapsed).toFixed(0); const pct = ((count / total) * 100).toFixed(1); console.log(`[migrate] Progreso: ${count}/${total} (${pct}%) - ${rate} pedidos/s`); batch = []; } } // Último batch if (batch.length > 0) { await insertOrderBatch(batch); } const totalTime = ((Date.now() - startTime) / 1000).toFixed(1); console.log("=".repeat(60)); console.log(`[migrate] COMPLETADO`); console.log(`[migrate] Pedidos migrados: ${count}`); console.log(`[migrate] Tiempo total: ${totalTime}s`); console.log(`[migrate] Velocidad promedio: ${(count / totalTime).toFixed(0)} pedidos/s`); console.log("=".repeat(60)); await disconnect(); } main().catch(err => { console.error("[migrate] ERROR:", err); disconnect().finally(() => process.exit(1)); });