Compare commits

7 Commits

Author SHA1 Message Date
Lucas Tettamanti
b933db88df remove database_url 2026-02-04 18:36:28 -03:00
Lucas Tettamanti
d8a0677912 more work with Dockerfile and dbmate 2026-02-04 18:16:32 -03:00
Lucas Tettamanti
f838603877 Docker compose and coolify solved 2026-02-04 17:59:30 -03:00
Lucas Tettamanti
5e79f17d00 20260204 2026-02-04 16:06:51 -03:00
Lucas Tettamanti
2f8e267268 docker compose override for local without affecting coolify 2026-01-27 03:13:44 -03:00
Lucas Tettamanti
1e84d19db8 configs 2026-01-27 02:59:31 -03:00
Lucas Tettamanti
df9420b954 dashboard 2026-01-27 02:41:39 -03:00
39 changed files with 2511 additions and 722 deletions

18
.cursor/debug.log Normal file
View File

@@ -0,0 +1,18 @@
{"location":"orders-crud.js:row.onclick","message":"Row clicked","data":{"datasetOrderId":"40923"},"timestamp":1770234345333,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"orders-crud.js:selectOrder","message":"selectOrder called","data":{"orderId":"40923","updateUrl":true},"timestamp":1770234345334,"sessionId":"debug-session","hypothesisId":"D"}
{"location":"orders-crud.js:typeCheck","message":"Type comparison","data":{"orderIdType":"number","orderId":40923,"firstOrderIdType":"string","firstOrderId":"40928","strictEqual":false,"looseEqual":false},"timestamp":1770234345334,"sessionId":"debug-session","hypothesisId":"C2"}
{"location":"orders-crud.js:findOrder","message":"Order lookup","data":{"orderId":40923,"found":true,"ordersCount":50},"timestamp":1770234345334,"sessionId":"debug-session","hypothesisId":"C"}
{"location":"orders-crud.js:parseInt","message":"Parsed orderId","data":{"orderId":40923,"isNaN":false},"timestamp":1770234345334,"sessionId":"debug-session","hypothesisId":"B"}
{"location":"orders-crud.js:renderDetail","message":"renderDetail called","data":{"hasContainer":true,"hasSelectedOrder":true,"selectedOrderId":"40923"},"timestamp":1770234345341,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"orders-crud.js:row.onclick","message":"Row clicked","data":{"datasetOrderId":"40925"},"timestamp":1770234346128,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"orders-crud.js:parseInt","message":"Parsed orderId","data":{"orderId":40925,"isNaN":false},"timestamp":1770234346129,"sessionId":"debug-session","hypothesisId":"B"}
{"location":"orders-crud.js:typeCheck","message":"Type comparison","data":{"orderIdType":"number","orderId":40925,"firstOrderIdType":"string","firstOrderId":"40928","strictEqual":false,"looseEqual":false},"timestamp":1770234346129,"sessionId":"debug-session","hypothesisId":"C2"}
{"location":"orders-crud.js:findOrder","message":"Order lookup","data":{"orderId":40925,"found":true,"ordersCount":50},"timestamp":1770234346129,"sessionId":"debug-session","hypothesisId":"C"}
{"location":"orders-crud.js:selectOrder","message":"selectOrder called","data":{"orderId":"40925","updateUrl":true},"timestamp":1770234346129,"sessionId":"debug-session","hypothesisId":"D"}
{"location":"orders-crud.js:renderDetail","message":"renderDetail called","data":{"hasContainer":true,"hasSelectedOrder":true,"selectedOrderId":"40925"},"timestamp":1770234346135,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"orders-crud.js:row.onclick","message":"Row clicked","data":{"datasetOrderId":"40921"},"timestamp":1770234346848,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"orders-crud.js:typeCheck","message":"Type comparison","data":{"orderIdType":"number","orderId":40921,"firstOrderIdType":"string","firstOrderId":"40928","strictEqual":false,"looseEqual":false},"timestamp":1770234346849,"sessionId":"debug-session","hypothesisId":"C2"}
{"location":"orders-crud.js:findOrder","message":"Order lookup","data":{"orderId":40921,"found":true,"ordersCount":50},"timestamp":1770234346849,"sessionId":"debug-session","hypothesisId":"C"}
{"location":"orders-crud.js:selectOrder","message":"selectOrder called","data":{"orderId":"40921","updateUrl":true},"timestamp":1770234346849,"sessionId":"debug-session","hypothesisId":"D"}
{"location":"orders-crud.js:parseInt","message":"Parsed orderId","data":{"orderId":40921,"isNaN":false},"timestamp":1770234346849,"sessionId":"debug-session","hypothesisId":"B"}
{"location":"orders-crud.js:renderDetail","message":"renderDetail called","data":{"hasContainer":true,"hasSelectedOrder":true,"selectedOrderId":"40921"},"timestamp":1770234346855,"sessionId":"debug-session","hypothesisId":"E"}

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-alpine
WORKDIR /usr/src/app
# Copiar archivos de dependencias
COPY package*.json ./
# Instalar dependencias de producción
RUN npm ci --only=production
# Copiar código fuente
COPY . .
# Puerto de la aplicación
EXPOSE 3000
# Ejecutar migraciones, seed y luego iniciar la app
CMD ["sh", "-c", "npm run migrate:up && npm run seed && npm start"]

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,21 @@
-- migrate:up
-- Eliminar la tabla mp_payments (integración de MercadoPago removida)
drop table if exists mp_payments;
-- migrate:down
-- Recrear la tabla si se necesita rollback
create table if not exists mp_payments (
tenant_id uuid not null references tenants(id) on delete cascade,
woo_order_id bigint null,
preference_id text null,
payment_id text null,
status text null,
paid_at timestamptz null,
raw jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (tenant_id, payment_id)
);
create index if not exists mp_payments_tenant_order_idx
on mp_payments (tenant_id, woo_order_id);

View File

@@ -0,0 +1,7 @@
-- migrate:up
-- Agregar columna delivery_zones para configurar zonas de entrega por barrio CABA
ALTER TABLE tenant_settings
ADD COLUMN IF NOT EXISTS delivery_zones JSONB DEFAULT '{}';
-- migrate:down
ALTER TABLE tenant_settings DROP COLUMN IF EXISTS delivery_zones;

View File

@@ -0,0 +1,13 @@
-- migrate:up
-- Crear tenant Piaf (sin credenciales sensibles - esas van por variable de entorno)
INSERT INTO tenants (id, key, name)
VALUES (
'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid,
'piaf',
'Piaf'
)
ON CONFLICT (id) DO NOTHING;
-- migrate:down
DELETE FROM tenants WHERE id = 'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid;

View File

@@ -0,0 +1,15 @@
# Override local: expone puertos para desarrollo
# Este archivo se aplica automáticamente con `docker compose up`
# Coolify ignora este archivo y usa solo docker-compose.yaml
services:
app:
ports:
- "3000:3000"
env_file:
- .env
environment:
- NODE_ENV=development
command: sh -c "npm install && npm run dev"
volumes:
- .:/usr/src/app
- /usr/src/app/node_modules

View File

@@ -1,20 +1,18 @@
services: services:
app: app:
image: node:20-alpine build: .
working_dir: /usr/src/app working_dir: /usr/src/app
command: sh -c "npm install && npm run dev" expose:
ports: - "3000"
- "3000:3000"
env_file:
- .env
environment: environment:
- NODE_ENV=development - NODE_ENV=production
- PORT=3000 - PORT=3000
- DATABASE_URL=postgres://${POSTGRES_USER:-botino}:${POSTGRES_PASSWORD:-botino}@db:5432/${POSTGRES_DB:-botino}
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
volumes: # Variables para seed (configurar en Coolify)
- .:/usr/src/app - APP_ENCRYPTION_KEY=${APP_ENCRYPTION_KEY:-}
- /usr/src/app/node_modules - WOO_CONSUMER_KEY=${WOO_CONSUMER_KEY:-}
- WOO_CONSUMER_SECRET=${WOO_CONSUMER_SECRET:-}
- WOO_BASE_URL=${WOO_BASE_URL:-}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -35,7 +33,11 @@ services:
volumes: volumes:
- db_data:/var/lib/postgresql/data - db_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-botino}"] test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-botino} -d ${POSTGRES_DB:-botino}",
]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5

View File

@@ -26,7 +26,9 @@ OPENAI_MODEL=gpt-4o-mini
TURN_ENGINE=v1 TURN_ENGINE=v1
# =================== # ===================
# WooCommerce (fallback si falta config por tenant) # WooCommerce (usado por seed y fallback)
# Estas variables son leídas por scripts/seed-tenant.mjs para crear
# la configuración inicial del tenant en la base de datos.
# =================== # ===================
WOO_BASE_URL=https://tu-tienda.com WOO_BASE_URL=https://tu-tienda.com
WOO_CONSUMER_KEY=ck_xxx WOO_CONSUMER_KEY=ck_xxx

