dashboard

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

View File

@@ -0,0 +1,90 @@
-- migrate:up
-- Tabla de cache de pedidos de WooCommerce
-- Almacena pedidos localmente para estadísticas y listado rápido
CREATE TABLE woo_orders_cache (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
woo_order_id BIGINT NOT NULL,
status VARCHAR(50),
total NUMERIC(12,2),
currency VARCHAR(10) DEFAULT 'ARS',
date_created TIMESTAMPTZ NOT NULL,
date_paid TIMESTAMPTZ,
-- Filtros del dashboard
source VARCHAR(20) DEFAULT 'web', -- 'whatsapp' | 'web'
is_delivery BOOLEAN DEFAULT false,
is_cash BOOLEAN DEFAULT false,
-- Cliente
customer_name VARCHAR(255),
customer_phone VARCHAR(50),
customer_email VARCHAR(255),
-- Dirección de envío (para futuro mapa de calor)
shipping_address_1 VARCHAR(255),
shipping_address_2 VARCHAR(255),
shipping_city VARCHAR(100),
shipping_state VARCHAR(100),
shipping_postcode VARCHAR(20),
shipping_country VARCHAR(10) DEFAULT 'AR',
-- Dirección de facturación
billing_address_1 VARCHAR(255),
billing_city VARCHAR(100),
billing_state VARCHAR(100),
billing_postcode VARCHAR(20),
-- Raw para debugging
raw JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, woo_order_id)
);
CREATE INDEX idx_woo_orders_tenant_date ON woo_orders_cache(tenant_id, date_created DESC);
CREATE INDEX idx_woo_orders_source ON woo_orders_cache(tenant_id, source);
CREATE INDEX idx_woo_orders_city ON woo_orders_cache(tenant_id, shipping_city);
-- Tabla de detalle de items (productos por pedido)
-- Permite calcular stats por producto (kg vendidos, unidades, facturación)
CREATE TABLE woo_order_items (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
woo_order_id BIGINT NOT NULL,
woo_product_id BIGINT,
-- Datos del producto
product_name VARCHAR(255) NOT NULL,
sku VARCHAR(100),
-- Cantidades y precios
quantity NUMERIC(10,3) NOT NULL, -- Soporta decimales para kg
unit_price NUMERIC(12,2), -- Precio unitario
line_total NUMERIC(12,2), -- quantity * unit_price
-- Tipo de unidad (para stats de kg vs unidades)
sell_unit VARCHAR(20) DEFAULT 'unit', -- 'kg' | 'unit' | 'pack'
created_at TIMESTAMPTZ DEFAULT NOW(),
FOREIGN KEY (tenant_id, woo_order_id)
REFERENCES woo_orders_cache(tenant_id, woo_order_id) ON DELETE CASCADE
);
CREATE INDEX idx_woo_items_order ON woo_order_items(tenant_id, woo_order_id);
CREATE INDEX idx_woo_items_product ON woo_order_items(tenant_id, woo_product_id);
-- migrate:down
DROP INDEX IF EXISTS idx_woo_items_product;
DROP INDEX IF EXISTS idx_woo_items_order;
DROP TABLE IF EXISTS woo_order_items;
DROP INDEX IF EXISTS idx_woo_orders_city;
DROP INDEX IF EXISTS idx_woo_orders_source;
DROP INDEX IF EXISTS idx_woo_orders_tenant_date;
DROP TABLE IF EXISTS woo_orders_cache;

View File

@@ -0,0 +1,59 @@
services:
app:
image: node:20-alpine
working_dir: /usr/src/app
command: sh -c "npm install && npm run dev"
ports:
- "3000:3000"
env_file:
- .env
environment:
- NODE_ENV=development
- PORT=3000
- DATABASE_URL=postgres://${POSTGRES_USER:-botino}:${POSTGRES_PASSWORD:-botino}@db:5432/${POSTGRES_DB:-botino}
- REDIS_URL=redis://redis:6379
volumes:
- .:/usr/src/app
- /usr/src/app/node_modules
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
env_file:
- .env
environment:
- POSTGRES_DB=${POSTGRES_DB:-botino}
- POSTGRES_USER=${POSTGRES_USER:-botino}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-botino}
ports:
- "5432:5432"
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-botino}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
db_data:
redis_data:

View File

@@ -3,8 +3,8 @@ services:
image: node:20-alpine
working_dir: /usr/src/app
command: sh -c "npm install && npm run dev"
ports:
- "3000:3000"
expose:
- "3000"
env_file:
- .env
environment:

