dashboard

This commit is contained in:
Lucas Tettamanti
2026-01-27 02:41:39 -03:00
parent 493f26af17
commit df9420b954
19 changed files with 2105 additions and 111 deletions

View File

@@ -1,10 +1,15 @@
import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js";
import { debug as dbg } from "../shared/debug.js";
import { getSnapshotPriceByWooId } from "../shared/wooSnapshot.js";
import * as ordersRepo from "./ordersRepo.js";
// --- Simple in-memory lock to serialize work per key ---
const locks = new Map();
// --- Sync lock per tenant to prevent concurrent syncs ---
const syncLocks = new Map();
const syncInProgress = new Map();
async function withLock(key, fn) {
const prev = locks.get(key) || Promise.resolve();
let release;
@@ -310,95 +315,233 @@ export async function listRecentOrders({ tenantId, limit = 20 }) {
if (!Array.isArray(data)) return [];
// Mapear a formato simplificado
return data.map(order => {
// Detectar si es orden de test (run_id empieza con "test-")
const runIdMeta = order.meta_data?.find(m => m.key === "run_id");
const runId = runIdMeta?.value || null;
const isTest = runId?.startsWith("test-") || false;
const sourceMeta = order.meta_data?.find(m => m.key === "source");
const source = sourceMeta?.value || "web";
return data.map(order => normalizeWooOrder(order));
}
/**
* Normaliza un pedido de WooCommerce a formato interno
*/
function normalizeWooOrder(order) {
// Detectar si es orden de test (run_id empieza con "test-")
const runIdMeta = order.meta_data?.find(m => m.key === "run_id");
const runId = runIdMeta?.value || null;
const isTest = runId?.startsWith("test-") || false;
const sourceMeta = order.meta_data?.find(m => m.key === "source");
const source = sourceMeta?.value || "web";
// Método de envío (shipping)
const metaShippingMethod = order.meta_data?.find(m => m.key === "shipping_method")?.value || null;
const shippingLines = order.shipping_lines || [];
const wooShippingMethod = shippingLines[0]?.method_title || shippingLines[0]?.method_id || null;
const shippingMethod = metaShippingMethod || wooShippingMethod;
let isDelivery = false;
if (metaShippingMethod) {
isDelivery = metaShippingMethod === "delivery";
} else if (wooShippingMethod) {
isDelivery = !wooShippingMethod.toLowerCase().includes("retiro") &&
!wooShippingMethod.toLowerCase().includes("pickup") &&
!wooShippingMethod.toLowerCase().includes("local");
}
// Método de pago
const metaPaymentMethod = order.meta_data?.find(m => m.key === "payment_method_wa")?.value || null;
const paymentMethod = order.payment_method || null;
const paymentMethodTitle = order.payment_method_title || null;
const isCash = metaPaymentMethod === "cash" ||
paymentMethod === "cod" ||
paymentMethodTitle?.toLowerCase().includes("efectivo") ||
paymentMethodTitle?.toLowerCase().includes("cash");
const isPaid = ["processing", "completed", "on-hold"].includes(order.status);
const datePaid = order.date_paid || null;
return {
id: order.id,
status: order.status,
total: order.total,
currency: order.currency,
date_created: order.date_created,
date_paid: datePaid,
billing: {
first_name: order.billing?.first_name || "",
last_name: order.billing?.last_name || "",
phone: order.billing?.phone || "",
email: order.billing?.email || "",
address_1: order.billing?.address_1 || "",
address_2: order.billing?.address_2 || "",
city: order.billing?.city || "",
state: order.billing?.state || "",
postcode: order.billing?.postcode || "",
},
shipping: {
first_name: order.shipping?.first_name || "",
last_name: order.shipping?.last_name || "",
address_1: order.shipping?.address_1 || "",
address_2: order.shipping?.address_2 || "",
city: order.shipping?.city || "",
state: order.shipping?.state || "",
postcode: order.shipping?.postcode || "",
},
line_items: (order.line_items || []).map(li => ({
id: li.id,
name: li.name,
product_id: li.product_id,
variation_id: li.variation_id,
quantity: li.quantity,
total: li.total,
subtotal: li.subtotal,
sku: li.sku,
meta_data: li.meta_data,
})),
source,
run_id: runId,
is_test: isTest,
shipping_method: shippingMethod,
is_delivery: isDelivery,
payment_method: paymentMethod,
payment_method_title: paymentMethodTitle,
is_cash: isCash,
is_paid: isPaid,
raw: order,
};
}
/**
* Detecta la unidad de venta de un line item
*/
function detectSellUnit(lineItem) {
// 1. Buscar en meta_data
const unitMeta = lineItem.meta_data?.find(m => m.key === "unit");
if (unitMeta?.value === "g" || unitMeta?.value === "kg") return "kg";
if (unitMeta?.value === "unit") return "unit";
// 2. Detectar por nombre del producto
const name = (lineItem.name || "").toLowerCase();
if (name.includes("kg") || name.includes("kilo")) return "kg";
// 3. Default a unit
return "unit";
}
/**
* Sincroniza pedidos de WooCommerce a la cache local (incremental)
* Usa un lock por tenant para evitar syncs concurrentes
*/
export async function syncOrdersIncremental({ tenantId }) {
// Si ya hay un sync en progreso para este tenant, esperar a que termine
const existingPromise = syncInProgress.get(tenantId);
if (existingPromise) {
console.log(`[wooOrders] syncOrdersIncremental already in progress for tenant ${tenantId}, waiting...`);
return existingPromise;
}
// Crear promise para este sync y registrarla
const syncPromise = doSyncOrdersIncremental({ tenantId });
syncInProgress.set(tenantId, syncPromise);
try {
const result = await syncPromise;
return result;
} finally {
syncInProgress.delete(tenantId);
}
}
/**
* Implementación interna del sync (sin lock)
* Procesa e inserta página por página para:
* - Bajo consumo de RAM (solo ~100 pedidos en memoria a la vez)
* - Resiliencia a cortes (progreso se guarda en DB)
* - Sync incremental real (puede resumir desde donde quedó)
*/
async function doSyncOrdersIncremental({ tenantId }) {
const client = await getWooClient({ tenantId });
const latestDate = await ordersRepo.getLatestOrderDate({ tenantId });
let synced = 0;
let page = 1;
const perPage = 100; // Máximo permitido por Woo
console.log(`[wooOrders] syncOrdersIncremental starting, latestDate: ${latestDate || 'none (full sync)'}`);
while (true) {
// Construir URL con paginación
let url = `${client.base}/orders?per_page=${perPage}&page=${page}&orderby=date&order=desc`;
// 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 wooShippingMethod = shippingLines[0]?.method_title || shippingLines[0]?.method_id || null;
// 3. Usar metadata si existe, sino WooCommerce
const shippingMethod = metaShippingMethod || wooShippingMethod;
// 4. Determinar isDelivery
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");
// Si tenemos fecha, filtrar solo los más recientes
if (latestDate) {
const afterDate = new Date(latestDate);
afterDate.setMinutes(afterDate.getMinutes() - 1);
url += `&after=${afterDate.toISOString()}`;
}
// 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 paymentMethodTitle = order.payment_method_title || null;
// 3. Determinar si es cash
const isCash = metaPaymentMethod === "cash" ||
paymentMethod === "cod" ||
paymentMethodTitle?.toLowerCase().includes("efectivo") ||
paymentMethodTitle?.toLowerCase().includes("cash");
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
// Estado de pago (basado en status de la orden)
// pending = no pago, processing/completed = pago
const isPaid = ["processing", "completed", "on-hold"].includes(order.status);
const datePaid = order.date_paid || null;
if (!Array.isArray(data) || data.length === 0) {
break;
}
return {
id: order.id,
status: order.status,
total: order.total,
currency: order.currency,
date_created: order.date_created,
date_paid: datePaid,
billing: {
first_name: order.billing?.first_name || "",
last_name: order.billing?.last_name || "",
phone: order.billing?.phone || "",
email: order.billing?.email || "",
address_1: order.billing?.address_1 || "",
address_2: order.billing?.address_2 || "",
city: order.billing?.city || "",
state: order.billing?.state || "",
postcode: order.billing?.postcode || "",
},
shipping: {
first_name: order.shipping?.first_name || "",
last_name: order.shipping?.last_name || "",
address_1: order.shipping?.address_1 || "",
address_2: order.shipping?.address_2 || "",
city: order.shipping?.city || "",
state: order.shipping?.state || "",
postcode: order.shipping?.postcode || "",
},
line_items: (order.line_items || []).map(li => ({
id: li.id,
name: li.name,
quantity: li.quantity,
total: li.total,
})),
source,
run_id: runId,
is_test: isTest,
// Shipping info
shipping_method: shippingMethod,
is_delivery: isDelivery,
// Payment info
payment_method: paymentMethod,
payment_method_title: paymentMethodTitle,
is_cash: isCash,
is_paid: isPaid,
};
});
// Procesar e insertar INMEDIATAMENTE esta página
for (const rawOrder of data) {
const order = normalizeWooOrder(rawOrder);
const cacheOrder = {
woo_order_id: order.id,
status: order.status,
total: parseFloat(order.total) || 0,
currency: order.currency,
date_created: order.date_created,
date_paid: order.date_paid,
source: order.source,
is_delivery: order.is_delivery,
is_cash: order.is_cash,
customer_name: `${order.billing.first_name} ${order.billing.last_name}`.trim(),
customer_phone: order.billing.phone,
customer_email: order.billing.email,
shipping_address_1: order.shipping.address_1,
shipping_address_2: order.shipping.address_2,
shipping_city: order.shipping.city,
shipping_state: order.shipping.state,
shipping_postcode: order.shipping.postcode,
shipping_country: "AR",
billing_address_1: order.billing.address_1,
billing_city: order.billing.city,
billing_state: order.billing.state,
billing_postcode: order.billing.postcode,
raw: order.raw,
};
await ordersRepo.upsertOrder({ tenantId, order: cacheOrder });
const items = order.line_items.map(li => ({
woo_product_id: li.product_id || li.variation_id,
product_name: li.name,
sku: li.sku,
quantity: parseFloat(li.quantity) || 0,
unit_price: li.subtotal ? parseFloat(li.subtotal) / (parseFloat(li.quantity) || 1) : null,
line_total: parseFloat(li.total) || 0,
sell_unit: detectSellUnit(li),
}));
await ordersRepo.upsertOrderItems({ tenantId, wooOrderId: order.id, items });
synced++;
}
// Log de progreso después de insertar
const totalInCache = await ordersRepo.countOrders({ tenantId });
console.log(`[wooOrders] syncOrdersIncremental page ${page}: +${data.length} orders (${totalInCache} total in DB)`);
// Si es última página
if (data.length < perPage) {
break;
}
page++;
}
const totalInCache = await ordersRepo.countOrders({ tenantId });
console.log(`[wooOrders] syncOrdersIncremental completed: ${synced} synced, ${totalInCache} total in cache`);
return { synced, total: totalInCache };
}