Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b933db88df | ||
|
|
d8a0677912 | ||
|
|
f838603877 | ||
|
|
5e79f17d00 | ||
|
|
2f8e267268 | ||
|
|
1e84d19db8 | ||
|
|
df9420b954 |
18
.cursor/debug.log
Normal file
18
.cursor/debug.log
Normal 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
18
Dockerfile
Normal 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"]
|
||||||
90
db/migrations/20260127100000_woo_orders_cache.sql
Normal file
90
db/migrations/20260127100000_woo_orders_cache.sql
Normal 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;
|
||||||
21
db/migrations/20260203123701_drop_mp_payments.sql
Normal file
21
db/migrations/20260203123701_drop_mp_payments.sql
Normal 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);
|
||||||
7
db/migrations/20260204155247_delivery_zones.sql
Normal file
7
db/migrations/20260204155247_delivery_zones.sql
Normal 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;
|
||||||
13
db/migrations/20260204180834_seed_tenant_piaf.sql
Normal file
13
db/migrations/20260204180834_seed_tenant_piaf.sql
Normal 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;
|
||||||
15
docker-compose.override.yaml
Normal file
15
docker-compose.override.yaml
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
123
package-lock.json
generated
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
622
public/components/home-dashboard.js
Normal file
622
public/components/home-dashboard.js
Normal 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);
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ class OrdersCrud extends HTMLElement {
|
|||||||
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>
|
||||||
:host {
|
:host {
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = "";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
360
scripts/migrate-woo-orders.mjs
Normal file
360
scripts/migrate-woo-orders.mjs
Normal 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
93
scripts/seed-tenant.mjs
Normal 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();
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|||||||
43
src/modules/0-ui/handlers/stats.js
Normal file
43
src/modules/0-ui/handlers/stats.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
408
src/modules/4-woo-orders/ordersRepo.js
Normal file
408
src/modules/4-woo-orders/ordersRepo.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,7 +315,13 @@ 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normaliza un pedido de WooCommerce a formato interno
|
||||||
|
*/
|
||||||
|
function normalizeWooOrder(order) {
|
||||||
// Detectar si es orden de test (run_id empieza con "test-")
|
// Detectar si es orden de test (run_id empieza con "test-")
|
||||||
const runIdMeta = order.meta_data?.find(m => m.key === "run_id");
|
const runIdMeta = order.meta_data?.find(m => m.key === "run_id");
|
||||||
const runId = runIdMeta?.value || null;
|
const runId = runIdMeta?.value || null;
|
||||||
@@ -319,39 +330,29 @@ export async function listRecentOrders({ tenantId, limit = 20 }) {
|
|||||||
const source = sourceMeta?.value || "web";
|
const source = sourceMeta?.value || "web";
|
||||||
|
|
||||||
// Método de envío (shipping)
|
// Método de envío (shipping)
|
||||||
// 1. Primero intentar leer de metadata (para pedidos de WhatsApp)
|
|
||||||
const metaShippingMethod = order.meta_data?.find(m => m.key === "shipping_method")?.value || null;
|
const metaShippingMethod = order.meta_data?.find(m => m.key === "shipping_method")?.value || null;
|
||||||
// 2. Fallback a shipping_lines de WooCommerce (para pedidos web)
|
|
||||||
const shippingLines = order.shipping_lines || [];
|
const shippingLines = order.shipping_lines || [];
|
||||||
const wooShippingMethod = shippingLines[0]?.method_title || shippingLines[0]?.method_id || null;
|
const wooShippingMethod = shippingLines[0]?.method_title || shippingLines[0]?.method_id || null;
|
||||||
// 3. Usar metadata si existe, sino WooCommerce
|
|
||||||
const shippingMethod = metaShippingMethod || wooShippingMethod;
|
const shippingMethod = metaShippingMethod || wooShippingMethod;
|
||||||
// 4. Determinar isDelivery
|
|
||||||
let isDelivery = false;
|
let isDelivery = false;
|
||||||
if (metaShippingMethod) {
|
if (metaShippingMethod) {
|
||||||
// Si viene de metadata, "delivery" = true, "pickup" = false
|
|
||||||
isDelivery = metaShippingMethod === "delivery";
|
isDelivery = metaShippingMethod === "delivery";
|
||||||
} else if (wooShippingMethod) {
|
} else if (wooShippingMethod) {
|
||||||
// Si viene de WooCommerce, detectar por nombre
|
|
||||||
isDelivery = !wooShippingMethod.toLowerCase().includes("retiro") &&
|
isDelivery = !wooShippingMethod.toLowerCase().includes("retiro") &&
|
||||||
!wooShippingMethod.toLowerCase().includes("pickup") &&
|
!wooShippingMethod.toLowerCase().includes("pickup") &&
|
||||||
!wooShippingMethod.toLowerCase().includes("local");
|
!wooShippingMethod.toLowerCase().includes("local");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Método de pago
|
// Método de pago
|
||||||
// 1. Primero intentar leer de metadata (para pedidos de WhatsApp)
|
|
||||||
const metaPaymentMethod = order.meta_data?.find(m => m.key === "payment_method_wa")?.value || null;
|
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 paymentMethod = order.payment_method || null;
|
||||||
const paymentMethodTitle = order.payment_method_title || null;
|
const paymentMethodTitle = order.payment_method_title || null;
|
||||||
// 3. Determinar si es cash
|
|
||||||
const isCash = metaPaymentMethod === "cash" ||
|
const isCash = metaPaymentMethod === "cash" ||
|
||||||
paymentMethod === "cod" ||
|
paymentMethod === "cod" ||
|
||||||
paymentMethodTitle?.toLowerCase().includes("efectivo") ||
|
paymentMethodTitle?.toLowerCase().includes("efectivo") ||
|
||||||
paymentMethodTitle?.toLowerCase().includes("cash");
|
paymentMethodTitle?.toLowerCase().includes("cash");
|
||||||
|
|
||||||
// Estado de pago (basado en status de la orden)
|
|
||||||
// pending = no pago, processing/completed = pago
|
|
||||||
const isPaid = ["processing", "completed", "on-hold"].includes(order.status);
|
const isPaid = ["processing", "completed", "on-hold"].includes(order.status);
|
||||||
const datePaid = order.date_paid || null;
|
const datePaid = order.date_paid || null;
|
||||||
|
|
||||||
@@ -385,20 +386,162 @@ export async function listRecentOrders({ tenantId, limit = 20 }) {
|
|||||||
line_items: (order.line_items || []).map(li => ({
|
line_items: (order.line_items || []).map(li => ({
|
||||||
id: li.id,
|
id: li.id,
|
||||||
name: li.name,
|
name: li.name,
|
||||||
|
product_id: li.product_id,
|
||||||
|
variation_id: li.variation_id,
|
||||||
quantity: li.quantity,
|
quantity: li.quantity,
|
||||||
total: li.total,
|
total: li.total,
|
||||||
|
subtotal: li.subtotal,
|
||||||
|
sku: li.sku,
|
||||||
|
meta_data: li.meta_data,
|
||||||
})),
|
})),
|
||||||
source,
|
source,
|
||||||
run_id: runId,
|
run_id: runId,
|
||||||
is_test: isTest,
|
is_test: isTest,
|
||||||
// Shipping info
|
|
||||||
shipping_method: shippingMethod,
|
shipping_method: shippingMethod,
|
||||||
is_delivery: isDelivery,
|
is_delivery: isDelivery,
|
||||||
// Payment info
|
|
||||||
payment_method: paymentMethod,
|
payment_method: paymentMethod,
|
||||||
payment_method_title: paymentMethodTitle,
|
payment_method_title: paymentMethodTitle,
|
||||||
is_cash: isCash,
|
is_cash: isCash,
|
||||||
is_paid: isPaid,
|
is_paid: isPaid,
|
||||||
|
raw: order,
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecta la unidad de venta de un line item
|
||||||
|
*/
|
||||||
|
function detectSellUnit(lineItem) {
|
||||||
|
// 1. Buscar en meta_data
|
||||||
|
const unitMeta = lineItem.meta_data?.find(m => m.key === "unit");
|
||||||
|
if (unitMeta?.value === "g" || unitMeta?.value === "kg") return "kg";
|
||||||
|
if (unitMeta?.value === "unit") return "unit";
|
||||||
|
|
||||||
|
// 2. Detectar por nombre del producto
|
||||||
|
const name = (lineItem.name || "").toLowerCase();
|
||||||
|
if (name.includes("kg") || name.includes("kilo")) return "kg";
|
||||||
|
|
||||||
|
// 3. Default a unit
|
||||||
|
return "unit";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincroniza pedidos de WooCommerce a la cache local (incremental)
|
||||||
|
* Usa un lock por tenant para evitar syncs concurrentes
|
||||||
|
*/
|
||||||
|
export async function syncOrdersIncremental({ tenantId }) {
|
||||||
|
// Si ya hay un sync en progreso para este tenant, esperar a que termine
|
||||||
|
const existingPromise = syncInProgress.get(tenantId);
|
||||||
|
if (existingPromise) {
|
||||||
|
console.log(`[wooOrders] syncOrdersIncremental already in progress for tenant ${tenantId}, waiting...`);
|
||||||
|
return existingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear promise para este sync y registrarla
|
||||||
|
const syncPromise = doSyncOrdersIncremental({ tenantId });
|
||||||
|
syncInProgress.set(tenantId, syncPromise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncPromise;
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
syncInProgress.delete(tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementación interna del sync (sin lock)
|
||||||
|
* Procesa e inserta página por página para:
|
||||||
|
* - Bajo consumo de RAM (solo ~100 pedidos en memoria a la vez)
|
||||||
|
* - Resiliencia a cortes (progreso se guarda en DB)
|
||||||
|
* - Sync incremental real (puede resumir desde donde quedó)
|
||||||
|
*/
|
||||||
|
async function doSyncOrdersIncremental({ tenantId }) {
|
||||||
|
const client = await getWooClient({ tenantId });
|
||||||
|
const latestDate = await ordersRepo.getLatestOrderDate({ tenantId });
|
||||||
|
|
||||||
|
let synced = 0;
|
||||||
|
let page = 1;
|
||||||
|
const perPage = 100; // Máximo permitido por Woo
|
||||||
|
|
||||||
|
console.log(`[wooOrders] syncOrdersIncremental starting, latestDate: ${latestDate || 'none (full sync)'}`);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// Construir URL con paginación
|
||||||
|
let url = `${client.base}/orders?per_page=${perPage}&page=${page}&orderby=date&order=desc`;
|
||||||
|
|
||||||
|
// Si tenemos fecha, filtrar solo los más recientes
|
||||||
|
if (latestDate) {
|
||||||
|
const afterDate = new Date(latestDate);
|
||||||
|
afterDate.setMinutes(afterDate.getMinutes() - 1);
|
||||||
|
url += `&after=${afterDate.toISOString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
|
||||||
|
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Procesar e insertar INMEDIATAMENTE esta página
|
||||||
|
for (const rawOrder of data) {
|
||||||
|
const order = normalizeWooOrder(rawOrder);
|
||||||
|
|
||||||
|
const cacheOrder = {
|
||||||
|
woo_order_id: order.id,
|
||||||
|
status: order.status,
|
||||||
|
total: parseFloat(order.total) || 0,
|
||||||
|
currency: order.currency,
|
||||||
|
date_created: order.date_created,
|
||||||
|
date_paid: order.date_paid,
|
||||||
|
source: order.source,
|
||||||
|
is_delivery: order.is_delivery,
|
||||||
|
is_cash: order.is_cash,
|
||||||
|
customer_name: `${order.billing.first_name} ${order.billing.last_name}`.trim(),
|
||||||
|
customer_phone: order.billing.phone,
|
||||||
|
customer_email: order.billing.email,
|
||||||
|
shipping_address_1: order.shipping.address_1,
|
||||||
|
shipping_address_2: order.shipping.address_2,
|
||||||
|
shipping_city: order.shipping.city,
|
||||||
|
shipping_state: order.shipping.state,
|
||||||
|
shipping_postcode: order.shipping.postcode,
|
||||||
|
shipping_country: "AR",
|
||||||
|
billing_address_1: order.billing.address_1,
|
||||||
|
billing_city: order.billing.city,
|
||||||
|
billing_state: order.billing.state,
|
||||||
|
billing_postcode: order.billing.postcode,
|
||||||
|
raw: order.raw,
|
||||||
|
};
|
||||||
|
|
||||||
|
await ordersRepo.upsertOrder({ tenantId, order: cacheOrder });
|
||||||
|
|
||||||
|
const items = order.line_items.map(li => ({
|
||||||
|
woo_product_id: li.product_id || li.variation_id,
|
||||||
|
product_name: li.name,
|
||||||
|
sku: li.sku,
|
||||||
|
quantity: parseFloat(li.quantity) || 0,
|
||||||
|
unit_price: li.subtotal ? parseFloat(li.subtotal) / (parseFloat(li.quantity) || 1) : null,
|
||||||
|
line_total: parseFloat(li.total) || 0,
|
||||||
|
sell_unit: detectSellUnit(li),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await ordersRepo.upsertOrderItems({ tenantId, wooOrderId: order.id, items });
|
||||||
|
synced++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log de progreso después de insertar
|
||||||
|
const totalInCache = await ordersRepo.countOrders({ tenantId });
|
||||||
|
console.log(`[wooOrders] syncOrdersIncremental page ${page}: +${data.length} orders (${totalInCache} total in DB)`);
|
||||||
|
|
||||||
|
// Si es última página
|
||||||
|
if (data.length < perPage) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalInCache = await ordersRepo.countOrders({ tenantId });
|
||||||
|
console.log(`[wooOrders] syncOrdersIncremental completed: ${synced} synced, ${totalInCache} total in cache`);
|
||||||
|
|
||||||
|
return { synced, total: totalInCache };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user