From df9420b9549e544f7dc52b314787da3efe62bf03 Mon Sep 17 00:00:00 2001 From: Lucas Tettamanti <757326+lkzwieder@users.noreply.github.com> Date: Tue, 27 Jan 2026 02:41:39 -0300 Subject: [PATCH] dashboard --- .../20260127100000_woo_orders_cache.sql | 90 +++ docker-compose.override.yaml | 59 ++ docker-compose.yaml | 4 +- package-lock.json | 117 ++++ package.json | 1 + public/app.js | 1 + public/components/home-dashboard.js | 622 ++++++++++++++++++ public/components/ops-shell.js | 11 +- public/components/orders-crud.js | 105 ++- public/index.html | 1 + public/lib/api.js | 16 +- public/lib/router.js | 8 +- scripts/migrate-woo-orders.mjs | 360 ++++++++++ src/modules/0-ui/controllers/testing.js | 23 +- src/modules/0-ui/handlers/stats.js | 43 ++ src/modules/0-ui/handlers/testing.js | 25 +- src/modules/1-intake/routes/simulator.js | 7 +- src/modules/4-woo-orders/ordersRepo.js | 408 ++++++++++++ src/modules/4-woo-orders/wooOrders.js | 315 ++++++--- 19 files changed, 2105 insertions(+), 111 deletions(-) create mode 100644 db/migrations/20260127100000_woo_orders_cache.sql create mode 100644 docker-compose.override.yaml create mode 100644 public/components/home-dashboard.js create mode 100644 scripts/migrate-woo-orders.mjs create mode 100644 src/modules/0-ui/handlers/stats.js create mode 100644 src/modules/4-woo-orders/ordersRepo.js diff --git a/db/migrations/20260127100000_woo_orders_cache.sql b/db/migrations/20260127100000_woo_orders_cache.sql new file mode 100644 index 0000000..5197a57 --- /dev/null +++ b/db/migrations/20260127100000_woo_orders_cache.sql @@ -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; diff --git a/docker-compose.override.yaml b/docker-compose.override.yaml new file mode 100644 index 0000000..c6e2e39 --- /dev/null +++ b/docker-compose.override.yaml @@ -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: diff --git a/docker-compose.yaml b/docker-compose.yaml index c6e2e39..9c8342b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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: diff --git a/package-lock.json b/package-lock.json index 6a35e5d..0372d54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2238de9..7fd888e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/app.js b/public/app.js index 633fd08..fd21d20 100644 --- a/public/app.js +++ b/public/app.js @@ -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"; diff --git a/public/components/home-dashboard.js b/public/components/home-dashboard.js new file mode 100644 index 0000000..5461104 --- /dev/null +++ b/public/components/home-dashboard.js @@ -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 = ` + +
+
+

Dashboard de Ventas

