Mejoras: idempotency webhook, métricas rewriter, recommend en XState, seeds

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>
This commit is contained in:
Lucas Tettamanti
2026-05-01 21:18:29 -03:00
parent 6b7889ef4e
commit 7b6c62b23d
21 changed files with 454 additions and 114 deletions

View File

@@ -25,7 +25,6 @@ export async function handleGetOrderStats({ tenantId }) {
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,

View File

@@ -154,7 +154,6 @@ export async function handleRespondToTakeover({
invariants: { ok: true, checks: [] },
final_reply: response,
order_id: null,
payment_link: null,
latency_ms: 0,
});
@@ -348,13 +347,10 @@ function summarizeContext(contextSnapshot) {
summary.push(`Pendiente: ${pendingItems}`);
}
// Shipping/Payment
// Shipping (sin payment — el bot no maneja pagos)
if (ctx.order?.is_delivery !== null) {
summary.push(ctx.order.is_delivery ? "Delivery" : "Retiro");
}
if (ctx.order?.payment_type) {
summary.push(`Pago: ${ctx.order.payment_type}`);
}
return summary.join(" | ") || "Sin contexto";
}

View File

@@ -15,6 +15,7 @@ import { makeListPendingTakeovers, makeListAllTakeovers, makeGetTakeover, makeRe
import { makeGetSettings, makeSaveSettings } from "../../0-ui/controllers/settings.js";
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js";
import { makeListOrders, makeGetOrderStats, makeGetProductsWithStock, makeCreateTestOrder } from "../../0-ui/controllers/testing.js";
import { getRewriterMetrics } from "../../3-turn-engine/replyRewriter.js";
function nowIso() {
return new Date().toISOString();
@@ -50,6 +51,7 @@ export function createSimulatorRouter({ tenantId }) {
* --- UI data endpoints ---
*/
router.post("/sim/send", makeSimSend());
router.get("/api/metrics/rewriter", (req, res) => res.json(getRewriterMetrics()));
router.get("/conversations", makeGetConversations(getTenantId));
router.get("/conversations/state", makeGetConversationState(getTenantId));

View File

@@ -141,7 +141,7 @@ let externalCustomerId = await getExternalCustomerIdByChat({
});
mark("after_getExternalCustomerIdByChat");
await insertMessage({
const inserted = await insertMessage({
tenant_id: tenantId,
wa_chat_id: chat_id,
provider,
@@ -153,6 +153,15 @@ let externalCustomerId = await getExternalCustomerIdByChat({
});
mark("after_insertMessage_in");
// Idempotency: si el message_id ya estaba insertado (Evolution suele
// reentregar webhooks), evitamos volver a procesar el turn entero.
if (!inserted) {
if (dbg.perf || dbg.evolution) {
console.log("[pipeline] duplicate message ignored", { message_id, chat_id });
}
return { run_id: null, reply: null, duplicate: true };
}
mark("before_getRecentMessagesForLLM_for_plan");
const history = await getRecentMessagesForLLM({
tenant_id: tenantId,

View File

@@ -256,6 +256,23 @@ export const enqueueRemoveFromCart = assign({
],
});
/**
* Absorbe el resultado del recommendActor: reply via rawText (ya viene
* formateado por handleRecommend), merge de order y enqueue de actions.
*/
export const ingestRecommendResult = assign({
pending_reply: ({ event }) => {
const reply = event.output?.plan?.reply;
return reply ? { rawText: reply } : null;
},
order: ({ context, event }) => event.output?.decision?.order || context.order,
pending_actions: ({ context, event }) => {
const incoming = event.output?.decision?.actions || [];
if (!incoming.length) return context.pending_actions || [];
return [...(context.pending_actions || []), ...incoming];
},
});
// ─────────────────────────────────────────────────────────────
// Async reply renderers (entry actions que producen reply async)
// ─────────────────────────────────────────────────────────────
@@ -364,6 +381,7 @@ export const actions = {
enqueueWooCreateOrder,
enqueueAddToCart,
enqueueRemoveFromCart,
ingestRecommendResult,
replyIdleGreeting,
replyIdleHelp,
replyAskMore,

View File

@@ -6,6 +6,7 @@
import { fromPromise } from "xstate";
import { retrieveCandidates } from "../catalogRetrieval.js";
import { getProductQtyRules } from "../../0-ui/db/repo.js";
import { handleRecommend } from "../recommendations.js";
/**
* Busca candidatos para una lista de queries de producto.
@@ -39,7 +40,24 @@ export const getQtyRulesActor = fromPromise(async ({ input }) => {
return await getProductQtyRules({ tenantId, wooProductId });
});
/**
* Recomendación (cross-sell o planificación). Devuelve `{ plan, decision }`
* con shape compatible con el dispatcher legacy.
*/
export const recommendActor = fromPromise(async ({ input }) => {
const { tenantId, text, nlu, order } = input || {};
return await handleRecommend({
tenantId,
text,
nlu,
order,
prevContext: { order },
audit: {},
});
});
export const actors = {
searchCatalogActor,
getQtyRulesActor,
recommendActor,
};

View File

@@ -0,0 +1,152 @@
/**
* E2E test del flow completo desde NLU output hasta serialización del
* snapshot, sin tocar DB real (mocks) ni LLM real.
*
* Cubre el camino feliz: hola → add → confirm → shipping pickup → IDLE
* con la orden creada. También valida snapshot persist/restore.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { createActor } from "xstate";
// Mock pool de DB para que reply_templates devuelva [] (cae a DEFAULTS)
vi.mock("../../shared/db/pool.js", () => ({
pool: { query: vi.fn().mockResolvedValue({ rows: [] }) },
}));
vi.mock("../replyRewriter.js", () => ({
rewriteReply: vi.fn(async ({ baseText }) => ({ text: baseText, rewritten: false, ms: 0 })),
}));
vi.mock("../catalogRetrieval.js", () => ({
retrieveCandidates: vi.fn(async ({ query }) => ({
candidates: query === "chorizo"
? [{ woo_product_id: 100, name: "Chorizo Parrillero", price: 1500, sell_unit: "kg", _score: 1.0 }]
: query === "vacio"
? [{ woo_product_id: 200, name: "Vacío", price: 4500, sell_unit: "kg", _score: 1.0 }]
: [],
audit: {},
})),
}));
vi.mock("../../0-ui/db/repo.js", () => ({
getProductQtyRules: vi.fn(async () => []),
}));
import { machine } from "./index.js";
const TENANT = "eb71b9a7-9ccf-430e-9b25-951a0c589c0f";
function makeActor(input = {}) {
return createActor(machine, {
input: { tenantId: TENANT, chat_id: "e2e", storeConfig: {}, ...input },
});
}
async function settle(actor) {
for (let i = 0; i < 100; i++) {
const snap = actor.getSnapshot();
const children = Object.values(snap.children || {});
const running = children.some((c) => {
try { return c.getSnapshot()?.status === "active"; } catch { return false; }
});
if (!running) return snap;
await new Promise((r) => setTimeout(r, 5));
}
return actor.getSnapshot();
}
describe("E2E — golden flow pickup", () => {
it("hola → add chorizo (qty+unit) → confirm → pickup → idle con create_order", async () => {
const a = makeActor();
a.start();
// Greeting
a.send({ type: "GREETING" });
expect(a.getSnapshot().value).toBe("idle");
expect(a.getSnapshot().context.pending_reply).toMatchObject({ templateKey: "idle.greeting" });
// Add chorizo con qty/unit completos → strong-match → ready
a.send({ type: "ADD_TO_CART", items: [{ product_query: "chorizo", quantity: 2, unit: "kg" }], text: "2kg de chorizo" });
await settle(a);
let snap = a.getSnapshot();
expect(snap.context.order.cart).toHaveLength(1);
expect(snap.context.order.cart[0].name).toBe("Chorizo Parrillero");
// Confirm
a.send({ type: "CONFIRM_ORDER" });
expect(a.getSnapshot().value).toBe("shipping");
// Pickup → IDLE con create_order
a.send({ type: "SELECT_SHIPPING", method: "pickup" });
snap = a.getSnapshot();
expect(snap.value).toBe("idle");
expect(snap.context.order.is_delivery).toBe(false);
expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true);
expect(snap.context.pending_reply?.templateKey).toBe("order.confirmed");
a.stop();
});
});
describe("E2E — golden flow delivery con address en zona", () => {
it("flow delivery con dirección en Palermo se confirma", async () => {
const a = makeActor({
storeConfig: { delivery_zones: { caba: { barrios: ["Palermo", "Belgrano"] } } },
});
a.start();
a.send({ type: "ADD_TO_CART", items: [{ product_query: "vacio", quantity: 1, unit: "kg" }], text: "1kg vacio" });
await settle(a);
a.send({ type: "CONFIRM_ORDER" });
a.send({ type: "SELECT_SHIPPING", method: "delivery" });
expect(a.getSnapshot().value).toBe("shipping");
a.send({ type: "PROVIDE_ADDRESS", address: "Av Santa Fe 3000 Palermo" });
const snap = a.getSnapshot();
expect(snap.value).toBe("idle");
expect(snap.context.order.shipping_address).toMatch(/Palermo/);
expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true);
a.stop();
});
});
describe("E2E — snapshot rehydrate full flow", () => {
it("persiste snapshot a mitad de flow, hidrata, completa", async () => {
const a = makeActor();
a.start();
a.send({ type: "ADD_TO_CART", items: [{ product_query: "vacio", quantity: 1, unit: "kg" }], text: "1kg vacio" });
await settle(a);
a.send({ type: "CONFIRM_ORDER" });
expect(a.getSnapshot().value).toBe("shipping");
const persisted = a.getPersistedSnapshot();
a.stop();
// Re-hidrato y completo
const b = createActor(machine, {
snapshot: persisted,
input: { tenantId: TENANT, chat_id: "e2e", storeConfig: {} },
});
b.start();
expect(b.getSnapshot().value).toBe("shipping");
b.send({ type: "SELECT_SHIPPING", method: "pickup" });
const snap = b.getSnapshot();
expect(snap.value).toBe("idle");
expect(snap.context.order.is_delivery).toBe(false);
expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true);
b.stop();
});
});
describe("E2E — universal cart-on-add desde shipping", () => {
it("desde shipping, add_to_cart vuelve a cart.searching", async () => {
const a = makeActor();
a.start();
a.send({ type: "ADD_TO_CART", items: [{ product_query: "chorizo", quantity: 2, unit: "kg" }], text: "2kg chorizo" });
await settle(a);
a.send({ type: "CONFIRM_ORDER" });
expect(a.getSnapshot().value).toBe("shipping");
a.send({ type: "ADD_TO_CART", items: [{ product_query: "vacio", quantity: 1, unit: "kg" }], text: "1kg vacio" });
expect(a.getSnapshot().value).toEqual({ cart: "searching" });
await settle(a);
expect(a.getSnapshot().context.order.cart.length).toBe(2);
a.stop();
});
});

View File

@@ -83,6 +83,7 @@ export const machine = setup({
target: `${ConversationStates.CART}.searching`,
},
PRICE_QUERY: { actions: "setUserText", target: `${ConversationStates.CART}.pricing` },
RECOMMEND: { actions: "setUserText", target: `${ConversationStates.CART}.recommending` },
VIEW_CART: { target: `${ConversationStates.CART}.showing` },
CONFIRM_ORDER: { actions: "replyEmptyCart" },
OTHER: { actions: "replyIdleHelp" },
@@ -114,6 +115,7 @@ export const machine = setup({
},
],
PRICE_QUERY: { actions: "setUserText", target: ".pricing" },
RECOMMEND: { actions: "setUserText", target: ".recommending" },
GREETING: { actions: "replyIdleGreeting", target: ".idle" },
},
states: {
@@ -251,6 +253,26 @@ export const machine = setup({
always: "idle",
},
recommending: {
invoke: {
src: "recommendActor",
input: ({ context, event }) => ({
tenantId: context.tenantId,
text: event.text || context.userText,
nlu: event.nlu || null,
order: context.order,
}),
onDone: {
actions: "ingestRecommendResult",
target: "idle",
},
onError: {
actions: "replyDidntUnderstand",
target: "idle",
},
},
},
pricing: {
// Para v1, pricing reusa el flow de searching y muestra resultados.
// Una iteración futura podría tener un actor separado para no agregar al carrito.

View File

@@ -79,6 +79,27 @@ const _inflightCache = new Map();
const _resultCache = new Map();
const RESULT_TTL_MS = 30_000;
// Métricas exportadas para observabilidad. Se logean cada N rewrites.
const _metrics = { ok: 0, fallback: 0, timeouts: 0, totalMs: 0 };
export function getRewriterMetrics() {
const total = _metrics.ok + _metrics.fallback;
return {
rewrites_ok: _metrics.ok,
rewrites_fallback: _metrics.fallback,
rewrites_timeout: _metrics.timeouts,
fallback_rate: total ? _metrics.fallback / total : 0,
avg_ms: _metrics.ok ? Math.round(_metrics.totalMs / _metrics.ok) : 0,
};
}
export function resetRewriterMetrics() {
_metrics.ok = 0;
_metrics.fallback = 0;
_metrics.timeouts = 0;
_metrics.totalMs = 0;
}
function cacheKey({ templateKey, baseText, lastUserMsg, lastAssistantMsg }) {
return `${templateKey}|${baseText}|${lastUserMsg}|${lastAssistantMsg}`;
}
@@ -186,9 +207,13 @@ export async function rewriteReply({
const result = { text: safeText, rewritten: true, model };
_resultCache.set(key, { value: result, t: Date.now() });
_metrics.ok++;
_metrics.totalMs += (Date.now() - t0);
return result;
} catch (err) {
const msg = String(err?.message || err);
_metrics.fallback++;
if (msg.includes("timeout")) _metrics.timeouts++;
if (dbg.llm) console.log("[rewriter] error fallback to base", msg);
return { text: baseText, rewritten: false, error: msg };
}

View File

@@ -13,6 +13,7 @@
import { pool } from "../shared/db/pool.js";
import { rewriteReply } from "./replyRewriter.js";
import { getTenantId } from "../shared/tenant.js";
const cache = new Map();
const CACHE_TTL = 5 * 60 * 1000;
@@ -99,6 +100,10 @@ export const DEFAULTS = {
"Anotado: {{address}}.",
"Listo, dirección guardada: {{address}}.",
],
"shipping.address_out_of_zone": [
"Esa dirección queda fuera de la zona de delivery. Hacemos entregas en {{delivery_zones_summary}}. ¿Probás otra dirección o pasás a buscar?",
"No llegamos hasta ahí. Cubrimos {{delivery_zones_summary}}. ¿Querés cambiar la dirección o retiro en sucursal?",
],
// ---------------- ORDER CLOSE ----------------
"order.confirmed": [
"¡Listo! Anotamos tu pedido. Te coordinamos por acá la entrega y el pago.",
@@ -135,8 +140,9 @@ async function loadFromDb({ tenantId, templateKey }) {
}));
}
export async function loadReplyVariants({ tenantId, templateKey, skipCache = false }) {
const cacheKey = `${tenantId}:${templateKey}`;
export async function loadReplyVariants({ tenantId, templateKey, skipCache = false } = {}) {
const tid = tenantId || getTenantId();
const cacheKey = `${tid}:${templateKey}`;
if (!skipCache) {
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
@@ -146,7 +152,7 @@ export async function loadReplyVariants({ tenantId, templateKey, skipCache = fal
let variants = [];
try {
variants = await loadFromDb({ tenantId, templateKey });
variants = await loadFromDb({ tenantId: tid, templateKey });
} catch (err) {
console.error(`[replyTemplates] DB error loading ${templateKey}: ${err.message}`);
}
@@ -220,8 +226,10 @@ export async function renderReply({
conversation_history = null,
state = null,
userText = null,
}) {
const variants = await loadReplyVariants({ tenantId, templateKey });
} = {}) {
// tenantId opcional: defaultea al cacheado al boot (mono-tenant).
const tid = tenantId || getTenantId();
const variants = await loadReplyVariants({ tenantId: tid, templateKey });
if (variants.length === 0) {
return { reply: "", template_id: `${templateKey}:0`, variant: 0 };
}

View File

@@ -10,7 +10,7 @@ import { ConversationState } from "../fsm.js";
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
import { parseIndexSelection } from "./utils.js";
import { renderReply } from "../replyTemplates.js";
import { buildStoreContextVars } from "../storeContext.js";
import { buildStoreContextVars, checkAddressInZone } from "../storeContext.js";
const SHIPPING_OPTIONS_TAIL = "\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal";
@@ -62,6 +62,28 @@ export async function handleShippingState({ tenantId, text, nlu, order, audit, r
const address = nlu?.entities?.address || (text?.length > 5 ? text.trim() : null);
if (address) {
// Validar zona si hay zonas cargadas. Si no hay datos cargados, accept-by-default.
const zoneCheck = checkAddressInZone({ address, storeConfig });
audit.address_zone_check = zoneCheck;
if (!zoneCheck.inZone) {
const r = await renderReply({
tenantId,
templateKey: "shipping.address_out_of_zone",
vars: storeVars,
recentReplies,
...rewriteCtx,
});
return {
plan: {
reply: r.reply,
next_state: ConversationState.SHIPPING,
intent: "provide_address",
missing_fields: ["address"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}
currentOrder = { ...currentOrder, shipping_address: address };
return finalizeOrder({ tenantId, currentOrder, audit, recentReplies, storeVars, rewriteCtx, addressJustRecorded: true });
}

View File

@@ -48,6 +48,50 @@ function formatDaySlot(slot) {
/**
* Resumen de zonas de delivery: lista de barrios o "Consultar zonas".
*/
/**
* Lista plana de barrios (lowercase) habilitados para delivery.
*/
function getDeliveryZoneNames(deliveryZones) {
if (!deliveryZones || typeof deliveryZones !== "object") return [];
const names = [];
if (Array.isArray(deliveryZones.zones)) {
for (const z of deliveryZones.zones) if (z?.name) names.push(z.name);
} else if (deliveryZones.caba?.barrios) {
if (Array.isArray(deliveryZones.caba.barrios)) names.push(...deliveryZones.caba.barrios);
} else {
for (const [k, v] of Object.entries(deliveryZones)) {
if (v === true) names.push(k);
else if (v?.name) names.push(v.name);
}
}
return names.map((n) => String(n).toLowerCase().trim()).filter(Boolean);
}
/**
* Verifica si un texto de dirección menciona un barrio en zona.
* - Sin zonas configuradas: retorna `{ inZone: true, reason: "no_zones_configured" }`
* (no bloqueamos hasta que el comercio cargue las zonas).
* - Con zonas: matching simple por inclusion del barrio en el texto (lowercase, sin tildes).
*/
export function checkAddressInZone({ address, storeConfig }) {
const zones = getDeliveryZoneNames(storeConfig?.delivery_zones);
if (!zones.length) {
return { inZone: true, reason: "no_zones_configured", zones: [] };
}
const norm = String(address || "")
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.trim();
if (!norm) return { inZone: false, reason: "empty_address", zones };
const match = zones.find((z) => {
const zNorm = z.normalize("NFD").replace(/[̀-ͯ]/g, "");
return norm.includes(zNorm);
});
if (match) return { inZone: true, reason: "matched", matched: match, zones };
return { inZone: false, reason: "not_in_zone", zones };
}
function summarizeDeliveryZones(deliveryZones) {
if (!deliveryZones || typeof deliveryZones !== "object") return "";
const names = [];

View File

@@ -19,6 +19,7 @@ import {
import { getStoreConfig } from "../0-ui/db/settingsRepo.js";
import { pushRecent } from "./replyTemplates.js";
import { runTurnXState } from "./machine/runner.js";
import { insertAuditLog } from "../0-ui/db/repo.js";
// Feature flag para NLU modular
const USE_MODULAR_NLU = process.env.USE_MODULAR_NLU === "true";
@@ -205,13 +206,24 @@ export async function runTurnV3({
const legacyResult = formatResult(result, prev_context, recentReplies, failedSearches);
// Shadow mode: corre XState en paralelo, devuelve legacy, loguea diffs.
// Shadow mode: corre XState en paralelo, devuelve legacy, persiste diffs
// estructurales en audit_log para revisarlos después.
if (shadowXState()) {
runTurnXState({ tenantId, chat_id, text, prev_state, prev_context, conversation_history })
.then((xstateResult) => {
.then(async (xstateResult) => {
const diffs = diffResults(legacyResult, xstateResult);
if (diffs.length) {
console.log("[xstate-shadow] diffs", { chat_id, diffs });
if (!diffs.length) return;
try {
await insertAuditLog({
tenantId,
entityType: "xstate_shadow",
entityId: chat_id,
action: "diff",
changes: { diffs, prev_state, text_preview: String(text || "").slice(0, 80) },
actor: "system",
});
} catch (err) {
console.error("[xstate-shadow] audit_log failed", err?.message || err);
}
})
.catch((err) => console.error("[xstate-shadow] error", err?.message || err));

View File

@@ -169,11 +169,8 @@ export async function listOrders({ tenantId, page = 1, limit = 50 }) {
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,
@@ -210,26 +207,24 @@ export async function listOrders({ tenantId, page = 1, limit = 50 }) {
*/
export async function getMonthlyStats({ tenantId }) {
const sql = `
SELECT
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
SUM(CASE WHEN NOT is_delivery THEN total ELSE 0 END) as pickup_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,
@@ -242,10 +237,6 @@ export async function getMonthlyStats({ tenantId }) {
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),
},
};
}
@@ -379,8 +370,6 @@ export async function getTotals({ tenantId }) {
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
@@ -388,7 +377,7 @@ export async function getTotals({ tenantId }) {
`;
const { rows } = await pool.query(sql, [tenantId]);
const r = rows[0] || {};
return {
by_source: {
whatsapp: parseFloat(r.whatsapp_total) || 0,
@@ -398,10 +387,6 @@ export async function getTotals({ tenantId }) {
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,
};

View File

@@ -340,25 +340,12 @@ function normalizeWooOrder(order) {
!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 || "",
@@ -395,10 +382,6 @@ function normalizeWooOrder(order) {
is_test: isTest,
shipping_method: shippingMethod,
is_delivery: isDelivery,
payment_method: paymentMethod,
payment_method_title: paymentMethodTitle,
is_cash: isCash,
is_paid: isPaid,
raw: order,
};
}