117
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"csv-parse": "^6.1.0",
"dotenv": "^17.2.3",
"express": "^4.19.2",
"mysql2": "^3.16.2",
"openai": "^6.15.0",
"pg": "^8.16.3",
"undici": "^7.16.0",
@@ -1249,6 +1250,15 @@
"js-tokens": "^9.0.1"
}
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1480,6 +1490,15 @@
"ms": "2.0.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1795,6 +1814,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -1998,6 +2026,12 @@
"node": ">=0.12.0"
}
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@@ -2073,6 +2107,27 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru.min": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz",
"integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2199,6 +2254,54 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/mysql2": {
"version": "3.16.2",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.16.2.tgz",
"integrity": "sha512-JsqBpYNy7pH20lGfPuSyRSIcCxSeAIwxWADpV64nP9KeyN3ZKpHZgjKXuBKsh7dH6FbOvf1bOgoVKjSUPXRMTw==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.2",
"long": "^5.3.2",
"lru.min": "^1.1.3",
"named-placeholders": "^1.1.6",
"seq-queue": "^0.0.5",
"sqlstring": "^2.3.3"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/mysql2/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"license": "MIT",
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -2750,6 +2853,11 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
@@ -2882,6 +2990,15 @@
"node": ">= 10.x"
}
},
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",

View File

@@ -24,6 +24,7 @@
"csv-parse": "^6.1.0",
"dotenv": "^17.2.3",
"express": "^4.19.2",
"mysql2": "^3.16.2",
"openai": "^6.15.0",
"pg": "^8.16.3",
"undici": "^7.16.0",

View File

@@ -1,4 +1,5 @@
import "./components/ops-shell.js";
import "./components/home-dashboard.js";
import "./components/run-timeline.js";
import "./components/chat-simulator.js";
import "./components/conversation-inspector.js";

View File