123
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"csv-parse": "^6.1.0", "csv-parse": "^6.1.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.19.2", "express": "^4.19.2",
"mysql2": "^3.16.2",
"openai": "^6.15.0", "openai": "^6.15.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"undici": "^7.16.0", "undici": "^7.16.0",
@@ -1249,6 +1250,15 @@
"js-tokens": "^9.0.1" "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": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1480,6 +1490,15 @@
"ms": "2.0.0" "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": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1795,6 +1814,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -1998,6 +2026,12 @@
"node": ">=0.12.0" "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": { "node_modules/istanbul-lib-coverage": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@@ -2073,6 +2107,27 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2199,6 +2254,54 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "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": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -2750,6 +2853,11 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "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": { "node_modules/serve-static": {
"version": "1.16.3", "version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
@@ -2882,6 +2990,15 @@
"node": ">= 10.x" "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": { "node_modules/stackback": {
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -3046,9 +3163,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "7.16.0", "version": "7.19.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.19.1.tgz",
"integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", "integrity": "sha512-Gpq0iNm5M6cQWlyHQv9MV+uOj1jWk7LpkoE5vSp/7zjb4zMdAcUD+VL5y0nH4p9EbUklq00eVIIX/XcDHzu5xg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=20.18.1" "node": ">=20.18.1"

View File

@@ -13,7 +13,8 @@
"migrate:up": "dbmate up", "migrate:up": "dbmate up",
"migrate:down": "dbmate down", "migrate:down": "dbmate down",
"migrate:redo": "dbmate rollback && dbmate up", "migrate:redo": "dbmate rollback && dbmate up",
"migrate:status": "dbmate status" "migrate:status": "dbmate status",
"seed": "node scripts/seed-tenant.mjs"
}, },
"keywords": [], "keywords": [],
"author": "Lucas Tettamanti", "author": "Lucas Tettamanti",
@@ -22,8 +23,10 @@
"ajv": "^8.17.1", "ajv": "^8.17.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"csv-parse": "^6.1.0", "csv-parse": "^6.1.0",
"dbmate": "^2.0.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.19.2", "express": "^4.19.2",
"mysql2": "^3.16.2",
"openai": "^6.15.0", "openai": "^6.15.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"undici": "^7.16.0", "undici": "^7.16.0",
@@ -31,7 +34,6 @@
}, },
"devDependencies": { "devDependencies": {
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"dbmate": "^2.0.0",
"nodemon": "^3.0.3", "nodemon": "^3.0.3",
"vitest": "^4.0.18" "vitest": "^4.0.18"
} }

View File

@@ -1,4 +1,5 @@
import "./components/ops-shell.js"; import "./components/ops-shell.js";
import "./components/home-dashboard.js";
import "./components/run-timeline.js"; import "./components/run-timeline.js";
import "./components/chat-simulator.js"; import "./components/chat-simulator.js";
import "./components/conversation-inspector.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> <header>
<h1>Bot Ops Console</h1> <h1>Bot Ops Console</h1>
<nav class="nav"> <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="/conversaciones" data-view="conversations">Conversaciones</a>
<a class="nav-btn" href="/usuarios" data-view="users">Usuarios</a> <a class="nav-btn" href="/usuarios" data-view="users">Usuarios</a>
<a class="nav-btn" href="/productos" data-view="products">Productos</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> <div class="status" id="sseStatus">SSE: connecting…</div>
</header> </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="layout-chat">
<div class="col chatTop"><run-timeline></run-timeline></div> <div class="col chatTop"><run-timeline></run-timeline></div>
<div class="col inspectorTop"><conversation-inspector></conversation-inspector></div> <div class="col inspectorTop"><conversation-inspector></conversation-inspector></div>

View File

@@ -41,6 +41,12 @@ class OrdersCrud extends HTMLElement {
this.orders = []; this.orders = [];
this.selectedOrder = null; this.selectedOrder = null;
this.loading = false; this.loading = false;
// Paginación
this.page = 1;
this.limit = 50;
this.totalPages = 1;
this.totalOrders = 0;
this.shadowRoot.innerHTML = ` this.shadowRoot.innerHTML = `
<style> <style>
@@ -214,17 +220,68 @@ class OrdersCrud extends HTMLElement {
text-align: center; text-align: center;
padding: 60px 20px; 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> </style>
<div class="container"> <div class="container">
<div class="panel"> <div class="panel">
<div class="panel-title"> <div class="panel-title">
<span>Pedidos de WooCommerce</span> <span>Pedidos</span>
<button id="btnRefresh" class="secondary small">Actualizar</button> <button id="btnRefresh" class="secondary small">Actualizar</button>
</div> </div>
<div class="orders-table" id="ordersTable"> <div class="orders-table" id="ordersTable">
<div class="empty">Cargando pedidos...</div> <div class="empty">Cargando pedidos...</div>
</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>
<div class="panel"> <div class="panel">
@@ -240,6 +297,25 @@ class OrdersCrud extends HTMLElement {
connectedCallback() { connectedCallback() {
this.shadowRoot.getElementById("btnRefresh").onclick = () => this.loadOrders(); 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 // Escuchar cambios de ruta para deep-linking
this._unsubRouter = on("router:viewChanged", ({ view, params }) => { this._unsubRouter = on("router:viewChanged", ({ view, params }) => {
if (view === "orders" && params.id) { if (view === "orders" && params.id) {
@@ -248,7 +324,6 @@ class OrdersCrud extends HTMLElement {
}); });
// Escuchar nuevos pedidos para actualizar automáticamente // 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 }) => { this._unsubOrderCreated = on("order:created", ({ order_id }) => {
console.log("[orders-crud] order:created received, order_id:", order_id); console.log("[orders-crud] order:created received, order_id:", order_id);
this.refreshWithRetry(order_id); this.refreshWithRetry(order_id);
@@ -298,9 +373,17 @@ class OrdersCrud extends HTMLElement {
container.innerHTML = `<div class="empty">Cargando pedidos...</div>`; container.innerHTML = `<div class="empty">Cargando pedidos...</div>`;
try { try {
const result = await api.listRecentOrders({ limit: 50 }); const result = await api.listOrders({ page: this.page, limit: this.limit });
this.orders = result.items || []; 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.renderTable();
this.updatePagination();
// Si hay un pedido pendiente de selección (deep-link), seleccionarlo // Si hay un pedido pendiente de selección (deep-link), seleccionarlo
if (this._pendingOrderId) { 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() { renderTable() {
const container = this.shadowRoot.getElementById("ordersTable"); const container = this.shadowRoot.getElementById("ordersTable");
@@ -373,7 +472,7 @@ class OrdersCrud extends HTMLElement {
container.querySelectorAll("tr[data-order-id]").forEach(row => { container.querySelectorAll("tr[data-order-id]").forEach(row => {
row.onclick = () => { row.onclick = () => {
const orderId = parseInt(row.dataset.orderId); const orderId = parseInt(row.dataset.orderId);
const order = this.orders.find(o => o.id === orderId); const order = this.orders.find(o => o.id == orderId);
if (order) { if (order) {
this.selectOrder(order); this.selectOrder(order);
} }

View File

@@ -1,13 +1,27 @@
import { api } from "../lib/api.js"; import { api } from "../lib/api.js";
const DAYS = [ const DAYS = [
{ id: "lun", label: "Lunes", short: "Lun" }, { id: "lun", label: "Lunes", short: "L" },
{ id: "mar", label: "Martes", short: "Mar" }, { id: "mar", label: "Martes", short: "M" },
{ id: "mie", label: "Miércoles", short: "Mié" }, { id: "mie", label: "Miércoles", short: "X" },
{ id: "jue", label: "Jueves", short: "Jue" }, { id: "jue", label: "Jueves", short: "J" },
{ id: "vie", label: "Viernes", short: "Vie" }, { id: "vie", label: "Viernes", short: "V" },
{ id: "sab", label: "Sábado", short: "Sáb" }, { id: "sab", label: "Sábado", short: "S" },
{ id: "dom", label: "Domingo", short: "Dom" }, { id: "dom", label: "Domingo", short: "D" },
];
// Lista oficial de 48 barrios de CABA
const CABA_BARRIOS = [
"Agronomía", "Almagro", "Balvanera", "Barracas", "Belgrano", "Boedo",
"Caballito", "Chacarita", "Coghlan", "Colegiales", "Constitución",
"Flores", "Floresta", "La Boca", "La Paternal", "Liniers",
"Mataderos", "Monte Castro", "Montserrat", "Nueva Pompeya", "Núñez",
"Palermo", "Parque Avellaneda", "Parque Chacabuco", "Parque Chas",
"Parque Patricios", "Puerto Madero", "Recoleta", "Retiro", "Saavedra",
"San Cristóbal", "San Nicolás", "San Telmo", "Vélez Sársfield", "Versalles",
"Villa Crespo", "Villa del Parque", "Villa Devoto", "Villa General Mitre",
"Villa Lugano", "Villa Luro", "Villa Ortúzar", "Villa Pueyrredón",
"Villa Real", "Villa Riachuelo", "Villa Santa Rita", "Villa Soldati", "Villa Urquiza"
]; ];
class SettingsCrud extends HTMLElement { class SettingsCrud extends HTMLElement {
@@ -113,6 +127,62 @@ class SettingsCrud extends HTMLElement {
} }
.min-order-field { margin-top:16px; padding-top:16px; border-top:1px solid #1e2a3a; } .min-order-field { margin-top:16px; padding-top:16px; border-top:1px solid #1e2a3a; }
/* Zonas de entrega */
.zones-search { margin-bottom:12px; }
.zones-search input {
width:100%; padding:10px 14px;
background:#0f1520 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%236c7a89'%3E%3Cpath d='M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z'/%3E%3C/svg%3E") no-repeat 12px center;
background-size:18px; padding-left:38px;
}
.zones-list { max-height:400px; overflow-y:auto; display:flex; flex-direction:column; gap:4px; }
.zone-row {
display:grid;
grid-template-columns:32px 1fr;
gap:12px;
align-items:start;
padding:10px 12px;
background:#0f1520;
border-radius:8px;
border:1px solid #1e2a3a;
transition:border-color .2s;
}
.zone-row.active { border-color:#1f6feb; background:#0f1825; }
.zone-row.hidden { display:none; }
.zone-toggle {
width:32px; height:18px; background:#253245; border-radius:9px;
cursor:pointer; position:relative; transition:background .2s; margin-top:2px;
}
.zone-toggle.active { background:#2ecc71; }
.zone-toggle::after {
content:''; position:absolute; top:2px; left:2px;
width:14px; height:14px; background:#fff; border-radius:50%;
transition:transform .2s;
}
.zone-toggle.active::after { transform:translateX(14px); }
.zone-content { display:flex; flex-direction:column; gap:8px; }
.zone-name { font-size:14px; color:#e7eef7; font-weight:500; }
.zone-config { display:none; gap:16px; flex-wrap:wrap; align-items:center; }
.zone-row.active .zone-config { display:flex; }
.zone-days { display:flex; gap:4px; }
.zone-day {
width:28px; height:28px; border-radius:6px;
background:#253245; color:#8aa0b5;
display:flex; align-items:center; justify-content:center;
font-size:11px; font-weight:600; cursor:pointer;
transition:all .15s;
}
.zone-day.active { background:#1f6feb; color:#fff; }
.zone-day:hover { background:#2d3e52; }
.zone-day.active:hover { background:#1a5fd0; }
.zone-cost { display:flex; align-items:center; gap:6px; }
.zone-cost label { font-size:12px; color:#8aa0b5; }
.zone-cost input { width:90px; padding:6px 10px; font-size:13px; text-align:right; }
.zones-summary {
margin-top:12px; padding:12px; background:#0f1520;
border-radius:8px; font-size:13px; color:#8aa0b5;
}
.zones-summary strong { color:#e7eef7; }
</style> </style>
<div class="container"> <div class="container">
@@ -138,6 +208,10 @@ class SettingsCrud extends HTMLElement {
if (!this.settings.schedule) { if (!this.settings.schedule) {
this.settings.schedule = { delivery: {}, pickup: {} }; this.settings.schedule = { delivery: {}, pickup: {} };
} }
// Asegurar que delivery_zones existe
if (!this.settings.delivery_zones) {
this.settings.delivery_zones = {};
}
this.loading = false; this.loading = false;
this.render(); this.render();
} catch (e) { } catch (e) {
@@ -201,6 +275,72 @@ class SettingsCrud extends HTMLElement {
}).join(""); }).join("");
} }
// Convierte nombre de barrio a key (ej: "Villa Crespo" -> "villa_crespo")
barrioToKey(name) {
return name.toLowerCase()
.normalize("NFD").replace(/[\u0300-\u036f]/g, "") // quitar acentos
.replace(/\s+/g, "_");
}
getZoneConfig(barrioKey) {
return this.settings?.delivery_zones?.[barrioKey] || null;
}
setZoneConfig(barrioKey, config) {
if (!this.settings.delivery_zones) {
this.settings.delivery_zones = {};
}
if (config === null) {
delete this.settings.delivery_zones[barrioKey];
} else {
this.settings.delivery_zones[barrioKey] = config;
}
}
renderZonesList() {
return CABA_BARRIOS.map(barrio => {
const key = this.barrioToKey(barrio);
const config = this.getZoneConfig(key);
const isActive = config?.enabled === true;
const days = config?.days || [];
const cost = config?.delivery_cost || 0;
return `
<div class="zone-row ${isActive ? 'active' : ''}" data-barrio="${key}">
<div class="zone-toggle ${isActive ? 'active' : ''}" data-barrio="${key}"></div>
<div class="zone-content">
<span class="zone-name">${barrio}</span>
<div class="zone-config">
<div class="zone-days">
${DAYS.map(d => `
<div class="zone-day ${days.includes(d.id) ? 'active' : ''}"
data-barrio="${key}" data-day="${d.id}"
title="${d.label}">${d.short}</div>
`).join("")}
</div>
<div class="zone-cost">
<label>Costo:</label>
<input type="number" class="zone-cost-input" data-barrio="${key}"
value="${cost}" min="0" step="100" placeholder="0" />
</div>
</div>
</div>
</div>
`;
}).join("");
}
renderZonesSummary() {
const zones = this.settings?.delivery_zones || {};
const activeZones = Object.entries(zones).filter(([k, v]) => v?.enabled);
if (activeZones.length === 0) {
return `<div class="zones-summary">No hay zonas de entrega configuradas. Activá los barrios donde hacés delivery.</div>`;
}
return `<div class="zones-summary"><strong>${activeZones.length}</strong> zona${activeZones.length > 1 ? 's' : ''} de entrega activa${activeZones.length > 1 ? 's' : ''}</div>`;
}
render() { render() {
const content = this.shadowRoot.getElementById("content"); const content = this.shadowRoot.getElementById("content");
@@ -290,6 +430,24 @@ class SettingsCrud extends HTMLElement {
</div> </div>
</div> </div>
<!-- Zonas de Entrega -->
<div class="panel">
<div class="panel-title">
<svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
Zonas de Entrega (Barrios CABA)
</div>
<div class="zones-search">
<input type="text" id="zoneSearch" placeholder="Buscar barrio..." />
</div>
<div class="zones-list" id="zonesList">
${this.renderZonesList()}
</div>
${this.renderZonesSummary()}
</div>
<div class="actions"> <div class="actions">
<button id="saveBtn" ${this.saving ? "disabled" : ""}>${this.saving ? "Guardando..." : "Guardar Configuración"}</button> <button id="saveBtn" ${this.saving ? "disabled" : ""}>${this.saving ? "Guardando..." : "Guardar Configuración"}</button>
<button id="resetBtn" class="secondary">Restaurar</button> <button id="resetBtn" class="secondary">Restaurar</button>
@@ -359,6 +517,73 @@ class SettingsCrud extends HTMLElement {
// Reset button // Reset button
this.shadowRoot.getElementById("resetBtn")?.addEventListener("click", () => this.load()); this.shadowRoot.getElementById("resetBtn")?.addEventListener("click", () => this.load());
// Zone search
this.shadowRoot.getElementById("zoneSearch")?.addEventListener("input", (e) => {
const query = e.target.value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
this.shadowRoot.querySelectorAll(".zone-row").forEach(row => {
const barrio = row.dataset.barrio;
const barrioName = CABA_BARRIOS.find(b => this.barrioToKey(b) === barrio) || "";
const normalized = barrioName.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
row.classList.toggle("hidden", query && !normalized.includes(query));
});
});
// Zone toggles
this.shadowRoot.querySelectorAll(".zone-toggle").forEach(toggle => {
toggle.addEventListener("click", () => {
const barrio = toggle.dataset.barrio;
const config = this.getZoneConfig(barrio);
if (config?.enabled) {
// Desactivar zona
this.setZoneConfig(barrio, null);
} else {
// Activar zona con días default (lun-sab)
this.setZoneConfig(barrio, {
enabled: true,
days: ["lun", "mar", "mie", "jue", "vie", "sab"],
delivery_cost: 0
});
}
this.render();
});
});
// Zone day toggles
this.shadowRoot.querySelectorAll(".zone-day").forEach(dayBtn => {
dayBtn.addEventListener("click", () => {
const barrio = dayBtn.dataset.barrio;
const day = dayBtn.dataset.day;
const config = this.getZoneConfig(barrio);
if (!config) return;
const days = config.days || [];
const idx = days.indexOf(day);
if (idx >= 0) {
days.splice(idx, 1);
} else {
days.push(day);
}
config.days = days;
this.setZoneConfig(barrio, config);
// Update UI without full re-render
dayBtn.classList.toggle("active", days.includes(day));
});
});
// Zone cost inputs
this.shadowRoot.querySelectorAll(".zone-cost-input").forEach(input => {
input.addEventListener("change", () => {
const barrio = input.dataset.barrio;
const config = this.getZoneConfig(barrio);
if (!config) return;
config.delivery_cost = parseFloat(input.value) || 0;
this.setZoneConfig(barrio, config);
});
});
} }
collectScheduleFromInputs() { collectScheduleFromInputs() {
@@ -386,6 +611,9 @@ class SettingsCrud extends HTMLElement {
// Collect schedule from inputs // Collect schedule from inputs
const schedule = this.collectScheduleFromInputs(); const schedule = this.collectScheduleFromInputs();
// Collect delivery zones (already in settings from event handlers)
const delivery_zones = this.settings.delivery_zones || {};
const data = { const data = {
store_name: this.shadowRoot.getElementById("storeName")?.value || "", store_name: this.shadowRoot.getElementById("storeName")?.value || "",
bot_name: this.shadowRoot.getElementById("botName")?.value || "", bot_name: this.shadowRoot.getElementById("botName")?.value || "",
@@ -395,6 +623,7 @@ class SettingsCrud extends HTMLElement {
pickup_enabled: this.settings.pickup_enabled, pickup_enabled: this.settings.pickup_enabled,
delivery_min_order: parseFloat(this.shadowRoot.getElementById("deliveryMinOrder")?.value) || 0, delivery_min_order: parseFloat(this.shadowRoot.getElementById("deliveryMinOrder")?.value) || 0,
schedule, schedule,
delivery_zones,
}; };
// Update settings with form values // Update settings with form values

View File

@@ -46,7 +46,6 @@ class TestPanel extends HTMLElement {
this.selectedProducts = []; this.selectedProducts = [];
this.testUser = null; this.testUser = null;
this.lastOrder = null; this.lastOrder = null;
this.lastPaymentLink = null;
this.loading = false; this.loading = false;
this.shadowRoot.innerHTML = ` this.shadowRoot.innerHTML = `
@@ -66,11 +65,12 @@ class TestPanel extends HTMLElement {
height: 100%; height: 100%;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
display: grid; display: flex;
grid-template-columns: 1fr 1fr; flex-direction: column;
gap: 16px; gap: 16px;
padding: 16px; padding: 16px;
overflow: auto; overflow: auto;
max-width: 600px;
} }
.panel { .panel {
background: var(--panel); background: var(--panel);
@@ -227,50 +227,6 @@ class TestPanel extends HTMLElement {
</div> </div>
</div> </div>
</div> </div>
<div class="panel">
<div class="panel-title">2. Link de Pago (MercadoPago)</div>
<div class="section">
<div class="section-title">Monto</div>
<div class="row">
<input type="number" id="inputAmount" placeholder="Monto en ARS" class="flex-1" />
<button id="btnPaymentLink" disabled>Generar Link de Pago</button>
</div>
</div>
<div class="section" id="paymentResult" style="display:none;">
<div class="result success">
<div class="result-label">Link de pago</div>
<a class="result-link" id="paymentLinkValue" href="#" target="_blank">—</a>
<div style="margin-top:8px;">
<span class="result-label">Preference ID:</span>
<span id="preferenceIdValue">—</span>
</div>
</div>
</div>
<div class="panel-title" style="margin-top:24px;">3. Simular Pago Exitoso</div>
<div class="section">
<p style="font-size:12px;color:var(--muted);margin:0;">
Simula el webhook de MercadoPago con status "approved".
Esto actualiza la orden en WooCommerce a "processing".
</p>
<button id="btnSimulateWebhook" disabled>Simular Pago Exitoso</button>
</div>
<div class="section" id="webhookResult" style="display:none;">
<div class="result success">
<div class="result-label">Pago simulado</div>
<div class="result-value" id="webhookStatusValue">—</div>
<div style="margin-top:8px;">
<span class="result-label">Orden status:</span>
<span id="webhookOrderStatusValue">—</span>
</div>
</div>
</div>
</div>
</div> </div>
`; `;
} }
@@ -279,8 +235,6 @@ class TestPanel extends HTMLElement {
this.shadowRoot.getElementById("btnGenerate").onclick = () => this.generateRandomOrder(); this.shadowRoot.getElementById("btnGenerate").onclick = () => this.generateRandomOrder();
this.shadowRoot.getElementById("btnClear").onclick = () => this.clearAll(); this.shadowRoot.getElementById("btnClear").onclick = () => this.clearAll();
this.shadowRoot.getElementById("btnCreateOrder").onclick = () => this.createOrder(); this.shadowRoot.getElementById("btnCreateOrder").onclick = () => this.createOrder();
this.shadowRoot.getElementById("btnPaymentLink").onclick = () => this.createPaymentLink();
this.shadowRoot.getElementById("btnSimulateWebhook").onclick = () => this.simulateWebhook();
this.loadProducts(); this.loadProducts();
} }
@@ -379,16 +333,7 @@ class TestPanel extends HTMLElement {
updateButtonStates() { updateButtonStates() {
const hasProducts = this.selectedProducts.length > 0; const hasProducts = this.selectedProducts.length > 0;
const hasOrder = this.lastOrder?.woo_order_id;
const hasPaymentLink = this.lastPaymentLink?.init_point;
this.shadowRoot.getElementById("btnCreateOrder").disabled = !hasProducts; this.shadowRoot.getElementById("btnCreateOrder").disabled = !hasProducts;
this.shadowRoot.getElementById("btnPaymentLink").disabled = !hasOrder;
this.shadowRoot.getElementById("btnSimulateWebhook").disabled = !hasOrder;
if (hasOrder) {
this.shadowRoot.getElementById("inputAmount").value = this.lastOrder.total || "";
}
} }
async createOrder() { async createOrder() {
@@ -433,101 +378,16 @@ class TestPanel extends HTMLElement {
} }
} }
async createPaymentLink() {
if (this.loading) return;
if (!this.lastOrder?.woo_order_id) {
modal.warn("Primero creá una orden");
return;
}
const amount = parseFloat(this.shadowRoot.getElementById("inputAmount").value);
if (!amount || amount <= 0) {
modal.warn("Ingresá un monto válido");
return;
}
this.loading = true;
const btn = this.shadowRoot.getElementById("btnPaymentLink");
btn.disabled = true;
btn.textContent = "Generando...";
try {
const result = await api.createPaymentLink({
woo_order_id: this.lastOrder.woo_order_id,
amount,
});
if (result.ok) {
this.lastPaymentLink = result;
const linkEl = this.shadowRoot.getElementById("paymentLinkValue");
linkEl.href = result.init_point || result.sandbox_init_point || "#";
linkEl.textContent = result.init_point || result.sandbox_init_point || "—";
this.shadowRoot.getElementById("preferenceIdValue").textContent = result.preference_id || "—";
this.shadowRoot.getElementById("paymentResult").style.display = "block";
} else {
modal.error("Error: " + (result.error || "Error desconocido"));
}
} catch (e) {
console.error("[test-panel] createPaymentLink error:", e);
modal.error("Error generando link: " + e.message);
} finally {
this.loading = false;
btn.textContent = "Generar Link de Pago";
this.updateButtonStates();
}
}
async simulateWebhook() {
if (this.loading) return;
if (!this.lastOrder?.woo_order_id) {
modal.warn("Primero creá una orden");
return;
}
this.loading = true;
const btn = this.shadowRoot.getElementById("btnSimulateWebhook");
btn.disabled = true;
btn.textContent = "Simulando...";
try {
const amount = parseFloat(this.shadowRoot.getElementById("inputAmount").value) || this.lastOrder.total || 0;
const result = await api.simulateMpWebhook({
woo_order_id: this.lastOrder.woo_order_id,
amount,
});
if (result.ok) {
this.shadowRoot.getElementById("webhookStatusValue").textContent = `Payment ${result.payment_id} - ${result.status}`;
this.shadowRoot.getElementById("webhookOrderStatusValue").textContent = result.order_status || "processing";
this.shadowRoot.getElementById("webhookResult").style.display = "block";
} else {
modal.error("Error: " + (result.error || "Error desconocido"));
}
} catch (e) {
console.error("[test-panel] simulateWebhook error:", e);
modal.error("Error simulando webhook: " + e.message);
} finally {
this.loading = false;
btn.textContent = "Simular Pago Exitoso";
this.updateButtonStates();
}
}
clearAll() { clearAll() {
this.selectedProducts = []; this.selectedProducts = [];
this.testUser = null; this.testUser = null;
this.lastOrder = null; this.lastOrder = null;
this.lastPaymentLink = null;
this.renderProductList(); this.renderProductList();
this.renderUserInfo(); this.renderUserInfo();
this.updateButtonStates(); this.updateButtonStates();
this.shadowRoot.getElementById("orderResult").style.display = "none"; this.shadowRoot.getElementById("orderResult").style.display = "none";
this.shadowRoot.getElementById("paymentResult").style.display = "none";
this.shadowRoot.getElementById("webhookResult").style.display = "none";
this.shadowRoot.getElementById("inputAmount").value = "";
} }
} }

View File

@@ -7,6 +7,7 @@
</head> </head>
<body> <body>
<ops-shell></ops-shell> <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> <script type="module" src="/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -185,13 +185,23 @@ export const api = {
return fetch("/products/sync-from-woo", { method: "POST" }).then(r => r.json()); return fetch("/products/sync-from-woo", { method: "POST" }).then(r => r.json());
}, },
// --- Testing --- // --- Orders & Stats ---
async listRecentOrders({ limit = 20 } = {}) { async listOrders({ page = 1, limit = 50 } = {}) {
const u = new URL("/test/orders", location.origin); const u = new URL("/api/orders", location.origin);
u.searchParams.set("page", String(page));
u.searchParams.set("limit", String(limit)); u.searchParams.set("limit", String(limit));
return fetch(u).then(r => r.json()); 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() { async getProductsWithStock() {
return fetch("/test/products-with-stock").then(r => r.json()); return fetch("/test/products-with-stock").then(r => r.json());
}, },
@@ -204,22 +214,6 @@ export const api = {
}).then(r => r.json()); }).then(r => r.json());
}, },
async createPaymentLink({ woo_order_id, amount }) {
return fetch("/test/payment-link", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ woo_order_id, amount }),
}).then(r => r.json());
},
async simulateMpWebhook({ woo_order_id, amount }) {
return fetch("/test/simulate-webhook", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ woo_order_id, amount }),
}).then(r => r.json());
},
// --- Prompts CRUD --- // --- Prompts CRUD ---
async prompts() { async prompts() {
return fetch("/prompts").then(r => r.json()); return fetch("/prompts").then(r => r.json());

View File

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

View File

@@ -70,7 +70,6 @@ function upsertConversation(chat_id, patch) {
* - call LLM (structured output) * - call LLM (structured output)
* - product search (LIMITED) + resolve ids * - product search (LIMITED) + resolve ids
* - create/update Woo order * - create/update Woo order
* - create MercadoPago link
* - save state * - save state
*/ */
async function processMessage({ chat_id, from, text }) { async function processMessage({ chat_id, from, text }) {

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

93
scripts/seed-tenant.mjs Normal file
View File

@@ -0,0 +1,93 @@
#!/usr/bin/env node
/**
* Seed script para configurar tenant con credenciales de WooCommerce.
* Lee las credenciales de variables de entorno (no hardcodeadas).
*
* Variables requeridas:
* - DATABASE_URL: conexión a PostgreSQL
* - APP_ENCRYPTION_KEY: clave para encriptar credenciales
* - WOO_CONSUMER_KEY: consumer key de WooCommerce
* - WOO_CONSUMER_SECRET: consumer secret de WooCommerce
* - WOO_BASE_URL: URL base de WooCommerce (opcional, default: https://piaf.floda.dev/wp-json/wc/v3)
*/
import pg from "pg";
const TENANT_ID = "eb71b9a7-9ccf-430e-9b25-951a0c589c0f";
async function seed() {
const {
DATABASE_URL,
APP_ENCRYPTION_KEY,
WOO_CONSUMER_KEY,
WOO_CONSUMER_SECRET,
WOO_BASE_URL = "https://piaf.floda.dev/wp-json/wc/v3",
} = process.env;
// Validar variables requeridas
if (!DATABASE_URL) {
console.log("[seed] DATABASE_URL no configurada, saltando seed");
return;
}
if (!APP_ENCRYPTION_KEY || !WOO_CONSUMER_KEY || !WOO_CONSUMER_SECRET) {
console.log("[seed] Variables de WooCommerce no configuradas, saltando seed de ecommerce config");
console.log("[seed] Para configurar, definir: APP_ENCRYPTION_KEY, WOO_CONSUMER_KEY, WOO_CONSUMER_SECRET");
return;
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
try {
// Verificar si ya existe la config
const check = await pool.query(
"SELECT 1 FROM tenant_ecommerce_config WHERE tenant_id = $1",
[TENANT_ID]
);
if (check.rows.length > 0) {
console.log("[seed] tenant_ecommerce_config ya existe, saltando");
return;
}
// Configurar encryption key para la sesión
await pool.query("SELECT set_config('app.encryption_key', $1, false)", [
APP_ENCRYPTION_KEY,
]);
// Insertar config de WooCommerce
await pool.query(
`INSERT INTO tenant_ecommerce_config (
tenant_id,
provider,
base_url,
credential_ref,
api_version,
timeout_ms,
enabled,
enc_consumer_key,
enc_consumer_secret
) VALUES (
$1::uuid,
'woo',
$2,
'secret://woo/piaf',
'wc/v3',
8000,
true,
pgp_sym_encrypt($3, current_setting('app.encryption_key')),
pgp_sym_encrypt($4, current_setting('app.encryption_key'))
)`,
[TENANT_ID, WOO_BASE_URL, WOO_CONSUMER_KEY, WOO_CONSUMER_SECRET]
);
console.log("[seed] tenant_ecommerce_config creada exitosamente");
} catch (err) {
console.error("[seed] Error:", err.message);
// No fallar el startup si el seed falla (puede ser que las tablas no existan aún)
} finally {
await pool.end();
}
}
seed();

View File

@@ -5,7 +5,6 @@ import { fileURLToPath } from "url";
import { createSimulatorRouter } from "./modules/1-intake/routes/simulator.js"; import { createSimulatorRouter } from "./modules/1-intake/routes/simulator.js";
import { createEvolutionRouter } from "./modules/1-intake/routes/evolution.js"; import { createEvolutionRouter } from "./modules/1-intake/routes/evolution.js";
import { createMercadoPagoRouter } from "./modules/6-mercadopago/routes/mercadoPago.js";
import { createWooWebhooksRouter } from "./modules/2-identity/routes/wooWebhooks.js"; import { createWooWebhooksRouter } from "./modules/2-identity/routes/wooWebhooks.js";
export function createApp({ tenantId }) { export function createApp({ tenantId }) {
@@ -23,7 +22,6 @@ export function createApp({ tenantId }) {
// --- Integraciones / UI --- // --- Integraciones / UI ---
app.use(createSimulatorRouter({ tenantId })); app.use(createSimulatorRouter({ tenantId }));
app.use(createEvolutionRouter()); app.use(createEvolutionRouter());
app.use("/payments/meli", createMercadoPagoRouter());
app.use(createWooWebhooksRouter()); app.use(createWooWebhooksRouter());
// Home (UI) // Home (UI)

View File

@@ -1,19 +1,30 @@
import { import {
handleListRecentOrders, handleListOrders,
handleGetProductsWithStock, handleGetProductsWithStock,
handleCreateTestOrder, handleCreateTestOrder,
handleCreatePaymentLink,
handleSimulateMpWebhook,
} from "../handlers/testing.js"; } 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 { try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const limit = parseInt(req.query.limit) || 20; const page = parseInt(req.query.page) || 1;
const result = await handleListRecentOrders({ tenantId, limit }); const limit = parseInt(req.query.limit) || 50;
const result = await handleListOrders({ tenantId, page, limit });
res.json(result); res.json(result);
} catch (err) { } 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" }); res.status(500).json({ ok: false, error: err.message || "internal_error" });
} }
}; };
@@ -46,47 +57,3 @@ export const makeCreateTestOrder = (tenantIdOrFn) => async (req, res) => {
} }
}; };
export const makeCreatePaymentLink = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const { woo_order_id, amount } = req.body || {};
if (!woo_order_id) {
return res.status(400).json({ ok: false, error: "woo_order_id_required" });
}
if (!amount || Number(amount) <= 0) {
return res.status(400).json({ ok: false, error: "amount_required" });
}
const result = await handleCreatePaymentLink({
tenantId,
wooOrderId: woo_order_id,
amount: Number(amount)
});
res.json(result);
} catch (err) {
console.error("[testing] createPaymentLink error:", err);
res.status(500).json({ ok: false, error: err.message || "internal_error" });
}
};
export const makeSimulateMpWebhook = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const { woo_order_id, amount } = req.body || {};
if (!woo_order_id) {
return res.status(400).json({ ok: false, error: "woo_order_id_required" });
}
const result = await handleSimulateMpWebhook({
tenantId,
wooOrderId: woo_order_id,
amount: Number(amount) || 0
});
res.json(result);
} catch (err) {
console.error("[testing] simulateMpWebhook error:", err);
res.status(500).json({ ok: false, error: err.message || "internal_error" });
}
};

View File

@@ -20,6 +20,7 @@ export async function getSettings({ tenantId }) {
pickup_hours_start::text as pickup_hours_start, pickup_hours_start::text as pickup_hours_start,
pickup_hours_end::text as pickup_hours_end, pickup_hours_end::text as pickup_hours_end,
schedule, schedule,
delivery_zones,
created_at, updated_at created_at, updated_at
FROM tenant_settings FROM tenant_settings
WHERE tenant_id = $1 WHERE tenant_id = $1
@@ -48,6 +49,7 @@ export async function upsertSettings({ tenantId, settings }) {
pickup_hours_start, pickup_hours_start,
pickup_hours_end, pickup_hours_end,
schedule, schedule,
delivery_zones,
} = settings; } = settings;
const sql = ` const sql = `
@@ -55,9 +57,9 @@ export async function upsertSettings({ tenantId, settings }) {
tenant_id, store_name, bot_name, store_address, store_phone, tenant_id, store_name, bot_name, store_address, store_phone,
delivery_enabled, delivery_days, delivery_hours_start, delivery_hours_end, delivery_min_order, delivery_enabled, delivery_days, delivery_hours_start, delivery_hours_end, delivery_min_order,
pickup_enabled, pickup_days, pickup_hours_start, pickup_hours_end, pickup_enabled, pickup_days, pickup_hours_start, pickup_hours_end,
schedule schedule, delivery_zones
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
ON CONFLICT (tenant_id) DO UPDATE SET ON CONFLICT (tenant_id) DO UPDATE SET
store_name = COALESCE(EXCLUDED.store_name, tenant_settings.store_name), store_name = COALESCE(EXCLUDED.store_name, tenant_settings.store_name),
bot_name = COALESCE(EXCLUDED.bot_name, tenant_settings.bot_name), bot_name = COALESCE(EXCLUDED.bot_name, tenant_settings.bot_name),
@@ -73,6 +75,7 @@ export async function upsertSettings({ tenantId, settings }) {
pickup_hours_start = COALESCE(EXCLUDED.pickup_hours_start, tenant_settings.pickup_hours_start), pickup_hours_start = COALESCE(EXCLUDED.pickup_hours_start, tenant_settings.pickup_hours_start),
pickup_hours_end = COALESCE(EXCLUDED.pickup_hours_end, tenant_settings.pickup_hours_end), pickup_hours_end = COALESCE(EXCLUDED.pickup_hours_end, tenant_settings.pickup_hours_end),
schedule = COALESCE(EXCLUDED.schedule, tenant_settings.schedule), schedule = COALESCE(EXCLUDED.schedule, tenant_settings.schedule),
delivery_zones = COALESCE(EXCLUDED.delivery_zones, tenant_settings.delivery_zones),
updated_at = NOW() updated_at = NOW()
RETURNING RETURNING
id, tenant_id, id, tenant_id,
@@ -85,6 +88,7 @@ export async function upsertSettings({ tenantId, settings }) {
pickup_hours_start::text as pickup_hours_start, pickup_hours_start::text as pickup_hours_start,
pickup_hours_end::text as pickup_hours_end, pickup_hours_end::text as pickup_hours_end,
schedule, schedule,
delivery_zones,
created_at, updated_at created_at, updated_at
`; `;
@@ -104,6 +108,7 @@ export async function upsertSettings({ tenantId, settings }) {
pickup_hours_start || null, pickup_hours_start || null,
pickup_hours_end || null, pickup_hours_end || null,
schedule ? JSON.stringify(schedule) : null, schedule ? JSON.stringify(schedule) : null,
delivery_zones ? JSON.stringify(delivery_zones) : null,
]; ];
const { rows } = await pool.query(sql, params); const { rows } = await pool.query(sql, params);

View File

@@ -42,6 +42,7 @@ export async function handleGetSettings({ tenantId }) {
pickup_hours_start: "08:00", pickup_hours_start: "08:00",
pickup_hours_end: "20:00", pickup_hours_end: "20:00",
schedule: createDefaultSchedule(), schedule: createDefaultSchedule(),
delivery_zones: {},
is_default: true, is_default: true,
}; };
} }
@@ -60,6 +61,7 @@ export async function handleGetSettings({ tenantId }) {
pickup_hours_start: settings.pickup_hours_start?.slice(0, 5) || "08:00", pickup_hours_start: settings.pickup_hours_start?.slice(0, 5) || "08:00",
pickup_hours_end: settings.pickup_hours_end?.slice(0, 5) || "20:00", pickup_hours_end: settings.pickup_hours_end?.slice(0, 5) || "20:00",
schedule, schedule,
delivery_zones: settings.delivery_zones || {},
is_default: false, is_default: false,
}; };
} }
@@ -103,7 +105,8 @@ function buildScheduleFromLegacy(settings) {
function validateSchedule(schedule) { function validateSchedule(schedule) {
if (!schedule || typeof schedule !== "object") return; if (!schedule || typeof schedule !== "object") return;
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/; // Acepta HH:MM o HH:MM:SS (la BD puede devolver con segundos)
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/;
for (const type of ["delivery", "pickup"]) { for (const type of ["delivery", "pickup"]) {
const typeSchedule = schedule[type]; const typeSchedule = schedule[type];
@@ -240,6 +243,7 @@ export async function handleSaveSettings({ tenantId, settings }) {
delivery_hours_end: result.delivery_hours_end?.slice(0, 5), delivery_hours_end: result.delivery_hours_end?.slice(0, 5),
pickup_hours_start: result.pickup_hours_start?.slice(0, 5), pickup_hours_start: result.pickup_hours_start?.slice(0, 5),
pickup_hours_end: result.pickup_hours_end?.slice(0, 5), pickup_hours_end: result.pickup_hours_end?.slice(0, 5),
delivery_zones: result.delivery_zones || {},
}, },
message: "Configuración guardada correctamente", message: "Configuración guardada correctamente",
}; };

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,27 @@
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 { 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 }) { export async function handleListOrders({ tenantId, page = 1, limit = 50 }) {
const orders = await listRecentOrders({ tenantId, limit }); // 1. Sincronizar pedidos nuevos de Woo
return { items: orders }; 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),
},
};
} }
/** /**
@@ -58,74 +72,3 @@ export async function handleCreateTestOrder({ tenantId, basket, address, wa_chat
}; };
} }
/**
* Crea un link de pago de MercadoPago
*/
export async function handleCreatePaymentLink({ tenantId, wooOrderId, amount }) {
if (!wooOrderId) {
throw new Error("missing_woo_order_id");
}
if (!amount || Number(amount) <= 0) {
throw new Error("invalid_amount");
}
const pref = await createPreference({
tenantId,
wooOrderId,
amount: Number(amount),
});
return {
ok: true,
preference_id: pref?.preference_id || null,
init_point: pref?.init_point || null,
sandbox_init_point: pref?.sandbox_init_point || null,
};
}
/**
* Simula un webhook de MercadoPago con pago exitoso
* No pasa por el endpoint real (requiere firma HMAC)
* Crea un payment mock y llama a reconcilePayment directamente
*/
export async function handleSimulateMpWebhook({ tenantId, wooOrderId, amount }) {
if (!wooOrderId) {
throw new Error("missing_woo_order_id");
}
// Crear payment mock con status approved
const mockPaymentId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const mockPayment = {
id: mockPaymentId,
status: "approved",
status_detail: "accredited",
external_reference: `${tenantId}|${wooOrderId}`,
transaction_amount: Number(amount) || 0,
currency_id: "ARS",
date_approved: new Date().toISOString(),
date_created: new Date().toISOString(),
payment_method_id: "test",
payment_type_id: "credit_card",
payer: {
email: "test@test.com",
},
order: {
id: `pref-test-${wooOrderId}`,
},
};
// Reconciliar el pago (actualiza mp_payments y cambia status de orden a processing)
const result = await reconcilePayment({
tenantId,
payment: mockPayment,
});
return {
ok: true,
payment_id: mockPaymentId,
woo_order_id: result?.woo_order_id || wooOrderId,
status: "approved",
order_status: "processing",
reconciled: result?.payment || null,
};
}

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 { makeListPendingTakeovers, makeListAllTakeovers, makeGetTakeover, makeRespondToTakeover, makeCancelTakeover, makeCheckPendingTakeover } from "../../0-ui/controllers/takeovers.js";
import { makeGetSettings, makeSaveSettings } from "../../0-ui/controllers/settings.js"; import { makeGetSettings, makeSaveSettings } from "../../0-ui/controllers/settings.js";
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.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 } from "../../0-ui/controllers/testing.js";
function nowIso() { function nowIso() {
return new Date().toISOString(); return new Date().toISOString();
@@ -107,12 +107,13 @@ export function createSimulatorRouter({ tenantId }) {
router.get("/runs", makeListRuns(getTenantId)); router.get("/runs", makeListRuns(getTenantId));
router.get("/runs/:run_id", makeGetRunById(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 --- // --- Testing routes ---
router.get("/test/orders", makeListRecentOrders(getTenantId));
router.get("/test/products-with-stock", makeGetProductsWithStock(getTenantId)); router.get("/test/products-with-stock", makeGetProductsWithStock(getTenantId));
router.post("/test/order", makeCreateTestOrder(getTenantId)); router.post("/test/order", makeCreateTestOrder(getTenantId));
router.post("/test/payment-link", makeCreatePaymentLink(getTenantId));
router.post("/test/simulate-webhook", makeSimulateMpWebhook(getTenantId));
return router; return router;
} }

View File

@@ -733,51 +733,4 @@ export async function upsertProductEmbedding({
return rows[0] || null; return rows[0] || null;
} }
export async function upsertMpPayment({
tenant_id,
woo_order_id = null,
preference_id = null,
payment_id = null,
status = null,
paid_at = null,
raw = {},
}) {
if (!payment_id) throw new Error("payment_id_required");
const sql = `
insert into mp_payments
(tenant_id, woo_order_id, preference_id, payment_id, status, paid_at, raw, created_at, updated_at)
values
($1, $2, $3, $4, $5, $6::timestamptz, $7::jsonb, now(), now())
on conflict (tenant_id, payment_id)
do update set
woo_order_id = excluded.woo_order_id,
preference_id = excluded.preference_id,
status = excluded.status,
paid_at = excluded.paid_at,
raw = excluded.raw,
updated_at = now()
returning tenant_id, woo_order_id, preference_id, payment_id, status, paid_at, raw, updated_at
`;
const { rows } = await pool.query(sql, [
tenant_id,
woo_order_id,
preference_id,
payment_id,
status,
paid_at,
JSON.stringify(raw ?? {}),
]);
return rows[0] || null;
}
export async function getMpPaymentById({ tenant_id, payment_id }) {
const sql = `
select tenant_id, woo_order_id, preference_id, payment_id, status, paid_at, raw, updated_at
from mp_payments
where tenant_id=$1 and payment_id=$2
limit 1
`;
const { rows } = await pool.query(sql, [tenant_id, payment_id]);
return rows[0] || null;
}

View File

@@ -17,7 +17,6 @@ import { debug as dbg } from "../../shared/debug.js";
import { runTurnV3 } from "../../3-turn-engine/turnEngineV3.js"; import { runTurnV3 } from "../../3-turn-engine/turnEngineV3.js";
import { safeNextState } from "../../3-turn-engine/fsm.js"; import { safeNextState } from "../../3-turn-engine/fsm.js";
import { createOrder, updateOrder } from "../../4-woo-orders/wooOrders.js"; import { createOrder, updateOrder } from "../../4-woo-orders/wooOrders.js";
import { createPreference } from "../../6-mercadopago/mercadoPago.js";
import { handleCreateTakeover } from "../../0-ui/handlers/takeovers.js"; import { handleCreateTakeover } from "../../0-ui/handlers/takeovers.js";
import { sendTextMessage, isEvolutionEnabled } from "../../1-intake/services/evolutionSender.js"; import { sendTextMessage, isEvolutionEnabled } from "../../1-intake/services/evolutionSender.js";
@@ -313,25 +312,6 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
pending_query: act.payload?.pending_query, pending_query: act.payload?.pending_query,
}); });
} }
} else if (act.type === "send_payment_link") {
const total = Number(actionPatch?.order_total || reducedContext?.order_total || 0) || null;
if (!total || total <= 0) {
throw new Error("order_total_missing");
}
const pref = await createPreference({
tenantId,
wooOrderId: actionPatch.woo_order_id || reducedContext?.woo_order_id || prev?.last_order_id,
amount: total || 0,
});
actionPatch.payment_link = pref?.init_point || null;
actionPatch.mp = {
preference_id: pref?.preference_id || null,
init_point: pref?.init_point || null,
};
newTools.push({ type: "send_payment_link", ok: true, preference_id: pref?.preference_id || null });
if (pref?.init_point) {
plan.reply = `Listo, acá tenés el link de pago: ${pref.init_point}\nAvisame cuando esté listo.`;
}
} }
} catch (e) { } catch (e) {
newTools.push({ type: act.type, ok: false, error: String(e?.message || e) }); newTools.push({ type: act.type, ok: false, error: String(e?.message || e) });

View File

@@ -162,6 +162,12 @@ function shouldSkipRouter(text, state, quickDomain) {
return true; return true;
} }
// En estado SHIPPING, si quickDomain ya detectó "shipping" (dirección), confiar en eso
// Esto evita que el router LLM clasifique direcciones como productos
if (state === "SHIPPING" && quickDomain === "shipping") {
return true;
}
return false; return false;
} }

View File

@@ -31,10 +31,6 @@ export async function handlePaymentState({ tenantId, text, nlu, order, audit })
currentOrder = { ...currentOrder, payment_type: paymentMethod }; currentOrder = { ...currentOrder, payment_type: paymentMethod };
actions.push({ type: "create_order", payload: { payment: paymentMethod } }); actions.push({ type: "create_order", payload: { payment: paymentMethod } });
if (paymentMethod === "link") {
actions.push({ type: "send_payment_link", payload: {} });
}
const { next_state } = safeNextState(ConversationState.PAYMENT, currentOrder, { payment_selected: true }); const { next_state } = safeNextState(ConversationState.PAYMENT, currentOrder, { payment_selected: true });
const paymentLabel = paymentMethod === "cash" ? "efectivo" : "link de pago"; const paymentLabel = paymentMethod === "cash" ? "efectivo" : "link de pago";
@@ -43,7 +39,7 @@ export async function handlePaymentState({ tenantId, text, nlu, order, audit })
: "Retiro en sucursal."; : "Retiro en sucursal.";
const paymentInfo = paymentMethod === "link" const paymentInfo = paymentMethod === "link"
? "Te paso el link de pago en un momento." ? "Te contactamos para coordinar el pago."
: "Pagás en " + (currentOrder.is_delivery ? "la entrega" : "sucursal") + "."; : "Pagás en " + (currentOrder.is_delivery ? "la entrega" : "sucursal") + ".";
return { return {

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 { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js";
import { debug as dbg } from "../shared/debug.js"; import { debug as dbg } from "../shared/debug.js";
import { getSnapshotPriceByWooId } from "../shared/wooSnapshot.js"; import { getSnapshotPriceByWooId } from "../shared/wooSnapshot.js";
import * as ordersRepo from "./ordersRepo.js";
// --- Simple in-memory lock to serialize work per key --- // --- Simple in-memory lock to serialize work per key ---
const locks = new Map(); 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) { async function withLock(key, fn) {
const prev = locks.get(key) || Promise.resolve(); const prev = locks.get(key) || Promise.resolve();
let release; let release;
@@ -310,95 +315,233 @@ export async function listRecentOrders({ tenantId, limit = 20 }) {
if (!Array.isArray(data)) return []; if (!Array.isArray(data)) return [];
// Mapear a formato simplificado // Mapear a formato simplificado
return data.map(order => { return data.map(order => 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; * Normaliza un pedido de WooCommerce a formato interno
const sourceMeta = order.meta_data?.find(m => m.key === "source"); */
const source = sourceMeta?.value || "web"; 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) // Si tenemos fecha, filtrar solo los más recientes
// 1. Primero intentar leer de metadata (para pedidos de WhatsApp) if (latestDate) {
const metaShippingMethod = order.meta_data?.find(m => m.key === "shipping_method")?.value || null; const afterDate = new Date(latestDate);
// 2. Fallback a shipping_lines de WooCommerce (para pedidos web) afterDate.setMinutes(afterDate.getMinutes() - 1);
const shippingLines = order.shipping_lines || []; url += `&after=${afterDate.toISOString()}`;
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");
} }
// Método de pago const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
// 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");
// Estado de pago (basado en status de la orden) if (!Array.isArray(data) || data.length === 0) {
// pending = no pago, processing/completed = pago break;
const isPaid = ["processing", "completed", "on-hold"].includes(order.status); }
const datePaid = order.date_paid || null;
return { // Procesar e insertar INMEDIATAMENTE esta página
id: order.id, for (const rawOrder of data) {
status: order.status, const order = normalizeWooOrder(rawOrder);
total: order.total,
currency: order.currency, const cacheOrder = {
date_created: order.date_created, woo_order_id: order.id,
date_paid: datePaid, status: order.status,
billing: { total: parseFloat(order.total) || 0,
first_name: order.billing?.first_name || "", currency: order.currency,
last_name: order.billing?.last_name || "", date_created: order.date_created,
phone: order.billing?.phone || "", date_paid: order.date_paid,
email: order.billing?.email || "", source: order.source,
address_1: order.billing?.address_1 || "", is_delivery: order.is_delivery,
address_2: order.billing?.address_2 || "", is_cash: order.is_cash,
city: order.billing?.city || "", customer_name: `${order.billing.first_name} ${order.billing.last_name}`.trim(),
state: order.billing?.state || "", customer_phone: order.billing.phone,
postcode: order.billing?.postcode || "", customer_email: order.billing.email,
}, shipping_address_1: order.shipping.address_1,
shipping: { shipping_address_2: order.shipping.address_2,
first_name: order.shipping?.first_name || "", shipping_city: order.shipping.city,
last_name: order.shipping?.last_name || "", shipping_state: order.shipping.state,
address_1: order.shipping?.address_1 || "", shipping_postcode: order.shipping.postcode,
address_2: order.shipping?.address_2 || "", shipping_country: "AR",
city: order.shipping?.city || "", billing_address_1: order.billing.address_1,
state: order.shipping?.state || "", billing_city: order.billing.city,
postcode: order.shipping?.postcode || "", billing_state: order.billing.state,
}, billing_postcode: order.billing.postcode,
line_items: (order.line_items || []).map(li => ({ raw: order.raw,
id: li.id, };
name: li.name,
quantity: li.quantity, await ordersRepo.upsertOrder({ tenantId, order: cacheOrder });
total: li.total,
})), const items = order.line_items.map(li => ({
source, woo_product_id: li.product_id || li.variation_id,
run_id: runId, product_name: li.name,
is_test: isTest, sku: li.sku,
// Shipping info quantity: parseFloat(li.quantity) || 0,
shipping_method: shippingMethod, unit_price: li.subtotal ? parseFloat(li.subtotal) / (parseFloat(li.quantity) || 1) : null,
is_delivery: isDelivery, line_total: parseFloat(li.total) || 0,
// Payment info sell_unit: detectSellUnit(li),
payment_method: paymentMethod, }));
payment_method_title: paymentMethodTitle,
is_cash: isCash, await ordersRepo.upsertOrderItems({ tenantId, wooOrderId: order.id, items });
is_paid: isPaid, 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 };
} }

View File

@@ -1,42 +0,0 @@
import { fetchPayment, reconcilePayment, verifyWebhookSignature } from "../mercadoPago.js";
export function makeMercadoPagoWebhook() {
return async function handleMercadoPagoWebhook(req, res) {
try {
const signature = verifyWebhookSignature({ headers: req.headers, query: req.query || {} });
if (!signature.ok) {
return res.status(401).json({ ok: false, error: "invalid_signature", reason: signature.reason });
}
const paymentId =
req?.query?.["data.id"] ||
req?.query?.data?.id ||
req?.body?.data?.id ||
null;
if (!paymentId) {
return res.status(400).json({ ok: false, error: "missing_payment_id" });
}
const payment = await fetchPayment({ paymentId });
const reconciled = await reconcilePayment({ payment });
return res.status(200).json({
ok: true,
payment_id: payment?.id || null,
status: payment?.status || null,
woo_order_id: reconciled?.woo_order_id || null,
});
} catch (e) {
return res.status(500).json({ ok: false, error: String(e?.message || e) });
}
};
}
export function makeMercadoPagoReturn() {
return function handleMercadoPagoReturn(req, res) {
const status = req.query?.status || "unknown";
res.status(200).send(`OK - ${status}`);
};
}

View File

@@ -1,178 +0,0 @@
import crypto from "crypto";
import { upsertMpPayment } from "../2-identity/db/repo.js";
import { updateOrderStatus } from "../4-woo-orders/wooOrders.js";
function getAccessToken() {
return process.env.MP_ACCESS_TOKEN || null;
}
function getWebhookSecret() {
return process.env.MP_WEBHOOK_SECRET || null;
}
function normalizeBaseUrl(base) {
if (!base) return null;
return base.endsWith("/") ? base : `${base}/`;
}
function getBaseUrl() {
return normalizeBaseUrl(process.env.MP_BASE_URL || process.env.MP_WEBHOOK_BASE_URL || null);
}
async function fetchMp({ url, method = "GET", body = null }) {
const token = getAccessToken();
if (!token) throw new Error("MP_ACCESS_TOKEN is not set");
const res = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let parsed;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
if (!res.ok) {
const err = new Error(`MP HTTP ${res.status}`);
err.status = res.status;
err.body = parsed;
throw err;
}
return parsed;
}
export async function createPreference({
tenantId,
wooOrderId,
amount,
payer = null,
items = null,
baseUrl = null,
}) {
const root = normalizeBaseUrl(baseUrl || getBaseUrl());
if (!root) throw new Error("MP_BASE_URL is not set");
const notificationUrl = `${root}webhook/mercadopago`;
const backUrls = {
success: `${root}return?status=success`,
failure: `${root}return?status=failure`,
pending: `${root}return?status=pending`,
};
const statementDescriptor = process.env.MP_STATEMENT_DESCRIPTOR || "Whatsapp Store";
const externalReference = `${tenantId}|${wooOrderId}`;
const unitPrice = Number(amount);
if (!Number.isFinite(unitPrice)) throw new Error("invalid_amount");
const payload = {
auto_return: "approved",
back_urls: backUrls,
statement_descriptor: statementDescriptor,
binary_mode: false,
external_reference: externalReference,
items: Array.isArray(items) && items.length
? items
: [
{
id: String(wooOrderId || "order"),
title: "Productos x whatsapp",
quantity: 1,
currency_id: "ARS",
unit_price: unitPrice,
},
],
notification_url: notificationUrl,
...(payer ? { payer } : {}),
};
const data = await fetchMp({
url: "https://api.mercadopago.com/checkout/preferences",
method: "POST",
body: payload,
});
return {
preference_id: data?.id || null,
init_point: data?.init_point || null,
sandbox_init_point: data?.sandbox_init_point || null,
raw: data,
};
}
function parseSignatureHeader(header) {
const h = String(header || "");
const parts = h.split(",");
let ts = null;
let v1 = null;
for (const p of parts) {
const [k, v] = p.split("=");
if (!k || !v) continue;
const key = k.trim();
const val = v.trim();
if (key === "ts") ts = val;
if (key === "v1") v1 = val;
}
return { ts, v1 };
}
export function verifyWebhookSignature({ headers = {}, query = {} }) {
const secret = getWebhookSecret();
if (!secret) return { ok: false, reason: "MP_WEBHOOK_SECRET is not set" };
const xSignature = headers["x-signature"] || headers["X-Signature"] || headers["x-signature"];
const xRequestId = headers["x-request-id"] || headers["X-Request-Id"] || headers["x-request-id"];
const { ts, v1 } = parseSignatureHeader(xSignature);
const dataId = query["data.id"] || query?.data?.id || null;
if (!xRequestId || !ts || !v1 || !dataId) {
return { ok: false, reason: "missing_signature_fields" };
}
const manifest = `id:${String(dataId).toLowerCase()};request-id:${xRequestId};ts:${ts};`;
const hmac = crypto.createHmac("sha256", secret);
hmac.update(manifest);
const hash = hmac.digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(v1));
return ok ? { ok: true } : { ok: false, reason: "invalid_signature" };
}
export async function fetchPayment({ paymentId }) {
if (!paymentId) throw new Error("missing_payment_id");
return await fetchMp({
url: `https://api.mercadopago.com/v1/payments/${encodeURIComponent(paymentId)}`,
method: "GET",
});
}
export function parseExternalReference(externalReference) {
if (!externalReference) return { tenantId: null, wooOrderId: null };
const parts = String(externalReference).split("|").filter(Boolean);
if (parts.length >= 2) {
return { tenantId: parts[0], wooOrderId: Number(parts[1]) || null };
}
return { tenantId: null, wooOrderId: Number(externalReference) || null };
}
export async function reconcilePayment({ tenantId, payment }) {
const status = payment?.status || null;
const paidAt = payment?.date_approved || payment?.date_created || null;
const { tenantId: refTenantId, wooOrderId } = parseExternalReference(payment?.external_reference);
const resolvedTenantId = tenantId || refTenantId;
if (!resolvedTenantId) throw new Error("tenant_id_missing_from_payment");
const saved = await upsertMpPayment({
tenant_id: resolvedTenantId,
woo_order_id: wooOrderId,
preference_id: payment?.order?.id || payment?.preference_id || null,
payment_id: String(payment?.id || ""),
status,
paid_at: paidAt,
raw: payment,
});
if (status === "approved" && wooOrderId) {
await updateOrderStatus({ tenantId: resolvedTenantId, wooOrderId, status: "processing" });
}
return { payment: saved, woo_order_id: wooOrderId, tenant_id: resolvedTenantId };
}

View File

@@ -1,10 +0,0 @@
import express from "express";
import { makeMercadoPagoReturn, makeMercadoPagoWebhook } from "../controllers/mercadoPago.js";
export function createMercadoPagoRouter() {
const router = express.Router();
router.post("/webhook/mercadopago", makeMercadoPagoWebhook());
router.get("/return", makeMercadoPagoReturn());
return router;
}