pedidos
This commit is contained in:
92
src/modules/0-ui/controllers/testing.js
Normal file
92
src/modules/0-ui/controllers/testing.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
handleListRecentOrders,
|
||||
handleGetProductsWithStock,
|
||||
handleCreateTestOrder,
|
||||
handleCreatePaymentLink,
|
||||
handleSimulateMpWebhook,
|
||||
} from "../handlers/testing.js";
|
||||
|
||||
export const makeListRecentOrders = (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 });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error("[testing] listRecentOrders error:", err);
|
||||
res.status(500).json({ ok: false, error: err.message || "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
export const makeGetProductsWithStock = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const result = await handleGetProductsWithStock({ tenantId });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error("[testing] getProductsWithStock error:", err);
|
||||
res.status(500).json({ ok: false, error: err.message || "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
export const makeCreateTestOrder = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const { basket, address, wa_chat_id } = req.body || {};
|
||||
|
||||
if (!basket?.items?.length) {
|
||||
return res.status(400).json({ ok: false, error: "basket_required" });
|
||||
}
|
||||
|
||||
const result = await handleCreateTestOrder({ tenantId, basket, address, wa_chat_id });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error("[testing] createTestOrder error:", err);
|
||||
res.status(500).json({ ok: false, error: err.message || "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
export const makeCreatePaymentLink = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const { woo_order_id, amount } = req.body || {};
|
||||
|
||||
if (!woo_order_id) {
|
||||
return res.status(400).json({ ok: false, error: "woo_order_id_required" });
|
||||
}
|
||||
if (!amount || Number(amount) <= 0) {
|
||||
return res.status(400).json({ ok: false, error: "amount_required" });
|
||||
}
|
||||
|
||||
const result = await handleCreatePaymentLink({
|
||||
tenantId,
|
||||
wooOrderId: woo_order_id,
|
||||
amount: Number(amount)
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error("[testing] createPaymentLink error:", err);
|
||||
res.status(500).json({ ok: false, error: err.message || "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
export const makeSimulateMpWebhook = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const { woo_order_id, amount } = req.body || {};
|
||||
|
||||
if (!woo_order_id) {
|
||||
return res.status(400).json({ ok: false, error: "woo_order_id_required" });
|
||||
}
|
||||
|
||||
const result = await handleSimulateMpWebhook({
|
||||
tenantId,
|
||||
wooOrderId: woo_order_id,
|
||||
amount: Number(amount) || 0
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error("[testing] simulateMpWebhook error:", err);
|
||||
res.status(500).json({ ok: false, error: err.message || "internal_error" });
|
||||
}
|
||||
};
|
||||
131
src/modules/0-ui/handlers/testing.js
Normal file
131
src/modules/0-ui/handlers/testing.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import { createOrder, listRecentOrders } from "../../4-woo-orders/wooOrders.js";
|
||||
import { createPreference, reconcilePayment } from "../../6-mercadopago/mercadoPago.js";
|
||||
import { listProducts } from "../db/repo.js";
|
||||
|
||||
/**
|
||||
* Lista pedidos recientes de WooCommerce
|
||||
*/
|
||||
export async function handleListRecentOrders({ tenantId, limit = 20 }) {
|
||||
const orders = await listRecentOrders({ tenantId, limit });
|
||||
return { items: orders };
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene productos con stock para testing
|
||||
*/
|
||||
export async function handleGetProductsWithStock({ tenantId }) {
|
||||
const allProducts = await listProducts({ tenantId, limit: 500 });
|
||||
const withStock = allProducts.filter(p =>
|
||||
p.stock_status === "instock" &&
|
||||
p.price &&
|
||||
Number(p.price) > 0
|
||||
);
|
||||
return { items: withStock };
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea una orden de prueba en WooCommerce
|
||||
*/
|
||||
export async function handleCreateTestOrder({ tenantId, basket, address, wa_chat_id }) {
|
||||
if (!basket?.items?.length) {
|
||||
throw new Error("basket_empty");
|
||||
}
|
||||
|
||||
const order = await createOrder({
|
||||
tenantId,
|
||||
wooCustomerId: null, // Sin customer de Woo para testing
|
||||
basket,
|
||||
address,
|
||||
run_id: `test-${Date.now()}`,
|
||||
});
|
||||
|
||||
// Calcular total desde line_items
|
||||
let total = 0;
|
||||
if (order?.raw?.line_items) {
|
||||
for (const item of order.raw.line_items) {
|
||||
total += Number(item.total) || 0;
|
||||
}
|
||||
} else if (order?.raw?.total) {
|
||||
total = Number(order.raw.total) || 0;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
woo_order_id: order?.id || null,
|
||||
total,
|
||||
line_items: order?.line_items || [],
|
||||
raw: order?.raw || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un link de pago de MercadoPago
|
||||
*/
|
||||
export async function handleCreatePaymentLink({ tenantId, wooOrderId, amount }) {
|
||||
if (!wooOrderId) {
|
||||
throw new Error("missing_woo_order_id");
|
||||
}
|
||||
if (!amount || Number(amount) <= 0) {
|
||||
throw new Error("invalid_amount");
|
||||
}
|
||||
|
||||
const pref = await createPreference({
|
||||
tenantId,
|
||||
wooOrderId,
|
||||
amount: Number(amount),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
preference_id: pref?.preference_id || null,
|
||||
init_point: pref?.init_point || null,
|
||||
sandbox_init_point: pref?.sandbox_init_point || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simula un webhook de MercadoPago con pago exitoso
|
||||
* No pasa por el endpoint real (requiere firma HMAC)
|
||||
* Crea un payment mock y llama a reconcilePayment directamente
|
||||
*/
|
||||
export async function handleSimulateMpWebhook({ tenantId, wooOrderId, amount }) {
|
||||
if (!wooOrderId) {
|
||||
throw new Error("missing_woo_order_id");
|
||||
}
|
||||
|
||||
// Crear payment mock con status approved
|
||||
const mockPaymentId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const mockPayment = {
|
||||
id: mockPaymentId,
|
||||
status: "approved",
|
||||
status_detail: "accredited",
|
||||
external_reference: `${tenantId}|${wooOrderId}`,
|
||||
transaction_amount: Number(amount) || 0,
|
||||
currency_id: "ARS",
|
||||
date_approved: new Date().toISOString(),
|
||||
date_created: new Date().toISOString(),
|
||||
payment_method_id: "test",
|
||||
payment_type_id: "credit_card",
|
||||
payer: {
|
||||
email: "test@test.com",
|
||||
},
|
||||
order: {
|
||||
id: `pref-test-${wooOrderId}`,
|
||||
},
|
||||
};
|
||||
|
||||
// Reconciliar el pago (actualiza mp_payments y cambia status de orden a processing)
|
||||
const result = await reconcilePayment({
|
||||
tenantId,
|
||||
payment: mockPayment,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
payment_id: mockPaymentId,
|
||||
woo_order_id: result?.woo_order_id || wooOrderId,
|
||||
status: "approved",
|
||||
order_status: "processing",
|
||||
reconciled: result?.payment || null,
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { makeListAliases, makeCreateAlias, makeUpdateAlias, makeDeleteAlias } fr
|
||||
import { makeListRecommendations, makeGetRecommendation, makeCreateRecommendation, makeUpdateRecommendation, makeDeleteRecommendation } from "../../0-ui/controllers/recommendations.js";
|
||||
import { makeListProductQtyRules, makeGetProductQtyRules, makeSaveProductQtyRules } from "../../0-ui/controllers/quantities.js";
|
||||
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js";
|
||||
import { makeListRecentOrders, makeGetProductsWithStock, makeCreateTestOrder, makeCreatePaymentLink, makeSimulateMpWebhook } from "../../0-ui/controllers/testing.js";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
@@ -82,6 +83,13 @@ export function createSimulatorRouter({ tenantId }) {
|
||||
router.get("/runs", makeListRuns(getTenantId));
|
||||
router.get("/runs/:run_id", makeGetRunById(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));
|
||||
router.post("/test/simulate-webhook", makeSimulateMpWebhook(getTenantId));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ async function getWooClient({ tenantId }) {
|
||||
return {
|
||||
base,
|
||||
authHeader: { Authorization: `Basic ${auth}` },
|
||||
timeout: Math.max(cfg.timeout_ms ?? 20000, 20000),
|
||||
timeout: Math.max(cfg.timeout_ms ?? 60000, 60000),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ async function buildLineItems({ tenantId, basket }) {
|
||||
const total = pricePerKg != null ? toMoney(pricePerKg * qty) : null;
|
||||
lineItems.push({
|
||||
product_id: productId,
|
||||
variation_id: it.variation_id ?? null,
|
||||
...(it.variation_id ? { variation_id: it.variation_id } : {}),
|
||||
quantity: Math.round(qty),
|
||||
...(total ? { subtotal: total, total } : {}),
|
||||
meta_data: [
|
||||
@@ -150,7 +150,7 @@ async function buildLineItems({ tenantId, basket }) {
|
||||
const total = pricePerKg != null ? toMoney(pricePerKg * kilos) : null;
|
||||
lineItems.push({
|
||||
product_id: productId,
|
||||
variation_id: it.variation_id ?? null,
|
||||
...(it.variation_id ? { variation_id: it.variation_id } : {}),
|
||||
quantity: 1,
|
||||
...(total ? { subtotal: total, total } : {}),
|
||||
meta_data: [
|
||||
@@ -224,6 +224,33 @@ export async function updateOrder({ tenantId, wooOrderId, basket, address, run_i
|
||||
});
|
||||
}
|
||||
|
||||
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`;
|
||||
@@ -231,8 +258,115 @@ export async function updateOrderStatus({ tenantId, wooOrderId, status }) {
|
||||
const client = await getWooClient({ tenantId });
|
||||
const payload = { status };
|
||||
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 };
|
||||
|
||||
// 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 => {
|
||||
// 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 shippingLines = order.shipping_lines || [];
|
||||
const shippingMethod = shippingLines[0]?.method_title || shippingLines[0]?.method_id || null;
|
||||
const isDelivery = shippingMethod ?
|
||||
!shippingMethod.toLowerCase().includes("retiro") &&
|
||||
!shippingMethod.toLowerCase().includes("pickup") &&
|
||||
!shippingMethod.toLowerCase().includes("local") : false;
|
||||
|
||||
// Método de pago
|
||||
const paymentMethod = order.payment_method || null;
|
||||
const paymentMethodTitle = order.payment_method_title || null;
|
||||
const isCash = paymentMethod === "cod" ||
|
||||
paymentMethodTitle?.toLowerCase().includes("efectivo") ||
|
||||
paymentMethodTitle?.toLowerCase().includes("cash");
|
||||
|
||||
// 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;
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -363,6 +363,7 @@ export async function pushProductToWoo({ tenantId, wooProductId, categories, sel
|
||||
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 }));
|
||||
}
|
||||
@@ -424,8 +425,28 @@ async function fetchWooCategoriesByNames({ tenantId, names }) {
|
||||
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
|
||||
const normalizedNames = names.map(n => String(n).toLowerCase().trim());
|
||||
return allCategories.filter(c =>
|
||||
normalizedNames.includes(String(c.name).toLowerCase().trim()) ||
|
||||
normalizedNames.includes(String(c.slug).toLowerCase().trim())
|
||||
|
||||
Reference in New Issue
Block a user