@@ -0,0 +1,622 @@
import { api } from "../lib/api.js";
function formatCurrency(value) {
if (value == null) return "$0";
return new Intl.NumberFormat("es-AR", { style: "currency", currency: "ARS", maximumFractionDigits: 0 }).format(value);
}
function formatNumber(value) {
if (value == null) return "0";
return new Intl.NumberFormat("es-AR", { maximumFractionDigits: 1 }).format(value);
}
class HomeDashboard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.stats = null;
this.loading = false;
this.charts = {};
this.shadowRoot.innerHTML = `
<style>
:host {
--bg: #0b0f14;
--panel: #121823;
--muted: #8aa0b5;
--text: #e7eef7;
--line: #1e2a3a;
--blue: #3b82f6;
--green: #25D366;
--purple: #8B5CF6;
--orange: #F59E0B;
--emerald: #10B981;
--pink: #EC4899;
--gray: #9CA3AF;
}
* { box-sizing: border-box; font-family: system-ui, Segoe UI, Roboto, Arial; }
.container {
min-height: 100%;
background: var(--bg);
color: var(--text);
padding: 16px;
overflow-y: auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header h1 {
font-size: 24px;
font-weight: 600;
margin: 0;
}
.sync-info {
font-size: 12px;
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 16px;
}
.chart-card {
background: var(--panel);
border-radius: 8px;
padding: 16px;
}
.chart-card.full-width {
grid-column: 1 / -1;
}
.chart-title {
font-size: 14px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.chart-container {
position: relative;
height: 250px;
}
.chart-container.tall {
height: 300px;
}
.chart-container.short {
height: 200px;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--muted);
}
.kpi-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.kpi-card {
background: var(--panel);
border-radius: 8px;
padding: 16px;
text-align: center;
}
.kpi-value {
font-size: 24px;
font-weight: 700;
margin-bottom: 4px;
}
.kpi-label {
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
}
.donut-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
canvas {
max-width: 100%;
}
</style>
<div class="container">
<div class="header">
<h1>Dashboard de Ventas</h1>
<div class="sync-info"></div>
</div>
<div class="kpi-row"></div>
<div class="grid">
<div class="chart-card full-width">
<div class="chart-title">Ventas Totales por Mes</div>
<div class="chart-container tall">
<canvas id="monthly-chart"></canvas>
</div>
</div>
<div class="chart-card">
<div class="chart-title">Web vs WhatsApp</div>
<div class="chart-container">
<canvas id="source-chart"></canvas>
</div>
</div>
<div class="chart-card">
<div class="chart-title">Comparativa Año a Año</div>
<div class="chart-container">
<canvas id="yoy-chart"></canvas>
</div>
</div>
<div class="donut-row">
<div class="chart-card">
<div class="chart-title">Por Canal</div>
<div class="chart-container short">
<canvas id="source-donut"></canvas>
</div>
</div>
<div class="chart-card">
<div class="chart-title">Delivery vs Retiro</div>
<div class="chart-container short">
<canvas id="shipping-donut"></canvas>
</div>
</div>
<div class="chart-card">
<div class="chart-title">Efectivo vs Tarjeta</div>
<div class="chart-container short">
<canvas id="payment-donut"></canvas>
</div>
</div>
</div>
<div class="chart-card full-width">
<div class="chart-title">Top Productos por Facturación</div>
<div class="chart-container tall">
<canvas id="products-chart"></canvas>
</div>
</div>
<div class="chart-card">
<div class="chart-title">Top por Kg Vendidos</div>
<div class="chart-container">
<canvas id="kg-chart"></canvas>
</div>
</div>
<div class="chart-card">
<div class="chart-title">Top por Unidades</div>
<div class="chart-container">
<canvas id="units-chart"></canvas>
</div>
</div>
</div>
</div>
`;
}
connectedCallback() {
this.loadStats();
}
disconnectedCallback() {
// Destruir charts para liberar memoria
Object.values(this.charts).forEach(chart => chart?.destroy?.());
this.charts = {};
}
async loadStats() {
this.loading = true;
try {
this.stats = await api.getOrderStats();
this.render();
} catch (err) {
console.error("[home-dashboard] loadStats error:", err);
} finally {
this.loading = false;
}
}
render() {
if (!this.stats) return;
// Actualizar sync info
const syncInfo = this.shadowRoot.querySelector(".sync-info");
syncInfo.textContent = `${this.stats.total_in_cache || 0} pedidos en cache`;
if (this.stats.synced > 0) {
syncInfo.textContent += ` (${this.stats.synced} nuevos sincronizados)`;
}
// Renderizar KPIs
this.renderKPIs();
// Renderizar charts
this.renderMonthlyChart();
this.renderSourceChart();
this.renderYoyChart();
this.renderDonuts();
this.renderProductsChart();
this.renderKgChart();
this.renderUnitsChart();
}
renderKPIs() {
const totals = this.stats.totals_aggregated || {};
const kpiRow = this.shadowRoot.querySelector(".kpi-row");
kpiRow.innerHTML = `
<div class="kpi-card">
<div class="kpi-value" style="color: var(--blue)">${formatCurrency(totals.total_revenue)}</div>
<div class="kpi-label">Total Facturado</div>
</div>
<div class="kpi-card">
<div class="kpi-value">${formatNumber(totals.total_orders)}</div>
<div class="kpi-label">Pedidos</div>
</div>
<div class="kpi-card">
<div class="kpi-value" style="color: var(--green)">${formatCurrency(totals.by_source?.whatsapp)}</div>
<div class="kpi-label">WhatsApp</div>
</div>
<div class="kpi-card">
<div class="kpi-value" style="color: var(--blue)">${formatCurrency(totals.by_source?.web)}</div>
<div class="kpi-label">Web</div>
</div>
`;
}
renderMonthlyChart() {
const ctx = this.shadowRoot.getElementById("monthly-chart");
if (!ctx) return;
if (this.charts.monthly) this.charts.monthly.destroy();
const months = this.stats.months || [];
const totals = this.stats.totals || [];
this.charts.monthly = new Chart(ctx, {
type: "bar",
data: {
labels: months.map(m => {
const [y, mo] = m.split("-");
return `${mo}/${y.slice(2)}`;
}),
datasets: [{
label: "Ventas",
data: totals,
backgroundColor: "rgba(59, 130, 246, 0.8)",
borderColor: "#3b82f6",
borderWidth: 1,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
y: {
beginAtZero: true,
ticks: { color: "#8aa0b5" },
grid: { color: "#1e2a3a" },
},
x: {
ticks: { color: "#8aa0b5" },
grid: { display: false },
},
},
},
});
}
renderSourceChart() {
const ctx = this.shadowRoot.getElementById("source-chart");
if (!ctx) return;
if (this.charts.source) this.charts.source.destroy();
const months = this.stats.months || [];
const waData = this.stats.by_source?.whatsapp || [];
const webData = this.stats.by_source?.web || [];
this.charts.source = new Chart(ctx, {
type: "bar",
data: {
labels: months.map(m => {
const [y, mo] = m.split("-");
return `${mo}/${y.slice(2)}`;
}),
datasets: [
{
label: "WhatsApp",
data: waData,
backgroundColor: "#25D366",
},
{
label: "Web",
data: webData,
backgroundColor: "#3b82f6",
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "top",
labels: { color: "#8aa0b5" },
},
},
scales: {
y: {
stacked: true,
beginAtZero: true,
ticks: { color: "#8aa0b5" },
grid: { color: "#1e2a3a" },
},
x: {
stacked: true,
ticks: { color: "#8aa0b5" },
grid: { display: false },
},
},
},
});
}
renderYoyChart() {
const ctx = this.shadowRoot.getElementById("yoy-chart");
if (!ctx) return;
if (this.charts.yoy) this.charts.yoy.destroy();
const yoy = this.stats.yoy || {};
this.charts.yoy = new Chart(ctx, {
type: "line",
data: {
labels: yoy.months || [],
datasets: [
{
label: String(yoy.current_year || "Actual"),
data: yoy.current_year_data || [],
borderColor: "#3b82f6",
backgroundColor: "rgba(59, 130, 246, 0.1)",
fill: true,
tension: 0.3,
},
{
label: String(yoy.last_year || "Anterior"),
data: yoy.last_year_data || [],
borderColor: "#9CA3AF",
backgroundColor: "rgba(156, 163, 175, 0.1)",
fill: true,
tension: 0.3,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "top",
labels: { color: "#8aa0b5" },
},
},
scales: {
y: {
beginAtZero: true,
ticks: { color: "#8aa0b5" },
grid: { color: "#1e2a3a" },
},
x: {
ticks: { color: "#8aa0b5" },
grid: { display: false },
},
},
},
});
}
renderDonuts() {
const totals = this.stats.totals_aggregated || {};
// Source donut
const sourceCtx = this.shadowRoot.getElementById("source-donut");
if (sourceCtx) {
if (this.charts.sourceDonut) this.charts.sourceDonut.destroy();
this.charts.sourceDonut = new Chart(sourceCtx, {
type: "doughnut",
data: {
labels: ["WhatsApp", "Web"],
datasets: [{
data: [totals.by_source?.whatsapp || 0, totals.by_source?.web || 0],
backgroundColor: ["#25D366", "#3b82f6"],
}],
},
options: this.getDonutOptions(),
});
}
// Shipping donut
const shippingCtx = this.shadowRoot.getElementById("shipping-donut");
if (shippingCtx) {
if (this.charts.shippingDonut) this.charts.shippingDonut.destroy();
this.charts.shippingDonut = new Chart(shippingCtx, {
type: "doughnut",
data: {
labels: ["Delivery", "Retiro"],
datasets: [{
data: [totals.by_shipping?.delivery || 0, totals.by_shipping?.pickup || 0],
backgroundColor: ["#8B5CF6", "#F59E0B"],
}],
},
options: this.getDonutOptions(),
});
}
// Payment donut
const paymentCtx = this.shadowRoot.getElementById("payment-donut");
if (paymentCtx) {
if (this.charts.paymentDonut) this.charts.paymentDonut.destroy();
this.charts.paymentDonut = new Chart(paymentCtx, {
type: "doughnut",
data: {
labels: ["Efectivo", "Tarjeta"],
datasets: [{
data: [totals.by_payment?.cash || 0, totals.by_payment?.card || 0],
backgroundColor: ["#10B981", "#EC4899"],
}],
},
options: this.getDonutOptions(),
});
}
}
getDonutOptions() {
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom",
labels: { color: "#8aa0b5" },
},
},
};
}
renderProductsChart() {
const ctx = this.shadowRoot.getElementById("products-chart");
if (!ctx) return;
if (this.charts.products) this.charts.products.destroy();
const products = this.stats.top_products_revenue || [];
const labels = products.map(p => p.name?.slice(0, 30) || "Sin nombre");
const data = products.map(p => p.revenue || 0);
this.charts.products = new Chart(ctx, {
type: "bar",
data: {
labels,
datasets: [{
label: "Facturación",
data,
backgroundColor: "rgba(59, 130, 246, 0.8)",
borderColor: "#3b82f6",
borderWidth: 1,
}],
},
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
x: {
beginAtZero: true,
ticks: { color: "#8aa0b5" },
grid: { color: "#1e2a3a" },
},
y: {
ticks: { color: "#8aa0b5" },
grid: { display: false },
},
},
},
});
}
renderKgChart() {
const ctx = this.shadowRoot.getElementById("kg-chart");
if (!ctx) return;
if (this.charts.kg) this.charts.kg.destroy();
const products = this.stats.top_products_kg || [];
const labels = products.map(p => p.name?.slice(0, 25) || "Sin nombre");
const data = products.map(p => p.kg || 0);
this.charts.kg = new Chart(ctx, {
type: "bar",
data: {
labels,
datasets: [{
label: "Kg",
data,
backgroundColor: "rgba(139, 92, 246, 0.8)",
borderColor: "#8B5CF6",
borderWidth: 1,
}],
},
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
x: {
beginAtZero: true,
ticks: { color: "#8aa0b5" },
grid: { color: "#1e2a3a" },
},
y: {
ticks: { color: "#8aa0b5", font: { size: 10 } },
grid: { display: false },
},
},
},
});
}
renderUnitsChart() {
const ctx = this.shadowRoot.getElementById("units-chart");
if (!ctx) return;
if (this.charts.units) this.charts.units.destroy();
const products = this.stats.top_products_units || [];
const labels = products.map(p => p.name?.slice(0, 25) || "Sin nombre");
const data = products.map(p => p.units || 0);
this.charts.units = new Chart(ctx, {
type: "bar",
data: {
labels,
datasets: [{
label: "Unidades",
data,
backgroundColor: "rgba(245, 158, 11, 0.8)",
borderColor: "#F59E0B",
borderWidth: 1,
}],
},
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
x: {
beginAtZero: true,
ticks: { color: "#8aa0b5" },
grid: { color: "#1e2a3a" },
},
y: {
ticks: { color: "#8aa0b5", font: { size: 10 } },
grid: { display: false },
},
},
},
});
}
}
customElements.define("home-dashboard", HomeDashboard);

