dashboard
This commit is contained in:
@@ -1,19 +1,32 @@
|
||||
import {
|
||||
handleListRecentOrders,
|
||||
handleListOrders,
|
||||
handleGetProductsWithStock,
|
||||
handleCreateTestOrder,
|
||||
handleCreatePaymentLink,
|
||||
handleSimulateMpWebhook,
|
||||
} from "../handlers/testing.js";
|
||||
import { handleGetOrderStats } from "../handlers/stats.js";
|
||||
|
||||
export const makeListRecentOrders = (tenantIdOrFn) => async (req, res) => {
|
||||
export const makeListOrders = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const result = await handleListRecentOrders({ tenantId, limit });
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
const result = await handleListOrders({ tenantId, page, limit });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error("[testing] listRecentOrders error:", err);
|
||||
console.error("[testing] listOrders error:", err);
|
||||
res.status(500).json({ ok: false, error: err.message || "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
export const makeGetOrderStats = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const result = await handleGetOrderStats({ tenantId });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error("[stats] getOrderStats error:", err);
|
||||
res.status(500).json({ ok: false, error: err.message || "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
43
src/modules/0-ui/handlers/stats.js
Normal file
43
src/modules/0-ui/handlers/stats.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { syncOrdersIncremental } from "../../4-woo-orders/wooOrders.js";
|
||||
import * as ordersRepo from "../../4-woo-orders/ordersRepo.js";
|
||||
|
||||
/**
|
||||
* Obtiene estadísticas de pedidos para el dashboard
|
||||
*/
|
||||
export async function handleGetOrderStats({ tenantId }) {
|
||||
// 1. Sincronizar pedidos nuevos de Woo
|
||||
const syncResult = await syncOrdersIncremental({ tenantId });
|
||||
|
||||
// 2. Obtener todas las estadísticas en paralelo
|
||||
const [monthlyStats, productStats, yoyStats, totals] = await Promise.all([
|
||||
ordersRepo.getMonthlyStats({ tenantId }),
|
||||
ordersRepo.getProductStats({ tenantId }),
|
||||
ordersRepo.getYoyStats({ tenantId }),
|
||||
ordersRepo.getTotals({ tenantId }),
|
||||
]);
|
||||
|
||||
return {
|
||||
// Stats mensuales (para gráficas de barras/líneas)
|
||||
months: monthlyStats.months,
|
||||
totals: monthlyStats.totals,
|
||||
order_counts: monthlyStats.order_counts,
|
||||
by_source: monthlyStats.by_source,
|
||||
by_shipping: monthlyStats.by_shipping,
|
||||
by_payment: monthlyStats.by_payment,
|
||||
|
||||
// Totales agregados (para donuts)
|
||||
totals_aggregated: totals,
|
||||
|
||||
// Stats por producto
|
||||
top_products_revenue: productStats.by_revenue,
|
||||
top_products_kg: productStats.by_kg,
|
||||
top_products_units: productStats.by_units,
|
||||
|
||||
// YoY
|
||||
yoy: yoyStats,
|
||||
|
||||
// Info de sync
|
||||
synced: syncResult.synced,
|
||||
total_in_cache: syncResult.total,
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,28 @@
|
||||
import { createOrder, listRecentOrders } from "../../4-woo-orders/wooOrders.js";
|
||||
import { createOrder, syncOrdersIncremental } from "../../4-woo-orders/wooOrders.js";
|
||||
import { createPreference, reconcilePayment } from "../../6-mercadopago/mercadoPago.js";
|
||||
import { listProducts } from "../db/repo.js";
|
||||
import * as ordersRepo from "../../4-woo-orders/ordersRepo.js";
|
||||
|
||||
/**
|
||||
* Lista pedidos recientes de WooCommerce
|
||||
* Lista pedidos desde cache local (con sync incremental)
|
||||
*/
|
||||
export async function handleListRecentOrders({ tenantId, limit = 20 }) {
|
||||
const orders = await listRecentOrders({ tenantId, limit });
|
||||
return { items: orders };
|
||||
export async function handleListOrders({ tenantId, page = 1, limit = 50 }) {
|
||||
// 1. Sincronizar pedidos nuevos de Woo
|
||||
await syncOrdersIncremental({ tenantId });
|
||||
|
||||
// 2. Obtener pedidos paginados desde cache
|
||||
const orders = await ordersRepo.listOrders({ tenantId, page, limit });
|
||||
const total = await ordersRepo.countOrders({ tenantId });
|
||||
|
||||
return {
|
||||
items: orders,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,7 @@ import { makeListPrompts, makeGetPrompt, makeSavePrompt, makeRollbackPrompt, mak
|
||||
import { makeListPendingTakeovers, makeListAllTakeovers, makeGetTakeover, makeRespondToTakeover, makeCancelTakeover, makeCheckPendingTakeover } from "../../0-ui/controllers/takeovers.js";
|
||||
import { makeGetSettings, makeSaveSettings } from "../../0-ui/controllers/settings.js";
|
||||
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js";
|
||||
import { makeListRecentOrders, makeGetProductsWithStock, makeCreateTestOrder, makeCreatePaymentLink, makeSimulateMpWebhook } from "../../0-ui/controllers/testing.js";
|
||||
import { makeListOrders, makeGetOrderStats, makeGetProductsWithStock, makeCreateTestOrder, makeCreatePaymentLink, makeSimulateMpWebhook } from "../../0-ui/controllers/testing.js";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
@@ -107,8 +107,11 @@ export function createSimulatorRouter({ tenantId }) {
|
||||
router.get("/runs", makeListRuns(getTenantId));
|
||||
router.get("/runs/:run_id", makeGetRunById(getTenantId));
|
||||
|
||||
// --- API routes (orders) ---
|
||||
router.get("/api/orders", makeListOrders(getTenantId));
|
||||
router.get("/api/stats/orders", makeGetOrderStats(getTenantId));
|
||||
|
||||
// --- Testing routes ---
|
||||
router.get("/test/orders", makeListRecentOrders(getTenantId));
|
||||
router.get("/test/products-with-stock", makeGetProductsWithStock(getTenantId));
|
||||
router.post("/test/order", makeCreateTestOrder(getTenantId));
|
||||
router.post("/test/payment-link", makeCreatePaymentLink(getTenantId));
|
||||
|
||||
408
src/modules/4-woo-orders/ordersRepo.js
Normal file
408
src/modules/4-woo-orders/ordersRepo.js
Normal file
@@ -0,0 +1,408 @@
|
||||
import { pool } from "../shared/db/pool.js";
|
||||
|
||||
/**
|
||||
* Obtiene la fecha del pedido más reciente en cache
|
||||
*/
|
||||
export async function getLatestOrderDate({ tenantId }) {
|
||||
const sql = `
|
||||
SELECT MAX(date_created) as latest
|
||||
FROM woo_orders_cache
|
||||
WHERE tenant_id = $1
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenantId]);
|
||||
return rows[0]?.latest || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuenta el total de pedidos en cache
|
||||
*/
|
||||
export async function countOrders({ tenantId }) {
|
||||
const sql = `SELECT COUNT(*) as count FROM woo_orders_cache WHERE tenant_id = $1`;
|
||||
const { rows } = await pool.query(sql, [tenantId]);
|
||||
return parseInt(rows[0]?.count || 0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserta o actualiza un pedido en la cache
|
||||
*/
|
||||
export async function upsertOrder({ tenantId, order }) {
|
||||
const sql = `
|
||||
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,
|
||||
customer_name = EXCLUDED.customer_name,
|
||||
customer_phone = EXCLUDED.customer_phone,
|
||||
customer_email = EXCLUDED.customer_email,
|
||||
shipping_address_1 = EXCLUDED.shipping_address_1,
|
||||
shipping_address_2 = EXCLUDED.shipping_address_2,
|
||||
shipping_city = EXCLUDED.shipping_city,
|
||||
shipping_state = EXCLUDED.shipping_state,
|
||||
shipping_postcode = EXCLUDED.shipping_postcode,
|
||||
billing_address_1 = EXCLUDED.billing_address_1,
|
||||
billing_city = EXCLUDED.billing_city,
|
||||
billing_state = EXCLUDED.billing_state,
|
||||
billing_postcode = EXCLUDED.billing_postcode,
|
||||
raw = EXCLUDED.raw,
|
||||
updated_at = NOW()
|
||||
RETURNING id
|
||||
`;
|
||||
const values = [
|
||||
tenantId,
|
||||
order.woo_order_id,
|
||||
order.status,
|
||||
order.total,
|
||||
order.currency || 'ARS',
|
||||
order.date_created,
|
||||
order.date_paid,
|
||||
order.source || 'web',
|
||||
order.is_delivery || false,
|
||||
order.is_cash || false,
|
||||
order.customer_name,
|
||||
order.customer_phone,
|
||||
order.customer_email,
|
||||
order.shipping_address_1,
|
||||
order.shipping_address_2,
|
||||
order.shipping_city,
|
||||
order.shipping_state,
|
||||
order.shipping_postcode,
|
||||
order.shipping_country || 'AR',
|
||||
order.billing_address_1,
|
||||
order.billing_city,
|
||||
order.billing_state,
|
||||
order.billing_postcode,
|
||||
JSON.stringify(order.raw || {}),
|
||||
];
|
||||
const { rows } = await pool.query(sql, values);
|
||||
return rows[0]?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina e inserta items de un pedido (replace strategy)
|
||||
*/
|
||||
export async function upsertOrderItems({ tenantId, wooOrderId, items }) {
|
||||
// Primero eliminar items existentes
|
||||
await pool.query(
|
||||
`DELETE FROM woo_order_items WHERE tenant_id = $1 AND woo_order_id = $2`,
|
||||
[tenantId, wooOrderId]
|
||||
);
|
||||
|
||||
// Insertar nuevos items
|
||||
for (const item of items) {
|
||||
const sql = `
|
||||
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)
|
||||
`;
|
||||
await pool.query(sql, [
|
||||
tenantId,
|
||||
wooOrderId,
|
||||
item.woo_product_id,
|
||||
item.product_name,
|
||||
item.sku,
|
||||
item.quantity,
|
||||
item.unit_price,
|
||||
item.line_total,
|
||||
item.sell_unit || 'unit',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista pedidos paginados desde la cache
|
||||
*/
|
||||
export async function listOrders({ tenantId, page = 1, limit = 50 }) {
|
||||
const offset = (page - 1) * limit;
|
||||
const sql = `
|
||||
SELECT
|
||||
o.*,
|
||||
COALESCE(
|
||||
(SELECT json_agg(json_build_object(
|
||||
'woo_product_id', i.woo_product_id,
|
||||
'product_name', i.product_name,
|
||||
'quantity', i.quantity,
|
||||
'unit_price', i.unit_price,
|
||||
'line_total', i.line_total,
|
||||
'sell_unit', i.sell_unit
|
||||
))
|
||||
FROM woo_order_items i
|
||||
WHERE i.tenant_id = o.tenant_id AND i.woo_order_id = o.woo_order_id),
|
||||
'[]'
|
||||
) as line_items
|
||||
FROM woo_orders_cache o
|
||||
WHERE o.tenant_id = $1
|
||||
ORDER BY o.date_created DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenantId, limit, offset]);
|
||||
return rows.map(row => {
|
||||
// Parsear nombre del cliente
|
||||
const nameParts = (row.customer_name || "").trim().split(/\s+/);
|
||||
const firstName = nameParts[0] || "";
|
||||
const lastName = nameParts.slice(1).join(" ") || "";
|
||||
|
||||
return {
|
||||
id: row.woo_order_id,
|
||||
status: row.status,
|
||||
total: row.total,
|
||||
currency: row.currency,
|
||||
date_created: row.date_created,
|
||||
date_paid: row.date_paid,
|
||||
source: row.source,
|
||||
is_delivery: row.is_delivery,
|
||||
is_cash: row.is_cash,
|
||||
is_paid: ['processing', 'completed', 'on-hold'].includes(row.status),
|
||||
is_test: false, // Podemos agregar este campo a la BD si es necesario
|
||||
shipping: {
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
address_1: row.shipping_address_1 || "",
|
||||
address_2: row.shipping_address_2 || "",
|
||||
city: row.shipping_city || "",
|
||||
state: row.shipping_state || "",
|
||||
postcode: row.shipping_postcode || "",
|
||||
country: row.shipping_country || "AR",
|
||||
},
|
||||
billing: {
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
phone: row.customer_phone || "",
|
||||
email: row.customer_email || "",
|
||||
address_1: row.billing_address_1 || "",
|
||||
city: row.billing_city || "",
|
||||
state: row.billing_state || "",
|
||||
postcode: row.billing_postcode || "",
|
||||
},
|
||||
line_items: (row.line_items || []).map(li => ({
|
||||
id: li.woo_product_id,
|
||||
name: li.product_name,
|
||||
quantity: li.quantity,
|
||||
total: li.line_total,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Estadísticas mensuales agregadas
|
||||
*/
|
||||
export async function getMonthlyStats({ tenantId }) {
|
||||
const sql = `
|
||||
SELECT
|
||||
TO_CHAR(date_created, 'YYYY-MM') as month,
|
||||
COUNT(*) as order_count,
|
||||
SUM(total) as total_revenue,
|
||||
SUM(CASE WHEN source = 'whatsapp' THEN total ELSE 0 END) as whatsapp_revenue,
|
||||
SUM(CASE WHEN source = 'web' THEN total ELSE 0 END) as web_revenue,
|
||||
SUM(CASE WHEN is_delivery THEN total ELSE 0 END) as delivery_revenue,
|
||||
SUM(CASE WHEN NOT is_delivery THEN total ELSE 0 END) as pickup_revenue,
|
||||
SUM(CASE WHEN is_cash THEN total ELSE 0 END) as cash_revenue,
|
||||
SUM(CASE WHEN NOT is_cash THEN total ELSE 0 END) as card_revenue
|
||||
FROM woo_orders_cache
|
||||
WHERE tenant_id = $1
|
||||
GROUP BY TO_CHAR(date_created, 'YYYY-MM')
|
||||
ORDER BY month ASC
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenantId]);
|
||||
|
||||
const months = rows.map(r => r.month);
|
||||
const totals = rows.map(r => parseFloat(r.total_revenue) || 0);
|
||||
|
||||
return {
|
||||
months,
|
||||
totals,
|
||||
order_counts: rows.map(r => parseInt(r.order_count) || 0),
|
||||
by_source: {
|
||||
whatsapp: rows.map(r => parseFloat(r.whatsapp_revenue) || 0),
|
||||
web: rows.map(r => parseFloat(r.web_revenue) || 0),
|
||||
},
|
||||
by_shipping: {
|
||||
delivery: rows.map(r => parseFloat(r.delivery_revenue) || 0),
|
||||
pickup: rows.map(r => parseFloat(r.pickup_revenue) || 0),
|
||||
},
|
||||
by_payment: {
|
||||
cash: rows.map(r => parseFloat(r.cash_revenue) || 0),
|
||||
card: rows.map(r => parseFloat(r.card_revenue) || 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Estadísticas por producto
|
||||
*/
|
||||
export async function getProductStats({ tenantId }) {
|
||||
// Top productos por revenue
|
||||
const revenueSQL = `
|
||||
SELECT
|
||||
woo_product_id,
|
||||
product_name,
|
||||
SUM(line_total) as total_revenue,
|
||||
SUM(quantity) as total_qty,
|
||||
COUNT(DISTINCT woo_order_id) as order_count
|
||||
FROM woo_order_items
|
||||
WHERE tenant_id = $1
|
||||
GROUP BY woo_product_id, product_name
|
||||
ORDER BY total_revenue DESC
|
||||
LIMIT 15
|
||||
`;
|
||||
const { rows: byRevenue } = await pool.query(revenueSQL, [tenantId]);
|
||||
|
||||
// Top productos vendidos por kg
|
||||
const kgSQL = `
|
||||
SELECT
|
||||
woo_product_id,
|
||||
product_name,
|
||||
SUM(quantity) as total_kg,
|
||||
SUM(line_total) as total_revenue,
|
||||
COUNT(DISTINCT woo_order_id) as order_count
|
||||
FROM woo_order_items
|
||||
WHERE tenant_id = $1 AND sell_unit = 'kg'
|
||||
GROUP BY woo_product_id, product_name
|
||||
ORDER BY total_kg DESC
|
||||
LIMIT 10
|
||||
`;
|
||||
const { rows: byKg } = await pool.query(kgSQL, [tenantId]);
|
||||
|
||||
// Top productos vendidos por unidades
|
||||
const unitsSQL = `
|
||||
SELECT
|
||||
woo_product_id,
|
||||
product_name,
|
||||
SUM(quantity) as total_units,
|
||||
SUM(line_total) as total_revenue,
|
||||
COUNT(DISTINCT woo_order_id) as order_count
|
||||
FROM woo_order_items
|
||||
WHERE tenant_id = $1 AND sell_unit = 'unit'
|
||||
GROUP BY woo_product_id, product_name
|
||||
ORDER BY total_units DESC
|
||||
LIMIT 10
|
||||
`;
|
||||
const { rows: byUnits } = await pool.query(unitsSQL, [tenantId]);
|
||||
|
||||
return {
|
||||
by_revenue: byRevenue.map(r => ({
|
||||
woo_product_id: r.woo_product_id,
|
||||
name: r.product_name,
|
||||
revenue: parseFloat(r.total_revenue) || 0,
|
||||
qty: parseFloat(r.total_qty) || 0,
|
||||
orders: parseInt(r.order_count) || 0,
|
||||
})),
|
||||
by_kg: byKg.map(r => ({
|
||||
woo_product_id: r.woo_product_id,
|
||||
name: r.product_name,
|
||||
kg: parseFloat(r.total_kg) || 0,
|
||||
revenue: parseFloat(r.total_revenue) || 0,
|
||||
orders: parseInt(r.order_count) || 0,
|
||||
})),
|
||||
by_units: byUnits.map(r => ({
|
||||
woo_product_id: r.woo_product_id,
|
||||
name: r.product_name,
|
||||
units: parseFloat(r.total_units) || 0,
|
||||
revenue: parseFloat(r.total_revenue) || 0,
|
||||
orders: parseInt(r.order_count) || 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Estadísticas Year-over-Year
|
||||
*/
|
||||
export async function getYoyStats({ tenantId }) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const lastYear = currentYear - 1;
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
EXTRACT(YEAR FROM date_created)::INT as year,
|
||||
EXTRACT(MONTH FROM date_created)::INT as month,
|
||||
SUM(total) as total_revenue,
|
||||
COUNT(*) as order_count
|
||||
FROM woo_orders_cache
|
||||
WHERE tenant_id = $1
|
||||
AND EXTRACT(YEAR FROM date_created) IN ($2, $3)
|
||||
GROUP BY EXTRACT(YEAR FROM date_created), EXTRACT(MONTH FROM date_created)
|
||||
ORDER BY year, month
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenantId, currentYear, lastYear]);
|
||||
|
||||
// Organizar por año
|
||||
const currentYearData = Array(12).fill(0);
|
||||
const lastYearData = Array(12).fill(0);
|
||||
|
||||
for (const row of rows) {
|
||||
const monthIndex = row.month - 1;
|
||||
if (row.year === currentYear) {
|
||||
currentYearData[monthIndex] = parseFloat(row.total_revenue) || 0;
|
||||
} else if (row.year === lastYear) {
|
||||
lastYearData[monthIndex] = parseFloat(row.total_revenue) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
current_year: currentYear,
|
||||
last_year: lastYear,
|
||||
months: ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'],
|
||||
current_year_data: currentYearData,
|
||||
last_year_data: lastYearData,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Totales agregados para donuts
|
||||
*/
|
||||
export async function getTotals({ tenantId }) {
|
||||
const sql = `
|
||||
SELECT
|
||||
SUM(CASE WHEN source = 'whatsapp' THEN total ELSE 0 END) as whatsapp_total,
|
||||
SUM(CASE WHEN source = 'web' THEN total ELSE 0 END) as web_total,
|
||||
SUM(CASE WHEN is_delivery THEN total ELSE 0 END) as delivery_total,
|
||||
SUM(CASE WHEN NOT is_delivery THEN total ELSE 0 END) as pickup_total,
|
||||
SUM(CASE WHEN is_cash THEN total ELSE 0 END) as cash_total,
|
||||
SUM(CASE WHEN NOT is_cash THEN total ELSE 0 END) as card_total,
|
||||
COUNT(*) as total_orders,
|
||||
SUM(total) as total_revenue
|
||||
FROM woo_orders_cache
|
||||
WHERE tenant_id = $1
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenantId]);
|
||||
const r = rows[0] || {};
|
||||
|
||||
return {
|
||||
by_source: {
|
||||
whatsapp: parseFloat(r.whatsapp_total) || 0,
|
||||
web: parseFloat(r.web_total) || 0,
|
||||
},
|
||||
by_shipping: {
|
||||
delivery: parseFloat(r.delivery_total) || 0,
|
||||
pickup: parseFloat(r.pickup_total) || 0,
|
||||
},
|
||||
by_payment: {
|
||||
cash: parseFloat(r.cash_total) || 0,
|
||||
card: parseFloat(r.card_total) || 0,
|
||||
},
|
||||
total_orders: parseInt(r.total_orders) || 0,
|
||||
total_revenue: parseFloat(r.total_revenue) || 0,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user