+
+
+
+
+
+
Ventas Totales por Mes
+
+ +
+
+
+
Web vs WhatsApp
+
+ +
+
+
+
Comparativa Año a Año
+
+ +
+
+
+
+
Por Canal
+
+ +
+
+
+
Delivery vs Retiro
+
+ +
+
+
+
Efectivo vs Tarjeta
+
+ +
+
+
+
+
Top Productos por Facturación
+
+ +
+
+
+
Top por Kg Vendidos
+
+ +
+
+
+
Top por Unidades
+
+ +
+
+
+
+ `; + } + + 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 = ` +
+
${formatCurrency(totals.total_revenue)}
+
Total Facturado
+
+
+
${formatNumber(totals.total_orders)}
+
Pedidos
+
+
+
${formatCurrency(totals.by_source?.whatsapp)}
+
WhatsApp
+
+
+
${formatCurrency(totals.by_source?.web)}
+
Web
+
+ `; + } + + 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); diff --git a/public/components/ops-shell.js b/public/components/ops-shell.js index c4716bd..c9fee78 100644 --- a/public/components/ops-shell.js +++ b/public/components/ops-shell.js @@ -54,7 +54,8 @@ class OpsShell extends HTMLElement {

Bot Ops Console

-
+
+
+ +
+
+ +
diff --git a/public/components/orders-crud.js b/public/components/orders-crud.js index 18f34db..576ca59 100644 --- a/public/components/orders-crud.js +++ b/public/components/orders-crud.js @@ -41,6 +41,12 @@ class OrdersCrud extends HTMLElement { this.orders = []; this.selectedOrder = null; this.loading = false; + + // Paginación + this.page = 1; + this.limit = 50; + this.totalPages = 1; + this.totalOrders = 0; this.shadowRoot.innerHTML = `
- Pedidos de WooCommerce + Pedidos
Cargando pedidos...
+
@@ -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 = `
Cargando pedidos...
`; 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"); diff --git a/public/index.html b/public/index.html index 163e23e..03cc8e9 100644 --- a/public/index.html +++ b/public/index.html @@ -7,6 +7,7 @@ + diff --git a/public/lib/api.js b/public/lib/api.js index 138579a..65d5af6 100644 --- a/public/lib/api.js +++ b/public/lib/api.js @@ -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()); }, diff --git a/public/lib/router.js b/public/lib/router.js index 523b332..3b577cc 100644 --- a/public/lib/router.js +++ b/public/lib/router.js @@ -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: {} }; } /** diff --git a/scripts/migrate-woo-orders.mjs b/scripts/migrate-woo-orders.mjs new file mode 100644 index 0000000..923a03b --- /dev/null +++ b/scripts/migrate-woo-orders.mjs @@ -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)); +}); diff --git a/src/modules/0-ui/controllers/testing.js b/src/modules/0-ui/controllers/testing.js index 50484d2..ff0da29 100644 --- a/src/modules/0-ui/controllers/testing.js +++ b/src/modules/0-ui/controllers/testing.js @@ -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" }); } }; diff --git a/src/modules/0-ui/handlers/stats.js b/src/modules/0-ui/handlers/stats.js new file mode 100644 index 0000000..359864a --- /dev/null +++ b/src/modules/0-ui/handlers/stats.js @@ -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, + }; +} diff --git a/src/modules/0-ui/handlers/testing.js b/src/modules/0-ui/handlers/testing.js index d433690..8225caa 100644 --- a/src/modules/0-ui/handlers/testing.js +++ b/src/modules/0-ui/handlers/testing.js @@ -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), + }, + }; } /** diff --git a/src/modules/1-intake/routes/simulator.js b/src/modules/1-intake/routes/simulator.js index eeeb12a..8d6121d 100644 --- a/src/modules/1-intake/routes/simulator.js +++ b/src/modules/1-intake/routes/simulator.js @@ -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)); diff --git a/src/modules/4-woo-orders/ordersRepo.js b/src/modules/4-woo-orders/ordersRepo.js new file mode 100644 index 0000000..3904767 --- /dev/null +++ b/src/modules/4-woo-orders/ordersRepo.js @@ -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, + }; +} diff --git a/src/modules/4-woo-orders/wooOrders.js b/src/modules/4-woo-orders/wooOrders.js index fca78f3..62d7416 100644 --- a/src/modules/4-woo-orders/wooOrders.js +++ b/src/modules/4-woo-orders/wooOrders.js @@ -1,10 +1,15 @@ import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js"; import { debug as dbg } from "../shared/debug.js"; import { getSnapshotPriceByWooId } from "../shared/wooSnapshot.js"; +import * as ordersRepo from "./ordersRepo.js"; // --- Simple in-memory lock to serialize work per key --- const locks = new Map(); +// --- Sync lock per tenant to prevent concurrent syncs --- +const syncLocks = new Map(); +const syncInProgress = new Map(); + async function withLock(key, fn) { const prev = locks.get(key) || Promise.resolve(); let release; @@ -310,95 +315,233 @@ export async function listRecentOrders({ tenantId, limit = 20 }) { if (!Array.isArray(data)) return []; // Mapear a formato simplificado - return data.map(order => { - // Detectar si es orden de test (run_id empieza con "test-") - const runIdMeta = order.meta_data?.find(m => m.key === "run_id"); - const runId = runIdMeta?.value || null; - const isTest = runId?.startsWith("test-") || false; - const sourceMeta = order.meta_data?.find(m => m.key === "source"); - const source = sourceMeta?.value || "web"; + return data.map(order => normalizeWooOrder(order)); +} + +/** + * Normaliza un pedido de WooCommerce a formato interno + */ +function normalizeWooOrder(order) { + // Detectar si es orden de test (run_id empieza con "test-") + const runIdMeta = order.meta_data?.find(m => m.key === "run_id"); + const runId = runIdMeta?.value || null; + const isTest = runId?.startsWith("test-") || false; + const sourceMeta = order.meta_data?.find(m => m.key === "source"); + const source = sourceMeta?.value || "web"; + + // Método de envío (shipping) + const metaShippingMethod = order.meta_data?.find(m => m.key === "shipping_method")?.value || null; + const shippingLines = order.shipping_lines || []; + const wooShippingMethod = shippingLines[0]?.method_title || shippingLines[0]?.method_id || null; + const shippingMethod = metaShippingMethod || wooShippingMethod; + + let isDelivery = false; + if (metaShippingMethod) { + isDelivery = metaShippingMethod === "delivery"; + } else if (wooShippingMethod) { + isDelivery = !wooShippingMethod.toLowerCase().includes("retiro") && + !wooShippingMethod.toLowerCase().includes("pickup") && + !wooShippingMethod.toLowerCase().includes("local"); + } + + // Método de pago + const metaPaymentMethod = order.meta_data?.find(m => m.key === "payment_method_wa")?.value || null; + const paymentMethod = order.payment_method || null; + const paymentMethodTitle = order.payment_method_title || null; + const isCash = metaPaymentMethod === "cash" || + paymentMethod === "cod" || + paymentMethodTitle?.toLowerCase().includes("efectivo") || + paymentMethodTitle?.toLowerCase().includes("cash"); + + const isPaid = ["processing", "completed", "on-hold"].includes(order.status); + const datePaid = order.date_paid || null; + + return { + id: order.id, + status: order.status, + total: order.total, + currency: order.currency, + date_created: order.date_created, + date_paid: datePaid, + billing: { + first_name: order.billing?.first_name || "", + last_name: order.billing?.last_name || "", + phone: order.billing?.phone || "", + email: order.billing?.email || "", + address_1: order.billing?.address_1 || "", + address_2: order.billing?.address_2 || "", + city: order.billing?.city || "", + state: order.billing?.state || "", + postcode: order.billing?.postcode || "", + }, + shipping: { + first_name: order.shipping?.first_name || "", + last_name: order.shipping?.last_name || "", + address_1: order.shipping?.address_1 || "", + address_2: order.shipping?.address_2 || "", + city: order.shipping?.city || "", + state: order.shipping?.state || "", + postcode: order.shipping?.postcode || "", + }, + line_items: (order.line_items || []).map(li => ({ + id: li.id, + name: li.name, + product_id: li.product_id, + variation_id: li.variation_id, + quantity: li.quantity, + total: li.total, + subtotal: li.subtotal, + sku: li.sku, + meta_data: li.meta_data, + })), + source, + run_id: runId, + is_test: isTest, + shipping_method: shippingMethod, + is_delivery: isDelivery, + payment_method: paymentMethod, + payment_method_title: paymentMethodTitle, + is_cash: isCash, + is_paid: isPaid, + raw: order, + }; +} + +/** + * Detecta la unidad de venta de un line item + */ +function detectSellUnit(lineItem) { + // 1. Buscar en meta_data + const unitMeta = lineItem.meta_data?.find(m => m.key === "unit"); + if (unitMeta?.value === "g" || unitMeta?.value === "kg") return "kg"; + if (unitMeta?.value === "unit") return "unit"; + + // 2. Detectar por nombre del producto + const name = (lineItem.name || "").toLowerCase(); + if (name.includes("kg") || name.includes("kilo")) return "kg"; + + // 3. Default a unit + return "unit"; +} + +/** + * Sincroniza pedidos de WooCommerce a la cache local (incremental) + * Usa un lock por tenant para evitar syncs concurrentes + */ +export async function syncOrdersIncremental({ tenantId }) { + // Si ya hay un sync en progreso para este tenant, esperar a que termine + const existingPromise = syncInProgress.get(tenantId); + if (existingPromise) { + console.log(`[wooOrders] syncOrdersIncremental already in progress for tenant ${tenantId}, waiting...`); + return existingPromise; + } + + // Crear promise para este sync y registrarla + const syncPromise = doSyncOrdersIncremental({ tenantId }); + syncInProgress.set(tenantId, syncPromise); + + try { + const result = await syncPromise; + return result; + } finally { + syncInProgress.delete(tenantId); + } +} + +/** + * Implementación interna del sync (sin lock) + * Procesa e inserta página por página para: + * - Bajo consumo de RAM (solo ~100 pedidos en memoria a la vez) + * - Resiliencia a cortes (progreso se guarda en DB) + * - Sync incremental real (puede resumir desde donde quedó) + */ +async function doSyncOrdersIncremental({ tenantId }) { + const client = await getWooClient({ tenantId }); + const latestDate = await ordersRepo.getLatestOrderDate({ tenantId }); + + let synced = 0; + let page = 1; + const perPage = 100; // Máximo permitido por Woo + + console.log(`[wooOrders] syncOrdersIncremental starting, latestDate: ${latestDate || 'none (full sync)'}`); + + while (true) { + // Construir URL con paginación + let url = `${client.base}/orders?per_page=${perPage}&page=${page}&orderby=date&order=desc`; - // Método de envío (shipping) - // 1. Primero intentar leer de metadata (para pedidos de WhatsApp) - const metaShippingMethod = order.meta_data?.find(m => m.key === "shipping_method")?.value || null; - // 2. Fallback a shipping_lines de WooCommerce (para pedidos web) - const shippingLines = order.shipping_lines || []; - const wooShippingMethod = shippingLines[0]?.method_title || shippingLines[0]?.method_id || null; - // 3. Usar metadata si existe, sino WooCommerce - const shippingMethod = metaShippingMethod || wooShippingMethod; - // 4. Determinar isDelivery - let isDelivery = false; - if (metaShippingMethod) { - // Si viene de metadata, "delivery" = true, "pickup" = false - isDelivery = metaShippingMethod === "delivery"; - } else if (wooShippingMethod) { - // Si viene de WooCommerce, detectar por nombre - isDelivery = !wooShippingMethod.toLowerCase().includes("retiro") && - !wooShippingMethod.toLowerCase().includes("pickup") && - !wooShippingMethod.toLowerCase().includes("local"); + // Si tenemos fecha, filtrar solo los más recientes + if (latestDate) { + const afterDate = new Date(latestDate); + afterDate.setMinutes(afterDate.getMinutes() - 1); + url += `&after=${afterDate.toISOString()}`; } - // Método de pago - // 1. Primero intentar leer de metadata (para pedidos de WhatsApp) - const metaPaymentMethod = order.meta_data?.find(m => m.key === "payment_method_wa")?.value || null; - // 2. Luego de los campos estándar de WooCommerce - const paymentMethod = order.payment_method || null; - const paymentMethodTitle = order.payment_method_title || null; - // 3. Determinar si es cash - const isCash = metaPaymentMethod === "cash" || - paymentMethod === "cod" || - paymentMethodTitle?.toLowerCase().includes("efectivo") || - paymentMethodTitle?.toLowerCase().includes("cash"); + const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader }); - // Estado de pago (basado en status de la orden) - // pending = no pago, processing/completed = pago - const isPaid = ["processing", "completed", "on-hold"].includes(order.status); - const datePaid = order.date_paid || null; + if (!Array.isArray(data) || data.length === 0) { + break; + } - return { - id: order.id, - status: order.status, - total: order.total, - currency: order.currency, - date_created: order.date_created, - date_paid: datePaid, - billing: { - first_name: order.billing?.first_name || "", - last_name: order.billing?.last_name || "", - phone: order.billing?.phone || "", - email: order.billing?.email || "", - address_1: order.billing?.address_1 || "", - address_2: order.billing?.address_2 || "", - city: order.billing?.city || "", - state: order.billing?.state || "", - postcode: order.billing?.postcode || "", - }, - shipping: { - first_name: order.shipping?.first_name || "", - last_name: order.shipping?.last_name || "", - address_1: order.shipping?.address_1 || "", - address_2: order.shipping?.address_2 || "", - city: order.shipping?.city || "", - state: order.shipping?.state || "", - postcode: order.shipping?.postcode || "", - }, - line_items: (order.line_items || []).map(li => ({ - id: li.id, - name: li.name, - quantity: li.quantity, - total: li.total, - })), - source, - run_id: runId, - is_test: isTest, - // Shipping info - shipping_method: shippingMethod, - is_delivery: isDelivery, - // Payment info - payment_method: paymentMethod, - payment_method_title: paymentMethodTitle, - is_cash: isCash, - is_paid: isPaid, - }; - }); + // Procesar e insertar INMEDIATAMENTE esta página + for (const rawOrder of data) { + const order = normalizeWooOrder(rawOrder); + + const cacheOrder = { + woo_order_id: order.id, + status: order.status, + total: parseFloat(order.total) || 0, + currency: order.currency, + date_created: order.date_created, + date_paid: order.date_paid, + source: order.source, + is_delivery: order.is_delivery, + is_cash: order.is_cash, + customer_name: `${order.billing.first_name} ${order.billing.last_name}`.trim(), + customer_phone: order.billing.phone, + customer_email: order.billing.email, + shipping_address_1: order.shipping.address_1, + shipping_address_2: order.shipping.address_2, + shipping_city: order.shipping.city, + shipping_state: order.shipping.state, + shipping_postcode: order.shipping.postcode, + shipping_country: "AR", + billing_address_1: order.billing.address_1, + billing_city: order.billing.city, + billing_state: order.billing.state, + billing_postcode: order.billing.postcode, + raw: order.raw, + }; + + await ordersRepo.upsertOrder({ tenantId, order: cacheOrder }); + + const items = order.line_items.map(li => ({ + woo_product_id: li.product_id || li.variation_id, + product_name: li.name, + sku: li.sku, + quantity: parseFloat(li.quantity) || 0, + unit_price: li.subtotal ? parseFloat(li.subtotal) / (parseFloat(li.quantity) || 1) : null, + line_total: parseFloat(li.total) || 0, + sell_unit: detectSellUnit(li), + })); + + await ordersRepo.upsertOrderItems({ tenantId, wooOrderId: order.id, items }); + synced++; + } + + // Log de progreso después de insertar + const totalInCache = await ordersRepo.countOrders({ tenantId }); + console.log(`[wooOrders] syncOrdersIncremental page ${page}: +${data.length} orders (${totalInCache} total in DB)`); + + // Si es última página + if (data.length < perPage) { + break; + } + + page++; + } + + const totalInCache = await ordersRepo.countOrders({ tenantId }); + console.log(`[wooOrders] syncOrdersIncremental completed: ${synced} synced, ${totalInCache} total in cache`); + + return { synced, total: totalInCache }; }