View File

@@ -54,7 +54,8 @@ class OpsShell extends HTMLElement {
<header>
<h1>Bot Ops Console</h1>
<nav class="nav">
<a class="nav-btn active" href="/chat" data-view="chat">Chat</a>
<a class="nav-btn active" href="/home" data-view="home">Home</a>
<a class="nav-btn" href="/chat" data-view="chat">Chat</a>
<a class="nav-btn" href="/conversaciones" data-view="conversations">Conversaciones</a>
<a class="nav-btn" href="/usuarios" data-view="users">Usuarios</a>
<a class="nav-btn" href="/productos" data-view="products">Productos</a>
@@ -74,7 +75,13 @@ class OpsShell extends HTMLElement {
<div class="status" id="sseStatus">SSE: connecting…</div>
</header>
<div id="viewChat" class="view active">
<div id="viewHome" class="view active">
<div class="layout-crud">
<home-dashboard></home-dashboard>
</div>
</div>
<div id="viewChat" class="view">
<div class="layout-chat">
<div class="col chatTop"><run-timeline></run-timeline></div>
<div class="col inspectorTop"><conversation-inspector></conversation-inspector></div>

View File

@@ -42,6 +42,12 @@ class OrdersCrud extends HTMLElement {
this.selectedOrder = null;
this.loading = false;
// Paginación
this.page = 1;
this.limit = 50;
this.totalPages = 1;
this.totalOrders = 0;
this.shadowRoot.innerHTML = `
<style>
:host {
@@ -214,17 +220,68 @@ class OrdersCrud extends HTMLElement {
text-align: center;
padding: 60px 20px;
}
/* Paginación */
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-top: 1px solid var(--line);
margin-top: auto;
font-size: 12px;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 8px;
}
.pagination-info {
color: var(--muted);
}
.pagination select {
background: var(--bg);
color: var(--text);
border: 1px solid var(--line);
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
}
.pagination select:hover {
border-color: var(--blue);
}
.pagination button {
padding: 4px 10px;
font-size: 11px;
}
</style>
<div class="container">
<div class="panel">
<div class="panel-title">
<span>Pedidos de WooCommerce</span>
<span>Pedidos</span>
<button id="btnRefresh" class="secondary small">Actualizar</button>
</div>
<div class="orders-table" id="ordersTable">
<div class="empty">Cargando pedidos...</div>
</div>
<div class="pagination" id="pagination">
<div class="pagination-controls">
<span>Mostrar:</span>
<select id="limitSelect">
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
</div>
<div class="pagination-controls">
<button id="btnPrev" class="secondary small" disabled>← Anterior</button>
<span class="pagination-info" id="pageInfo">Página 1 de 1</span>
<button id="btnNext" class="secondary small" disabled>Siguiente →</button>
</div>
<div class="pagination-info" id="totalInfo">0 pedidos</div>
</div>
</div>
<div class="panel">
@@ -240,6 +297,25 @@ class OrdersCrud extends HTMLElement {
connectedCallback() {
this.shadowRoot.getElementById("btnRefresh").onclick = () => this.loadOrders();
// Paginación
this.shadowRoot.getElementById("limitSelect").onchange = (e) => {
this.limit = parseInt(e.target.value);
this.page = 1;
this.loadOrders();
};
this.shadowRoot.getElementById("btnPrev").onclick = () => {
if (this.page > 1) {
this.page--;
this.loadOrders();
}
};
this.shadowRoot.getElementById("btnNext").onclick = () => {
if (this.page < this.totalPages) {
this.page++;
this.loadOrders();
}
};
// Escuchar cambios de ruta para deep-linking
this._unsubRouter = on("router:viewChanged", ({ view, params }) => {
if (view === "orders" && params.id) {
@@ -248,7 +324,6 @@ class OrdersCrud extends HTMLElement {
});
// Escuchar nuevos pedidos para actualizar automáticamente
// Usa retry con backoff porque WooCommerce puede tardar en devolver el pedido recién creado
this._unsubOrderCreated = on("order:created", ({ order_id }) => {
console.log("[orders-crud] order:created received, order_id:", order_id);
this.refreshWithRetry(order_id);
@@ -298,9 +373,17 @@ class OrdersCrud extends HTMLElement {
container.innerHTML = `<div class="empty">Cargando pedidos...</div>`;
try {
const result = await api.listRecentOrders({ limit: 50 });
const result = await api.listOrders({ page: this.page, limit: this.limit });
this.orders = result.items || [];
// Actualizar paginación
if (result.pagination) {
this.totalPages = result.pagination.pages || 1;
this.totalOrders = result.pagination.total || 0;
}
this.renderTable();
this.updatePagination();
// Si hay un pedido pendiente de selección (deep-link), seleccionarlo
if (this._pendingOrderId) {
@@ -316,6 +399,22 @@ class OrdersCrud extends HTMLElement {
}
}
updatePagination() {
const pageInfo = this.shadowRoot.getElementById("pageInfo");
const totalInfo = this.shadowRoot.getElementById("totalInfo");
const btnPrev = this.shadowRoot.getElementById("btnPrev");
const btnNext = this.shadowRoot.getElementById("btnNext");
const limitSelect = this.shadowRoot.getElementById("limitSelect");
pageInfo.textContent = `Página ${this.page} de ${this.totalPages}`;
totalInfo.textContent = `${this.totalOrders.toLocaleString("es-AR")} pedidos`;
btnPrev.disabled = this.page <= 1;
btnNext.disabled = this.page >= this.totalPages;
limitSelect.value = String(this.limit);
}
renderTable() {
const container = this.shadowRoot.getElementById("ordersTable");

View File

@@ -7,6 +7,7 @@
</head>
<body>
<ops-shell></ops-shell>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script type="module" src="/app.js"></script>
</body>
</html>

View File

@@ -185,13 +185,23 @@ export const api = {
return fetch("/products/sync-from-woo", { method: "POST" }).then(r => r.json());
},
// --- Testing ---
async listRecentOrders({ limit = 20 } = {}) {
const u = new URL("/test/orders", location.origin);
// --- Orders & Stats ---
async listOrders({ page = 1, limit = 50 } = {}) {
const u = new URL("/api/orders", location.origin);
u.searchParams.set("page", String(page));
u.searchParams.set("limit", String(limit));
return fetch(u).then(r => r.json());
},
async getOrderStats() {
return fetch("/api/stats/orders").then(r => r.json());
},
// Alias para compatibilidad
async listRecentOrders({ limit = 20 } = {}) {
return this.listOrders({ page: 1, limit });
},
async getProductsWithStock() {
return fetch("/test/products-with-stock").then(r => r.json());
},

View File

@@ -2,7 +2,8 @@ import { emit } from "./bus.js";
// Mapeo de rutas a vistas
const ROUTES = [
{ pattern: /^\/$/, view: "chat", params: [] },
{ pattern: /^\/$/, view: "home", params: [] },
{ pattern: /^\/home$/, view: "home", params: [] },
{ pattern: /^\/chat$/, view: "chat", params: [] },
{ pattern: /^\/conversaciones$/, view: "conversations", params: [] },
{ pattern: /^\/usuarios$/, view: "users", params: [] },
@@ -23,6 +24,7 @@ const ROUTES = [
// Mapeo de vistas a rutas base (para navegación sin parámetros)
const VIEW_TO_PATH = {
home: "/home",
chat: "/chat",
conversations: "/conversaciones",
users: "/usuarios",
@@ -54,8 +56,8 @@ export function parseRoute(pathname) {
}
}
// Fallback a chat si no matchea ninguna ruta
return { view: "chat", params: {} };
// Fallback a home si no matchea ninguna ruta
return { view: "home", params: {} };
}
/**

View File

@@ -0,0 +1,360 @@
/**
* Migración directa de pedidos WooCommerce (MySQL) a cache local (PostgreSQL)
*
* WooCommerce 8.x+ usa HPOS (High Performance Order Storage)
*
* Uso:
* node scripts/migrate-woo-orders.mjs [--tenant-id=xxx] [--batch-size=500] [--dry-run]
*
* Requiere en .env:
* WOO_MYSQL_HOST, WOO_MYSQL_PORT, WOO_MYSQL_USER, WOO_MYSQL_PASSWORD, WOO_MYSQL_DATABASE
* WOO_TABLE_PREFIX (default: wp_)
* DATABASE_URL (PostgreSQL)
*/
import mysql from "mysql2/promise";
import pg from "pg";
import "dotenv/config";
const { Pool } = pg;
// --- Configuración ---
const TENANT_ID = process.argv.find(a => a.startsWith("--tenant-id="))?.split("=")[1]
|| process.env.DEFAULT_TENANT_ID
|| "eb71b9a7-9ccf-430e-9b25-951a0c589c0f"; // tenant de piaf
const BATCH_SIZE = parseInt(process.argv.find(a => a.startsWith("--batch-size="))?.split("=")[1] || "500", 10);
const DRY_RUN = process.argv.includes("--dry-run");
const TABLE_PREFIX = process.env.WOO_TABLE_PREFIX || "wp_";
// --- Conexiones ---
let mysqlConn;
let pgPool;
async function connect() {
console.log("[migrate] Conectando a MySQL...");
mysqlConn = await mysql.createConnection({
host: process.env.WOO_MYSQL_HOST,
port: parseInt(process.env.WOO_MYSQL_PORT || "3306", 10),
user: process.env.WOO_MYSQL_USER,
password: process.env.WOO_MYSQL_PASSWORD,
database: process.env.WOO_MYSQL_DATABASE,
rowsAsArray: false,
});
console.log("[migrate] MySQL conectado");
console.log("[migrate] Conectando a PostgreSQL...");
pgPool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 5,
});
await pgPool.query("SELECT 1");
console.log("[migrate] PostgreSQL conectado");
}
async function disconnect() {
if (mysqlConn) await mysqlConn.end();
if (pgPool) await pgPool.end();
}
// --- Query principal de pedidos (HPOS) ---
function buildOrdersQuery() {
return `
SELECT
o.id as order_id,
o.status,
o.currency,
o.total_amount as total,
o.date_created_gmt as date_created,
o.date_paid_gmt as date_paid,
o.payment_method,
o.payment_method_title,
-- Billing
ba.first_name as billing_first_name,
ba.last_name as billing_last_name,
ba.address_1 as billing_address_1,
ba.city as billing_city,
ba.state as billing_state,
ba.postcode as billing_postcode,
ba.phone as billing_phone,
ba.email as billing_email,
-- Shipping
sa.first_name as shipping_first_name,
sa.last_name as shipping_last_name,
sa.address_1 as shipping_address_1,
sa.address_2 as shipping_address_2,
sa.city as shipping_city,
sa.state as shipping_state,
sa.postcode as shipping_postcode
FROM ${TABLE_PREFIX}wc_orders o
LEFT JOIN ${TABLE_PREFIX}wc_order_addresses ba
ON ba.order_id = o.id AND ba.address_type = 'billing'
LEFT JOIN ${TABLE_PREFIX}wc_order_addresses sa
ON sa.order_id = o.id AND sa.address_type = 'shipping'
WHERE o.type = 'shop_order'
ORDER BY o.id ASC
`;
}
// --- Query de items por pedido ---
async function getOrderItems(orderId) {
const [items] = await mysqlConn.query(`
SELECT
oi.order_item_id,
oi.order_item_name as product_name,
MAX(CASE WHEN oim.meta_key = '_product_id' THEN oim.meta_value END) as product_id,
MAX(CASE WHEN oim.meta_key = '_variation_id' THEN oim.meta_value END) as variation_id,
MAX(CASE WHEN oim.meta_key = '_qty' THEN oim.meta_value END) as quantity,
MAX(CASE WHEN oim.meta_key = '_line_total' THEN oim.meta_value END) as line_total,
MAX(CASE WHEN oim.meta_key = '_line_subtotal' THEN oim.meta_value END) as line_subtotal,
MAX(CASE WHEN oim.meta_key = 'unit' THEN oim.meta_value END) as unit,
MAX(CASE WHEN oim.meta_key = 'weight_g' THEN oim.meta_value END) as weight_g
FROM ${TABLE_PREFIX}woocommerce_order_items oi
LEFT JOIN ${TABLE_PREFIX}woocommerce_order_itemmeta oim ON oim.order_item_id = oi.order_item_id
WHERE oi.order_id = ? AND oi.order_item_type = 'line_item'
GROUP BY oi.order_item_id, oi.order_item_name
`, [orderId]);
return items;
}
// --- Query de metadata por pedido (source, shipping_method, etc) ---
async function getOrderMeta(orderId) {
const [rows] = await mysqlConn.query(`
SELECT meta_key, meta_value
FROM ${TABLE_PREFIX}wc_orders_meta
WHERE order_id = ?
AND meta_key IN ('source', 'shipping_method', 'payment_method_wa', 'run_id')
`, [orderId]);
const meta = {};
for (const row of rows) {
meta[row.meta_key] = row.meta_value;
}
return meta;
}
// --- Detectar source y flags ---
function detectOrderFlags(order, meta) {
// Source
const source = meta.source || "web";
// isDelivery
const shippingMethod = meta.shipping_method || "";
const isDelivery = shippingMethod === "delivery" ||
(!shippingMethod.toLowerCase().includes("retiro") &&
!shippingMethod.toLowerCase().includes("pickup") &&
!shippingMethod.toLowerCase().includes("local") &&
order.shipping_address_1); // Si tiene dirección de envío
// isCash
const metaPayment = meta.payment_method_wa || "";
const isCash = metaPayment === "cash" ||
order.payment_method === "cod" ||
(order.payment_method_title || "").toLowerCase().includes("efectivo");
return { source, isDelivery, isCash };
}
// --- Detectar sell_unit del item ---
function detectSellUnit(item) {
if (item.unit === "g" || item.unit === "kg") return "kg";
if (item.unit === "unit") return "unit";
if (item.weight_g) return "kg";
const name = (item.product_name || "").toLowerCase();
if (name.includes(" kg") || name.includes("kilo")) return "kg";
return "unit";
}
// --- Insert en PostgreSQL (batch con transacción) ---
async function insertOrderBatch(orders) {
if (DRY_RUN || orders.length === 0) return;
const client = await pgPool.connect();
try {
await client.query("BEGIN");
for (const order of orders) {
// Upsert pedido
await client.query(`
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,
updated_at = NOW()
`, [
TENANT_ID,
order.order_id,
order.status?.replace("wc-", "") || "pending",
parseFloat(order.total) || 0,
order.currency || "ARS",
order.date_created,
order.date_paid,
order.source,
order.isDelivery,
order.isCash,
`${order.billing_first_name || ""} ${order.billing_last_name || ""}`.trim(),
order.billing_phone,
order.billing_email,
order.shipping_address_1,
order.shipping_address_2,
order.shipping_city,
order.shipping_state,
order.shipping_postcode,
"AR",
order.billing_address_1,
order.billing_city,
order.billing_state,
order.billing_postcode,
JSON.stringify({}), // raw simplificado para ahorrar espacio
]);
// Delete + insert items
await client.query(
`DELETE FROM woo_order_items WHERE tenant_id = $1 AND woo_order_id = $2`,
[TENANT_ID, order.order_id]
);
if (order.items && order.items.length > 0) {
const itemValues = order.items.map(it => [
TENANT_ID,
order.order_id,
it.product_id || it.variation_id,
it.product_name,
null, // sku
parseFloat(it.quantity) || 0,
it.line_subtotal ? parseFloat(it.line_subtotal) / (parseFloat(it.quantity) || 1) : null,
parseFloat(it.line_total) || 0,
detectSellUnit(it),
]);
for (const vals of itemValues) {
await client.query(`
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)
`, vals);
}
}
}
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
// --- Main ---
async function main() {
console.log("=".repeat(60));
console.log("[migrate] Migración WooCommerce (MySQL) -> PostgreSQL");
console.log(`[migrate] Tenant: ${TENANT_ID}`);
console.log(`[migrate] Batch size: ${BATCH_SIZE}`);
console.log(`[migrate] Table prefix: ${TABLE_PREFIX}`);
console.log(`[migrate] Dry run: ${DRY_RUN}`);
console.log("=".repeat(60));
await connect();
// Contar total de pedidos
const [[{ total }]] = await mysqlConn.query(`
SELECT COUNT(*) as total
FROM ${TABLE_PREFIX}wc_orders
WHERE type = 'shop_order'
`);
console.log(`[migrate] Total pedidos en WooCommerce: ${total}`);
// Limpiar cache existente si no es dry run
if (!DRY_RUN) {
console.log("[migrate] Limpiando cache existente...");
await pgPool.query(`DELETE FROM woo_order_items WHERE tenant_id = $1`, [TENANT_ID]);
await pgPool.query(`DELETE FROM woo_orders_cache WHERE tenant_id = $1`, [TENANT_ID]);
console.log("[migrate] Cache limpiado");
}
// Query de pedidos
console.log("[migrate] Iniciando migración...");
const [ordersRows] = await mysqlConn.query(buildOrdersQuery());
let count = 0;
let batch = [];
const startTime = Date.now();
for (const row of ordersRows) {
// Obtener items y metadata
const [items, meta] = await Promise.all([
getOrderItems(row.order_id),
getOrderMeta(row.order_id),
]);
const flags = detectOrderFlags(row, meta);
batch.push({
...row,
...flags,
items,
});
count++;
// Insert batch
if (batch.length >= BATCH_SIZE) {
await insertOrderBatch(batch);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
const rate = (count / elapsed).toFixed(0);
const pct = ((count / total) * 100).toFixed(1);
console.log(`[migrate] Progreso: ${count}/${total} (${pct}%) - ${rate} pedidos/s`);
batch = [];
}
}
// Último batch
if (batch.length > 0) {
await insertOrderBatch(batch);
}
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
console.log("=".repeat(60));
console.log(`[migrate] COMPLETADO`);
console.log(`[migrate] Pedidos migrados: ${count}`);
console.log(`[migrate] Tiempo total: ${totalTime}s`);
console.log(`[migrate] Velocidad promedio: ${(count / totalTime).toFixed(0)} pedidos/s`);
console.log("=".repeat(60));
await disconnect();
}
main().catch(err => {
console.error("[migrate] ERROR:", err);
disconnect().finally(() => process.exit(1));
});

View File

@@ -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" });
}
};

View 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,
};
}

View File

@@ -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),
},
};
}
/**

View File

@@ -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));

View 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,
};
}

View File

@@ -1,10 +1,15 @@
import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js";
import { debug as dbg } from "../shared/debug.js";
import { getSnapshotPriceByWooId } from "../shared/wooSnapshot.js";
import * as ordersRepo from "./ordersRepo.js";
// --- Simple in-memory lock to serialize work per key ---
const locks = new Map();
// --- Sync lock per tenant to prevent concurrent syncs ---
const syncLocks = new Map();
const syncInProgress = new Map();
async function withLock(key, fn) {
const prev = locks.get(key) || Promise.resolve();
let release;
@@ -310,95 +315,233 @@ export async function listRecentOrders({ tenantId, limit = 20 }) {
if (!Array.isArray(data)) return [];
// Mapear a formato simplificado
return data.map(order => {
// Detectar si es orden de test (run_id empieza con "test-")
const runIdMeta = order.meta_data?.find(m => m.key === "run_id");
const runId = runIdMeta?.value || null;
const isTest = runId?.startsWith("test-") || false;
const sourceMeta = order.meta_data?.find(m => m.key === "source");
const source = sourceMeta?.value || "web";
return data.map(order => normalizeWooOrder(order));
}
// 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");
/**
* 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`;
// 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 };
}