7 frentes en una pasada:
1. Idempotency en pipeline.processMessage: si el message_id ya existía
(Evolution suele reentregar webhooks), skip todo el turn y devuelve
{ duplicate: true }. Antes el ON CONFLICT DO NOTHING evitaba el insert
pero igual procesaba NLU + side effects.
2. Limpieza de payment residual de admin/UI:
- ordersRepo.getMonthlyStats / getTotals: out las queries cash/card
- home-dashboard: out el donut "Efectivo vs Tarjeta"
- orders-crud: out columna "Pago" + sección de detalle de pago
- conversation-inspector: out 💵💳✅ del resumen
- takeovers: out payment_link en run, out payment del summary
- public/main.js: out la invariant no_checkout_without_payment_link
- prompts-crud: out la entry "payment" del PROMPT_LABELS
- wooOrders.parseOrder: out lectura de payment_method/is_paid (estado
del pago lo gestiona el comercio offline, fuera del bot)
- ordersRepo: out is_cash/is_paid del row mapping
3. Seed migration 20260501130000_seed_piaf_settings_and_replies:
- schedule realista para piaf (delivery/pickup días+horas)
- delivery_zones con barrios CABA reales (Palermo, Belgrano, etc)
- 41 reply_templates con 17 keys + variantes (todo lo de DEFAULTS)
Permite editar respuestas sin redeploy y desbloquea {{store_hours_today}},
{{delivery_zones_summary}} reales en los templates.
4. Address validation en shipping: nuevo checkAddressInZone() en
storeContext.js. Cuando el usuario da dirección, se valida contra
zonas configuradas. Si está fuera, renderiza shipping.address_out_of_zone
con sugerencia de zonas alternativas. Sin zonas configuradas → accept-by-default.
5. Métricas rewriter: getRewriterMetrics() expuesto, contadores
ok/fallback/timeouts + avg_ms + fallback_rate. Endpoint nuevo
/api/metrics/rewriter.
6. Shadow XState → audit_log: el shadow mode pasa de console.log a
insertAuditLog con entity_type='xstate_shadow'. Permite review post-mortem.
7. Recommend portado a XState: nuevo recommendActor (fromPromise) wrappea
handleRecommend; sub-state cart.recommending invoca el actor; ingestRecommendResult
absorbe { plan, decision } en context. RECOMMEND event funciona desde idle
y desde cart con USE_XSTATE=1.
8. tenantId opcional en renderReply/loadReplyVariants — defaultea a
getTenantId() del módulo shared/tenant.js. Backward-compat: callers
pueden seguir pasando tenantId o omitirlo.
E2E tests nuevos en machine/e2e.test.js: golden flow pickup, golden flow
delivery con address-in-zone, snapshot rehydrate full flow, universal
cart-on-add desde shipping. 192/192 tests pasando (188 previos + 4 E2E).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
527 lines
19 KiB
JavaScript
527 lines
19 KiB
JavaScript
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;
|
|
const next = new Promise((r) => (release = r));
|
|
locks.set(key, prev.then(() => next));
|
|
await prev;
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
release();
|
|
if (locks.get(key) === next) locks.delete(key);
|
|
}
|
|
}
|
|
|
|
async function fetchWoo({ url, method = "GET", body = null, timeout = 20000, headers = {} }) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(new Error("timeout")), timeout);
|
|
const t0 = Date.now();
|
|
try {
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...headers,
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
signal: controller.signal,
|
|
});
|
|
if (dbg.wooHttp) console.log("[wooOrders] http", method, res.status, Date.now() - t0, "ms");
|
|
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;
|
|
} catch (e) {
|
|
const err = new Error(`Woo request failed after ${Date.now() - t0}ms: ${e.message}`);
|
|
err.cause = e;
|
|
err.status = e?.status || null;
|
|
err.body = e?.body || null;
|
|
err.url = url;
|
|
err.method = method;
|
|
throw err;
|
|
} 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 ?? 60000, 60000),
|
|
};
|
|
}
|
|
|
|
function parsePrice(p) {
|
|
if (p == null) return null;
|
|
const n = Number(String(p).replace(",", "."));
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
|
|
async function getWooProductPrice({ tenantId, productId }) {
|
|
if (!productId) return null;
|
|
const snap = await getSnapshotPriceByWooId({ tenantId, wooId: productId });
|
|
if (snap != null) return Number(snap);
|
|
const client = await getWooClient({ tenantId });
|
|
const url = `${client.base}/products/${encodeURIComponent(productId)}`;
|
|
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
|
|
return parsePrice(data?.price ?? data?.regular_price ?? data?.sale_price);
|
|
}
|
|
|
|
function normalizeBasketItems(basket) {
|
|
const items = Array.isArray(basket?.items) ? basket.items : [];
|
|
return items.filter((it) => it && it.product_id && it.quantity && it.unit);
|
|
}
|
|
|
|
function toMoney(value) {
|
|
const n = Number(value);
|
|
if (!Number.isFinite(n)) return null;
|
|
return (Math.round(n * 100) / 100).toFixed(2);
|
|
}
|
|
|
|
async function buildLineItems({ tenantId, basket }) {
|
|
const items = normalizeBasketItems(basket);
|
|
const lineItems = [];
|
|
for (const it of items) {
|
|
const productId = Number(it.product_id);
|
|
const unit = String(it.unit).toLowerCase();
|
|
const qty = Number(it.quantity);
|
|
if (!productId || !Number.isFinite(qty) || qty <= 0) continue;
|
|
const pricePerKg = await getWooProductPrice({ tenantId, productId });
|
|
|
|
if (unit === "unit") {
|
|
const total = pricePerKg != null ? toMoney(pricePerKg * qty) : null;
|
|
lineItems.push({
|
|
product_id: productId,
|
|
...(it.variation_id ? { variation_id: it.variation_id } : {}),
|
|
quantity: Math.round(qty),
|
|
...(total ? { subtotal: total, total } : {}),
|
|
meta_data: [
|
|
{ key: "unit", value: "unit" },
|
|
],
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Carne por peso: convertir a gramos
|
|
// Si qty < 100, asumir que viene en kg (ej: 1.5 kg)
|
|
// Si qty >= 100, asumir que ya viene en gramos (ej: 1500 g)
|
|
const grams = qty < 100 ? Math.round(qty * 1000) : Math.round(qty);
|
|
const kilos = grams / 1000;
|
|
const total = pricePerKg != null ? toMoney(pricePerKg * kilos) : null;
|
|
lineItems.push({
|
|
product_id: productId,
|
|
...(it.variation_id ? { variation_id: it.variation_id } : {}),
|
|
quantity: 1,
|
|
...(total ? { subtotal: total, total } : {}),
|
|
meta_data: [
|
|
{ key: "unit", value: "g" },
|
|
{ key: "weight_g", value: grams },
|
|
{ key: "unit_price_per_kg", value: pricePerKg },
|
|
],
|
|
});
|
|
}
|
|
return lineItems;
|
|
}
|
|
|
|
function mapAddress(address) {
|
|
if (!address || typeof address !== "object") return null;
|
|
// Generar email fallback si no hay uno válido (usa formato wa_chat_id)
|
|
let email = address.email || "";
|
|
if (!email || !email.includes("@")) {
|
|
const phone = address.phone || "";
|
|
// Formato: {phone}@s.whatsapp.net (igual que wa_chat_id)
|
|
email = phone ? `${phone.replace(/[^0-9]/g, "")}@s.whatsapp.net` : `anon-${Date.now()}@s.whatsapp.net`;
|
|
}
|
|
return {
|
|
first_name: address.first_name || "",
|
|
last_name: address.last_name || "",
|
|
address_1: address.address_1 || address.text || "",
|
|
address_2: address.address_2 || "",
|
|
city: address.city || "",
|
|
state: address.state || "",
|
|
postcode: address.postcode || "",
|
|
country: address.country || "AR",
|
|
phone: address.phone || "",
|
|
email,
|
|
};
|
|
}
|
|
|
|
export async function createOrder({ tenantId, wooCustomerId, basket, address, shippingMethod, run_id }) {
|
|
const lockKey = `${tenantId}:${wooCustomerId || "anon"}`;
|
|
return withLock(lockKey, async () => {
|
|
const client = await getWooClient({ tenantId });
|
|
const lineItems = await buildLineItems({ tenantId, basket });
|
|
if (!lineItems.length) throw new Error("order_empty_basket");
|
|
const addr = mapAddress(address);
|
|
// Estado "pending" en Woo = el pago/cobro lo gestiona el comercio offline.
|
|
const payload = {
|
|
status: "pending",
|
|
customer_id: wooCustomerId || undefined,
|
|
line_items: lineItems,
|
|
...(addr ? { billing: addr, shipping: addr } : {}),
|
|
meta_data: [
|
|
{ key: "source", value: "whatsapp" },
|
|
...(run_id ? [{ key: "run_id", value: run_id }] : []),
|
|
...(shippingMethod ? [{ key: "shipping_method", value: shippingMethod }] : []),
|
|
],
|
|
};
|
|
const url = `${client.base}/orders`;
|
|
const data = await fetchWoo({ url, method: "POST", body: payload, timeout: client.timeout, headers: client.authHeader });
|
|
return { id: data?.id || null, raw: data, line_items: lineItems };
|
|
});
|
|
}
|
|
|
|
export async function updateOrder({ tenantId, wooOrderId, basket, address, run_id }) {
|
|
if (!wooOrderId) throw new Error("missing_woo_order_id");
|
|
const lockKey = `${tenantId}:order:${wooOrderId}`;
|
|
return withLock(lockKey, async () => {
|
|
const client = await getWooClient({ tenantId });
|
|
const lineItems = await buildLineItems({ tenantId, basket });
|
|
if (!lineItems.length) throw new Error("order_empty_basket");
|
|
const addr = mapAddress(address);
|
|
const payload = {
|
|
line_items: lineItems,
|
|
...(addr ? { billing: addr, shipping: addr } : {}),
|
|
meta_data: [
|
|
{ key: "source", value: "whatsapp" },
|
|
...(run_id ? [{ key: "run_id", value: run_id }] : []),
|
|
],
|
|
};
|
|
const url = `${client.base}/orders/${encodeURIComponent(wooOrderId)}`;
|
|
const data = await fetchWoo({ url, method: "PUT", body: payload, timeout: client.timeout, headers: client.authHeader });
|
|
return { id: data?.id || wooOrderId, raw: data, line_items: lineItems };
|
|
});
|
|
}
|
|
|
|
function isRetryableNetworkError(err) {
|
|
const e0 = err;
|
|
const e1 = err?.cause;
|
|
const e2 = err?.cause?.cause;
|
|
const candidates = [e0, e1, e2].filter(Boolean);
|
|
const codes = new Set(candidates.map((e) => e.code).filter(Boolean));
|
|
const names = new Set(candidates.map((e) => e.name).filter(Boolean));
|
|
const messages = candidates.map((e) => String(e.message || "")).join(" | ").toLowerCase();
|
|
|
|
const aborted =
|
|
names.has("AbortError") ||
|
|
messages.includes("aborted") ||
|
|
messages.includes("timeout") ||
|
|
messages.includes("timed out");
|
|
|
|
const retryCodes = new Set(["ECONNRESET", "ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET"]);
|
|
const byCode = [...codes].some((c) => retryCodes.has(c));
|
|
|
|
return aborted || byCode;
|
|
}
|
|
|
|
async function getOrderStatus({ client, wooOrderId }) {
|
|
const url = `${client.base}/orders/${encodeURIComponent(wooOrderId)}`;
|
|
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
|
|
return { id: data?.id, status: data?.status, raw: data };
|
|
}
|
|
|
|
export async function updateOrderStatus({ tenantId, wooOrderId, status }) {
|
|
if (!wooOrderId) throw new Error("missing_woo_order_id");
|
|
const lockKey = `${tenantId}:order:${wooOrderId}:status`;
|
|
return withLock(lockKey, async () => {
|
|
const client = await getWooClient({ tenantId });
|
|
const payload = { status };
|
|
const url = `${client.base}/orders/${encodeURIComponent(wooOrderId)}`;
|
|
|
|
// Timeout corto para el PUT (Woo procesa pero tarda en responder)
|
|
const putTimeout = 3000;
|
|
|
|
try {
|
|
const data = await fetchWoo({ url, method: "PUT", body: payload, timeout: putTimeout, headers: client.authHeader });
|
|
return { id: data?.id || wooOrderId, raw: data };
|
|
} catch (err) {
|
|
// Si es timeout, verificar si el status cambió con un GET
|
|
if (isRetryableNetworkError(err)) {
|
|
if (dbg.wooHttp) console.log("[wooOrders] updateOrderStatus timeout, checking with GET...");
|
|
|
|
try {
|
|
const current = await getOrderStatus({ client, wooOrderId });
|
|
// Si el status ya es el deseado, la operación fue exitosa
|
|
if (current.status === status) {
|
|
if (dbg.wooHttp) console.log("[wooOrders] updateOrderStatus confirmed via GET", { wooOrderId, status });
|
|
return { id: current.id || wooOrderId, raw: current.raw, recovered: true };
|
|
}
|
|
} catch (getErr) {
|
|
// Si falla el GET también, propagar el error original
|
|
if (dbg.wooHttp) console.log("[wooOrders] updateOrderStatus GET also failed:", getErr.message);
|
|
}
|
|
}
|
|
throw err;
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function listRecentOrders({ tenantId, limit = 20 }) {
|
|
const client = await getWooClient({ tenantId });
|
|
const url = `${client.base}/orders?per_page=${limit}&orderby=date&order=desc`;
|
|
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
|
|
|
|
if (!Array.isArray(data)) return [];
|
|
|
|
// Mapear a formato simplificado
|
|
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");
|
|
}
|
|
|
|
return {
|
|
id: order.id,
|
|
status: order.status,
|
|
total: order.total,
|
|
currency: order.currency,
|
|
date_created: order.date_created,
|
|
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,
|
|
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`;
|
|
|
|
// 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()}`;
|
|
}
|
|
|
|
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
|
|
|
|
if (!Array.isArray(data) || data.length === 0) {
|
|
break;
|
|
}
|
|
|
|
// 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 };
|
|
}
|