Compare commits
23 Commits
dashboard
...
cbbb88c052
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbbb88c052 | ||
|
|
448b3d7c44 | ||
|
|
3c70eb5ff7 | ||
|
|
c93955fa55 | ||
|
|
aed79078de | ||
|
|
0bf26f8eb5 | ||
|
|
675a449ce8 | ||
|
|
03621f16f4 | ||
|
|
9c69cf8911 | ||
|
|
6376739f48 | ||
|
|
7b6c62b23d | ||
|
|
6b7889ef4e | ||
|
|
17cea4aa9e | ||
|
|
04ac33430f | ||
|
|
f784ddd62d | ||
|
|
525679cf8b | ||
|
|
b933db88df | ||
|
|
d8a0677912 | ||
|
|
f838603877 | ||
|
|
5e79f17d00 | ||
|
|
2f8e267268 | ||
|
|
1e84d19db8 | ||
|
|
df9420b954 |
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal file
@@ -0,0 +1 @@
|
||||
{"sessionId":"b4e65d8e-b477-417b-9390-f3d14033ef91","pid":3138376,"procStart":"71699559","acquiredAt":1777673126273}
|
||||
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"}
|
||||
124
CLAUDE.md
Normal file
124
CLAUDE.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Start with nodemon auto-reload
|
||||
npm start # Production start
|
||||
|
||||
# Testing
|
||||
npm test # Run all tests once (vitest run)
|
||||
npm run test:watch # Watch mode
|
||||
npm run test:coverage
|
||||
|
||||
# Run a single test file
|
||||
npx vitest run src/modules/3-turn-engine/orderModel.test.js
|
||||
|
||||
# Database migrations (requires DATABASE_URL in .env)
|
||||
npm run migrate:up
|
||||
npm run migrate:down
|
||||
npm run migrate:redo
|
||||
npm run migrate:status
|
||||
npm run seed # Seed a tenant via scripts/seed-tenant.mjs
|
||||
```
|
||||
|
||||
No lint command is configured.
|
||||
|
||||
## Product goal
|
||||
|
||||
The bot must be **conversational and intelligent**, not a menu-driven flow. Customers reach out via WhatsApp **with intent to buy** — the bot's job is to:
|
||||
|
||||
1. **Engage in conversation** — answer questions about products, prices, availability/stock; recommend; clarify.
|
||||
2. **Take orders** — build a cart through natural dialogue (multi-product turns, quantities, units).
|
||||
3. **Collect delivery data** — address, delivery vs pickup, payment method.
|
||||
4. **Operate within store rules** — delivery zones, days/hours, pickup windows. These config tables (`delivery_zones`, store schedule in `tenant_settings`) will be populated later; the bot has to read and respect them when present.
|
||||
|
||||
Repetitive, hardcoded responses are a known quality problem and the focus of the active improvement plan (see `~/.claude/plans/ok-creo-que-tiene-humming-sutton.md`). The system is **not yet in production** — refactors that change behavior are acceptable.
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a **mono-tenant WhatsApp e-commerce chatbot** powered by Express.js. The store operator hooks the bot to a single WooCommerce shop; customers interact via WhatsApp to browse products, build carts, and place orders.
|
||||
|
||||
The DB schema retains `tenant_id` columns (it was originally multi-tenant) but the app boots with a single tenant resolved at startup. The single id is exposed via `src/modules/shared/tenant.js` (`getTenantId()`); webhook handlers and intake routes read from there instead of looking up tenants per-request.
|
||||
|
||||
### Request flow
|
||||
|
||||
```
|
||||
WhatsApp → Evolution API webhook → /webhook/evolution (or /sim/send)
|
||||
↓
|
||||
1-intake: route & normalize message
|
||||
↓
|
||||
2-identity/pipeline.processMessage (idempotency, history, side effects)
|
||||
↓
|
||||
3-turn-engine/agent: tool-calling LLM loop
|
||||
↓
|
||||
Response persisted to DB + sent back via Evolution API
|
||||
```
|
||||
|
||||
### Turn engine: tool-calling agent (DeepSeek)
|
||||
|
||||
`src/modules/3-turn-engine/agent/` es el único motor. Cada turno arma un **WorkingMemory** (cart, pending, last_shown_options, store, history truncado, customer_profile, preparsed quantity) y se lo pasa al LLM como user message. El LLM decide qué tools llamar:
|
||||
|
||||
- `search_catalog`, `add_to_cart`, `set_quantity`, `select_candidate`, `remove_from_cart`
|
||||
- `set_shipping`, `set_address`, `confirm_order`
|
||||
- `pause`, `escalate_to_human`
|
||||
- `say` (último siempre — es el reply al usuario)
|
||||
|
||||
El system prompt es **estático** (en `agent/systemPrompt.js` como `SYSTEM_PROMPT` const) para que DeepSeek lo cachée prefix-cache automáticamente. Cache hit ratio típico ≥70% después de 2 turnos. El parser de cantidades (`agent/quantityParser.js`) preprocesa el texto y se pasa como `working_memory.preparsed` (fracciones, "media docena", "cuarto kilo", etc.).
|
||||
|
||||
La FSM (`fsm.js`) sigue siendo guardrail: estados `IDLE / CART / SHIPPING / PAUSED / AWAITING_HUMAN` con transiciones validadas. PAUSED tiene TTL 7d (cart preservado para "después te digo").
|
||||
|
||||
### Module structure (numbered layers)
|
||||
|
||||
- **`src/modules/0-UI/`** — Admin dashboard: REST controllers para products, conversations, settings, takeovers, recommendations, aliases.
|
||||
|
||||
- **`src/modules/1-intake/`** — Message ingestion. Routes: `/simulator` (dev UI), `/webhook/evolution` (WhatsApp).
|
||||
|
||||
- **`src/modules/2-identity/`** — User mapping (WhatsApp ↔ WooCommerce customer), encrypted WooCommerce credentials, pipeline orchestrator.
|
||||
|
||||
- **`src/modules/3-turn-engine/`** — Agente tool-calling (`agent/`), FSM (`fsm.js`), order model (`orderModel.js`), catalog retrieval (`catalogRetrieval.js`), store context (`storeContext.js`).
|
||||
|
||||
- **`src/modules/4-woo-orders/`** — WooCommerce order sync (lectura). El bot crea orders nuevas vía `wooOrders.createOrder` desde `pipeline.js` cuando emite la action `create_order`.
|
||||
|
||||
- **`src/modules/shared/`** — DB pool, SSE, WooSnapshot, tenant resolver (`getTenantId()`), debug.
|
||||
|
||||
### Key integrations
|
||||
|
||||
| System | Purpose | Config |
|
||||
|--------|---------|--------|
|
||||
| LLM (DeepSeek) | Agente tool-calling — único motor | `OPENAI_API_KEY`, `OPENAI_BASE_URL=https://api.deepseek.com/v1`, `OPENAI_MODEL=deepseek-chat` |
|
||||
| Evolution API | WhatsApp send/receive | `EVOLUTION_*`, `EVOLUTION_SEND_ENABLED` |
|
||||
| WooCommerce REST API | Products, orders, customers | `WOO_BASE_URL`, `WOO_CONSUMER_KEY`, `WOO_CONSUMER_SECRET` |
|
||||
| PostgreSQL | Primary database | `DATABASE_URL` |
|
||||
|
||||
### Database
|
||||
|
||||
Migrations live in `db/migrations/` as timestamped SQL files managed by `dbmate`. Key tables:
|
||||
- `tenants`, `tenant_config`, `tenant_settings`, `tenant_ecommerce_config`, `tenant_channels`
|
||||
- `wa_identity_map` — WhatsApp ↔ WooCommerce customer mapping
|
||||
- `wa_conversation_state` — FSM state + context (cart, pending, last_shown_options, paused_until) en JSONB
|
||||
- `wa_messages` — Message history (idempotencia por message_id)
|
||||
- `woo_products_snapshot` — Cached product catalog (con índices pg_trgm en aliases)
|
||||
- `product_aliases`, `alias_product_mappings` — fuzzy alias resolution
|
||||
- `woo_orders_cache` + `woo_order_items` — orders sync para customer_profile / stats
|
||||
- `human_takeovers`, `audit_log`, `conversation_runs`
|
||||
|
||||
### Feature flags (env vars)
|
||||
|
||||
- `AGENT_MAX_TOOL_CALLS=10` — cap de tool calls por turno
|
||||
- `AGENT_TURN_TIMEOUT_MS=25000` — timeout total del turno
|
||||
- `EVOLUTION_SEND_ENABLED=1` — enviar a WhatsApp real (off en dev)
|
||||
- `DEBUG_PERF`, `DEBUG_WOO_HTTP`, `DEBUG_LLM`, `DEBUG_EVOLUTION` — debug logs granular
|
||||
|
||||
### Métricas
|
||||
|
||||
- `GET /api/metrics/agent` — turns, avg tool calls, fallback rate, escalations, **cache_hit_ratio** (prompt caching de DeepSeek)
|
||||
|
||||
### Local development
|
||||
|
||||
Copy `env.example` to `.env` and fill in values. Use `docker-compose.override.yaml` for local overrides. Run `docker compose up` to start app + Postgres + Redis. The Dockerfile runs migrations automatically on startup (`migrate:up && seed && start`).
|
||||
|
||||
Test files use Vitest with `globals: true` — no need to import `describe`, `it`, `expect`.
|
||||
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;
|
||||
17
db/migrations/20260501100000_product_aliases_trgm.sql
Normal file
17
db/migrations/20260501100000_product_aliases_trgm.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- migrate:up
|
||||
-- pg_trgm para fuzzy matching de aliases:
|
||||
-- - Captura plurales (vacio↔vacios), diminutivos (costillita↔costilla),
|
||||
-- typos (vasio↔vacio) sin escribir reglas.
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS product_aliases_norm_trgm_idx
|
||||
ON product_aliases USING gin (normalized_alias gin_trgm_ops);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS alias_product_mappings_alias_trgm_idx
|
||||
ON alias_product_mappings USING gin (alias gin_trgm_ops);
|
||||
|
||||
-- migrate:down
|
||||
DROP INDEX IF EXISTS alias_product_mappings_alias_trgm_idx;
|
||||
DROP INDEX IF EXISTS product_aliases_norm_trgm_idx;
|
||||
-- Intencionalmente NO se hace DROP EXTENSION pg_trgm:
|
||||
-- puede ser usada por otras consultas/migraciones futuras.
|
||||
24
db/migrations/20260501110000_reply_templates.sql
Normal file
24
db/migrations/20260501110000_reply_templates.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- migrate:up
|
||||
-- Templates de respuestas con variantes para evitar repetición.
|
||||
-- Filosofía: cada slot semántico (ej. cart.ask_more) tiene N variantes;
|
||||
-- el código rota entre ellas excluyendo las recientemente usadas.
|
||||
CREATE TABLE reply_templates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
template_key VARCHAR(80) NOT NULL,
|
||||
variant INTEGER NOT NULL DEFAULT 1,
|
||||
content TEXT NOT NULL,
|
||||
weight INTEGER NOT NULL DEFAULT 1,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_reply_variant UNIQUE(tenant_id, template_key, variant)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reply_active
|
||||
ON reply_templates(tenant_id, template_key)
|
||||
WHERE is_active = true;
|
||||
|
||||
-- migrate:down
|
||||
DROP INDEX IF EXISTS idx_reply_active;
|
||||
DROP TABLE IF EXISTS reply_templates;
|
||||
@@ -0,0 +1,88 @@
|
||||
-- migrate:up
|
||||
-- Seed mono-tenant: settings de tienda (horarios + zonas de delivery) +
|
||||
-- reply_templates con todas las variantes hoy hardcodeadas en DEFAULTS.
|
||||
-- Esto desbloquea editar respuestas vía UI/SQL sin redeploy.
|
||||
|
||||
-- 1) tenant_settings: schedule + delivery_zones para piaf
|
||||
UPDATE tenant_settings
|
||||
SET
|
||||
store_name = COALESCE(NULLIF(store_name, 'Mi Negocio'), 'Piaf'),
|
||||
bot_name = COALESCE(NULLIF(bot_name, 'Bot'), 'Piaf'),
|
||||
pickup_enabled = true,
|
||||
schedule = '{
|
||||
"pickup": {
|
||||
"lun": {"enabled": true, "start": "09:00", "end": "20:00"},
|
||||
"mar": {"enabled": true, "start": "09:00", "end": "20:00"},
|
||||
"mie": {"enabled": true, "start": "09:00", "end": "20:00"},
|
||||
"jue": {"enabled": true, "start": "09:00", "end": "20:00"},
|
||||
"vie": {"enabled": true, "start": "09:00", "end": "20:00"},
|
||||
"sab": {"enabled": true, "start": "09:00", "end": "13:00"}
|
||||
}
|
||||
}'::jsonb,
|
||||
delivery_zones = '{}'::jsonb
|
||||
WHERE tenant_id = 'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid;
|
||||
|
||||
-- 2) reply_templates: seed con DEFAULTS de replyTemplates.js para piaf
|
||||
INSERT INTO reply_templates (tenant_id, template_key, variant, content, weight)
|
||||
SELECT
|
||||
'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid,
|
||||
v.template_key,
|
||||
v.variant,
|
||||
v.content,
|
||||
v.weight
|
||||
FROM (VALUES
|
||||
-- IDLE
|
||||
('idle.greeting', 1, '¡Hola! ¿En qué te puedo ayudar?', 1),
|
||||
('idle.greeting', 2, '¡Hola! Estoy para ayudarte con tu pedido. ¿Qué andás buscando?', 1),
|
||||
('idle.greeting', 3, 'Buenas. ¿Querés que te muestre algo en particular o hacemos un pedido?', 1),
|
||||
('idle.help_prompt', 1, 'Decime qué necesitás. Podés pedirme productos, precios, o armar el pedido directo.', 1),
|
||||
('idle.help_prompt', 2, '¿Qué te tiro? Podés pedir algo, preguntar precios o consultar disponibilidad.', 1),
|
||||
-- CART
|
||||
('cart.ask_more', 1, '¿Algo más?', 1),
|
||||
('cart.ask_more', 2, '¿Querés agregar algo más al pedido?', 1),
|
||||
('cart.ask_more', 3, '¿Sumamos algo más o cerramos así?', 1),
|
||||
('cart.empty_prompt', 1, 'Tu carrito está vacío. ¿Qué querés agregar?', 1),
|
||||
('cart.empty_prompt', 2, 'Todavía no hay nada en el carrito. ¿Por dónde empezamos?', 1),
|
||||
('cart.not_found', 1, 'No encontré "{{query}}". ¿Podés decirlo de otra forma?', 1),
|
||||
('cart.not_found', 2, 'Mmm, no tengo "{{query}}" exacto. ¿Probamos con otra cosa?', 1),
|
||||
('cart.not_found', 3, 'No me aparece "{{query}}". Si querés, dame otro nombre o detalle más.', 1),
|
||||
('cart.didnt_understand', 1, 'Perdón, no te entendí.', 1),
|
||||
('cart.didnt_understand', 2, 'No me quedó claro, ¿me lo decís de otra forma?', 1),
|
||||
('cart.didnt_understand', 3, 'No te seguí, ¿podés repetir?', 1),
|
||||
('cart.skip_acknowledged', 1, 'Ok, lo dejamos.', 1),
|
||||
('cart.skip_acknowledged', 2, 'Listo, no lo agregamos.', 1),
|
||||
('cart.confirm_to_shipping', 1, 'Buenísimo. ¿Es para delivery o lo pasás a buscar?', 1),
|
||||
('cart.confirm_to_shipping', 2, 'Perfecto. ¿Te lo enviamos o lo retirás?', 1),
|
||||
('cart.pending_before_close', 1, 'Antes de cerrar, ¿qué hacemos con lo que quedó pendiente?', 1),
|
||||
('cart.pending_before_close', 2, 'Tenemos algo pendiente para resolver antes de cerrar el pedido.', 1),
|
||||
('cart.added_confirm', 1, 'Anoté {{summary}}. ¿Algo más?', 1),
|
||||
('cart.added_confirm', 2, 'Listo, {{summary}} agregado. ¿Sumamos algo más?', 1),
|
||||
('cart.added_confirm', 3, 'Sumé {{summary}}. ¿Querés agregar algo más?', 1),
|
||||
('cart.added_confirm', 4, 'Va {{summary}}. ¿Algo más?', 1),
|
||||
('cart.ask_what_product', 1, '¿Qué producto querés?', 1),
|
||||
('cart.ask_what_product', 2, 'Decime el producto y lo busco.', 1),
|
||||
('cart.price_no_query', 1, '¿De qué producto querés saber el precio?', 1),
|
||||
('cart.price_no_query', 2, 'Decime el producto y te paso el precio.', 1),
|
||||
('cart.price_results_header', 1, 'Estos son los precios:', 1),
|
||||
('cart.price_results_header', 2, 'Precios disponibles:', 1),
|
||||
-- SHIPPING (incluye {{delivery_zones_summary}} y {{delivery_hours}} cuando hay datos)
|
||||
('shipping.ask_method', 1, '¿Lo enviamos a domicilio o lo pasás a buscar?', 1),
|
||||
('shipping.ask_method', 2, '¿Es para delivery o pickup?', 1),
|
||||
('shipping.ask_address', 1, 'Pasame la dirección de entrega.', 1),
|
||||
('shipping.ask_address', 2, 'Decime dónde lo entregamos (calle y altura). Hacemos delivery en {{delivery_zones_summary}}.', 1),
|
||||
('shipping.address_recorded', 1, 'Anotado: {{address}}.', 1),
|
||||
('shipping.address_recorded', 2, 'Listo, dirección guardada: {{address}}.', 1),
|
||||
-- ORDER CLOSE
|
||||
('order.confirmed', 1, '¡Listo! Anotamos tu pedido. Te coordinamos por acá la entrega.', 1),
|
||||
('order.confirmed', 2, 'Perfecto, ya quedó registrado. Te confirmamos en breve los detalles de entrega.', 1),
|
||||
('order.confirmed', 3, 'Genial, anotado. Cualquier ajuste avisame por acá.', 1)
|
||||
) AS v(template_key, variant, content, weight)
|
||||
ON CONFLICT (tenant_id, template_key, variant) DO NOTHING;
|
||||
|
||||
-- migrate:down
|
||||
DELETE FROM reply_templates
|
||||
WHERE tenant_id = 'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid;
|
||||
-- Settings: limpiar schedule/zones (no borrar la fila)
|
||||
UPDATE tenant_settings
|
||||
SET schedule = '{}'::jsonb, delivery_zones = '{}'::jsonb
|
||||
WHERE tenant_id = 'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid;
|
||||
13
db/migrations/20260502181148_drop_legacy_zones_format.sql
Normal file
13
db/migrations/20260502181148_drop_legacy_zones_format.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- migrate:up
|
||||
-- Limpiar formato legacy de delivery_zones (caba.barrios, flat) y dejar solo
|
||||
-- el schema nuevo { zones: [...] }. Pre-prod: no preservamos data legacy.
|
||||
UPDATE tenant_settings
|
||||
SET delivery_zones = '{}'::jsonb
|
||||
WHERE
|
||||
delivery_zones IS NULL
|
||||
OR NOT (delivery_zones ? 'zones')
|
||||
OR jsonb_typeof(delivery_zones->'zones') <> 'array';
|
||||
|
||||
-- migrate:down
|
||||
-- noop: no preservamos el formato legacy.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- migrate:up
|
||||
-- Borrar columnas obsoletas de tenant_settings: ahora cada zona de delivery
|
||||
-- tiene su propio costo, días y rango horario. Schedule.delivery también queda
|
||||
-- obsoleto (sólo pickup hace sentido como horario único de la tienda física).
|
||||
ALTER TABLE tenant_settings
|
||||
DROP COLUMN IF EXISTS delivery_enabled,
|
||||
DROP COLUMN IF EXISTS delivery_days,
|
||||
DROP COLUMN IF EXISTS delivery_hours_start,
|
||||
DROP COLUMN IF EXISTS delivery_hours_end,
|
||||
DROP COLUMN IF EXISTS delivery_min_order;
|
||||
|
||||
UPDATE tenant_settings
|
||||
SET schedule = (schedule - 'delivery')
|
||||
WHERE schedule ? 'delivery';
|
||||
|
||||
-- migrate:down
|
||||
-- noop: no preservamos los campos legacy.
|
||||
SELECT 1;
|
||||
17
docker-compose.override.yaml
Normal file
17
docker-compose.override.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
# 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:
|
||||
- "3001: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
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
@@ -1,20 +1,18 @@
|
||||
services:
|
||||
app:
|
||||
image: node:20-alpine
|
||||
build: .
|
||||
working_dir: /usr/src/app
|
||||
command: sh -c "npm install && npm run dev"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- .env
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- DATABASE_URL=postgres://${POSTGRES_USER:-botino}:${POSTGRES_PASSWORD:-botino}@db:5432/${POSTGRES_DB:-botino}
|
||||
- REDIS_URL=redis://redis:6379
|
||||
volumes:
|
||||
- .:/usr/src/app
|
||||
- /usr/src/app/node_modules
|
||||
# Variables para seed (configurar en Coolify)
|
||||
- APP_ENCRYPTION_KEY=${APP_ENCRYPTION_KEY:-}
|
||||
- WOO_CONSUMER_KEY=${WOO_CONSUMER_KEY:-}
|
||||
- WOO_CONSUMER_SECRET=${WOO_CONSUMER_SECRET:-}
|
||||
- WOO_BASE_URL=${WOO_BASE_URL:-}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -35,7 +33,11 @@ services:
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
29
env.example
29
env.example
@@ -13,22 +13,17 @@ PG_CONN_TIMEOUT_MS=5000
|
||||
APP_ENCRYPTION_KEY=your-32-char-encryption-key-here
|
||||
|
||||
# ===================
|
||||
# OpenAI
|
||||
# LLM (OpenAI-compatible: DeepSeek, OpenAI, Anthropic via gateway, etc.)
|
||||
# ===================
|
||||
# Default actual: DeepSeek V3.x con tool-calling nativo + prompt caching automático.
|
||||
OPENAI_API_KEY=sk-xxx
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
OPENAI_BASE_URL=https://api.deepseek.com/v1
|
||||
OPENAI_MODEL=deepseek-chat
|
||||
|
||||
# ===================
|
||||
# Turn Engine
|
||||
# WooCommerce
|
||||
# ===================
|
||||
# v1 = pipeline actual (heurísticas + guardrails + LLM plan final)
|
||||
# v2 = LLM-first NLU, deterministic core (nuevo motor)
|
||||
TURN_ENGINE=v1
|
||||
|
||||
# ===================
|
||||
# WooCommerce (fallback si falta config por tenant)
|
||||
# ===================
|
||||
WOO_BASE_URL=https://tu-tienda.com
|
||||
WOO_BASE_URL=https://tu-tienda.com/wp-json/wc/v3
|
||||
WOO_CONSUMER_KEY=ck_xxx
|
||||
WOO_CONSUMER_SECRET=cs_xxx
|
||||
|
||||
@@ -40,6 +35,18 @@ EVOLUTION_API_KEY=your-api-key
|
||||
EVOLUTION_INSTANCE_NAME=piaf
|
||||
EVOLUTION_SEND_ENABLED=0 # 0=solo BD (pruebas), 1=envía a WhatsApp (producción)
|
||||
|
||||
# ===================
|
||||
# Limits
|
||||
# ===================
|
||||
LIMIT_CONVERSATIONS=100
|
||||
MAX_CHARS_PER_MESSAGE=4000
|
||||
|
||||
# ===================
|
||||
# Agent (tool-calling) — único motor de turno
|
||||
# ===================
|
||||
AGENT_MAX_TOOL_CALLS=10
|
||||
AGENT_TURN_TIMEOUT_MS=25000
|
||||
|
||||
# ===================
|
||||
# Debug Flags (1/true/yes/on para activar)
|
||||
# ===================
|
||||
|
||||
17
index.js
17
index.js
@@ -1,10 +1,10 @@
|
||||
import "dotenv/config";
|
||||
import { ensureTenant } from "./src/modules/2-identity/db/repo.js";
|
||||
import { setTenant } from "./src/modules/shared/tenant.js";
|
||||
import { createApp } from "./src/app.js";
|
||||
|
||||
async function configureUndiciDispatcher() {
|
||||
// Node 18+ usa undici debajo de fetch. Esto suele arreglar timeouts “fantasma” por keep-alive/pooling.
|
||||
// Nota: si el módulo `undici` no está disponible, no rompemos el arranque (solo logueamos warning).
|
||||
// Node 18+ usa undici debajo de fetch. Esto suele arreglar timeouts "fantasma" por keep-alive/pooling.
|
||||
try {
|
||||
const { setGlobalDispatcher, Agent } = await import("undici");
|
||||
setGlobalDispatcher(
|
||||
@@ -21,21 +21,14 @@ async function configureUndiciDispatcher() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* --- Tenant ---
|
||||
*/
|
||||
const TENANT_KEY = process.env.TENANT_KEY || "piaf";
|
||||
let TENANT_ID = null;
|
||||
|
||||
/**
|
||||
* --- Boot ---
|
||||
*/
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
(async function boot() {
|
||||
await configureUndiciDispatcher();
|
||||
TENANT_ID = await ensureTenant({ key: TENANT_KEY, name: TENANT_KEY.toUpperCase() });
|
||||
const app = createApp({ tenantId: TENANT_ID });
|
||||
const tenantId = await ensureTenant({ key: TENANT_KEY, name: TENANT_KEY.toUpperCase() });
|
||||
setTenant({ id: tenantId, key: TENANT_KEY });
|
||||
const app = createApp({ tenantId });
|
||||
app.listen(port, () => console.log(`UI: http://localhost:${port} (tenant=${TENANT_KEY})`));
|
||||
})().catch((err) => {
|
||||
console.error("Boot failed:", err);
|
||||
|
||||
144
package-lock.json
generated
144
package-lock.json
generated
@@ -12,16 +12,18 @@
|
||||
"ajv": "^8.17.1",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parse": "^6.1.0",
|
||||
"dbmate": "^2.0.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.19.2",
|
||||
"mysql2": "^3.16.2",
|
||||
"openai": "^6.15.0",
|
||||
"pg": "^8.16.3",
|
||||
"undici": "^7.16.0",
|
||||
"xstate": "^5.31.0",
|
||||
"zod": "^4.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"dbmate": "^2.0.0",
|
||||
"nodemon": "^3.0.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
@@ -93,7 +95,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -107,7 +108,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -121,7 +121,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -135,7 +134,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -149,7 +147,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -163,7 +160,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -177,7 +173,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1249,6 +1244,15 @@
|
||||
"js-tokens": "^9.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-ssl-profiles": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -1456,7 +1460,6 @@
|
||||
"version": "2.28.0",
|
||||
"resolved": "https://registry.npmjs.org/dbmate/-/dbmate-2.28.0.tgz",
|
||||
"integrity": "sha512-kbJ+Aqna/SOsS86RuimX8X/qmo9ItG00EYMUfUQmo3xGoXBac4+PZwOYV0fVPIGakIRljNolhfJySkPAFjmZsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"dbmate": "dist/cli.js"
|
||||
@@ -1480,6 +1483,15 @@
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -1795,6 +1807,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/generate-function": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-property": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -1998,6 +2019,12 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-property": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
@@ -2073,6 +2100,27 @@
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lru.min": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz",
|
||||
"integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.0.0",
|
||||
"deno": ">=1.30.0",
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wellwelwel"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -2199,6 +2247,54 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.16.2",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.16.2.tgz",
|
||||
"integrity": "sha512-JsqBpYNy7pH20lGfPuSyRSIcCxSeAIwxWADpV64nP9KeyN3ZKpHZgjKXuBKsh7dH6FbOvf1bOgoVKjSUPXRMTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.2",
|
||||
"denque": "^2.1.0",
|
||||
"generate-function": "^2.3.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"long": "^5.3.2",
|
||||
"lru.min": "^1.1.3",
|
||||
"named-placeholders": "^1.1.6",
|
||||
"seq-queue": "^0.0.5",
|
||||
"sqlstring": "^2.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mysql2/node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/named-placeholders": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
||||
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lru.min": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -2750,6 +2846,11 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/seq-queue": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
||||
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||
@@ -2882,6 +2983,15 @@
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sqlstring": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
||||
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
@@ -3046,9 +3156,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
|
||||
"integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==",
|
||||
"version": "7.19.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.19.1.tgz",
|
||||
"integrity": "sha512-Gpq0iNm5M6cQWlyHQv9MV+uOj1jWk7LpkoE5vSp/7zjb4zMdAcUD+VL5y0nH4p9EbUklq00eVIIX/XcDHzu5xg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
@@ -3295,6 +3405,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/xstate": {
|
||||
"version": "5.31.0",
|
||||
"resolved": "https://registry.npmjs.org/xstate/-/xstate-5.31.0.tgz",
|
||||
"integrity": "sha512-5B+0DqC0uNUrcLUEY3pn3iNy+swvK2E0ZpYp5gnV3oxMX5y87vzXkU5YXv9CAtyG5c5FOJ1SzvTWHrwE8fMZNQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/xstate"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"migrate:up": "dbmate up",
|
||||
"migrate:down": "dbmate down",
|
||||
"migrate:redo": "dbmate rollback && dbmate up",
|
||||
"migrate:status": "dbmate status"
|
||||
"migrate:status": "dbmate status",
|
||||
"seed": "node scripts/seed-tenant.mjs"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Lucas Tettamanti",
|
||||
@@ -22,16 +23,18 @@
|
||||
"ajv": "^8.17.1",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parse": "^6.1.0",
|
||||
"dbmate": "^2.0.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.19.2",
|
||||
"mysql2": "^3.16.2",
|
||||
"openai": "^6.15.0",
|
||||
"pg": "^8.16.3",
|
||||
"undici": "^7.16.0",
|
||||
"xstate": "^5.31.0",
|
||||
"zod": "^4.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"dbmate": "^2.0.0",
|
||||
"nodemon": "^3.0.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "./components/ops-shell.js";
|
||||
import "./components/home-dashboard.js";
|
||||
import "./components/run-timeline.js";
|
||||
import "./components/chat-simulator.js";
|
||||
import "./components/conversation-inspector.js";
|
||||
@@ -9,9 +10,8 @@ import "./components/aliases-crud.js";
|
||||
import "./components/recommendations-crud.js";
|
||||
import "./components/quantities-crud.js";
|
||||
import "./components/orders-crud.js";
|
||||
import "./components/test-panel.js";
|
||||
import "./components/prompts-crud.js";
|
||||
import "./components/takeovers-crud.js";
|
||||
import "./components/zone-map-editor.js";
|
||||
import "./components/settings-crud.js";
|
||||
import { connectSSE } from "./lib/sse.js";
|
||||
import { initRouter } from "./lib/router.js";
|
||||
|
||||
@@ -20,45 +20,45 @@ class AliasesCrud extends HTMLElement {
|
||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
|
||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||
|
||||
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
|
||||
input, select, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
||||
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
|
||||
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||
button:hover { background:#1a5fd0; }
|
||||
input, select, textarea { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
||||
input:focus, select:focus, textarea:focus { outline:none; border-color:var(--accent); }
|
||||
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||
button:hover { background:var(--accent-hover); }
|
||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||
button.secondary { background:#253245; }
|
||||
button.secondary:hover { background:#2d3e52; }
|
||||
button.danger { background:#e74c3c; }
|
||||
button.danger:hover { background:#c0392b; }
|
||||
button.secondary { background:var(--border-hi); }
|
||||
button.secondary:hover { background:var(--border-hi); }
|
||||
button.danger { background:var(--err); }
|
||||
button.danger:hover { background:var(--err); }
|
||||
button.small { padding:4px 8px; font-size:11px; }
|
||||
|
||||
.list { flex:1; overflow-y:auto; }
|
||||
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
||||
.item:hover { border-color:#1f6feb; }
|
||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
||||
.item-alias { font-weight:600; color:#e7eef7; margin-bottom:4px; font-size:15px; }
|
||||
.item-products { font-size:12px; color:#8aa0b5; }
|
||||
.item-boost { color:#2ecc71; font-size:11px; }
|
||||
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
||||
.item:hover { border-color:var(--accent); }
|
||||
.item.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||
.item-alias { font-weight:600; color:var(--text); margin-bottom:4px; font-size:15px; }
|
||||
.item-products { font-size:12px; color:var(--text-muted); }
|
||||
.item-boost { color:var(--ok); font-size:11px; }
|
||||
|
||||
.form { flex:1; overflow-y:auto; }
|
||||
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
||||
.form-empty { color:var(--text-muted); text-align:center; padding:40px; }
|
||||
.field { margin-bottom:16px; }
|
||||
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.field-hint { font-size:11px; color:#8aa0b5; margin-top:4px; }
|
||||
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.field-hint { font-size:11px; color:var(--text-muted); margin-top:4px; }
|
||||
|
||||
.actions { display:flex; gap:8px; margin-top:16px; }
|
||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
||||
.loading { text-align:center; padding:40px; color:var(--text-muted); }
|
||||
|
||||
/* Product mappings table */
|
||||
.mappings-table { width:100%; border-collapse:collapse; margin-top:8px; }
|
||||
.mappings-table th { text-align:left; font-size:11px; color:#8aa0b5; padding:8px 4px; border-bottom:1px solid #253245; }
|
||||
.mappings-table td { padding:6px 4px; border-bottom:1px solid #1e2a3a; vertical-align:middle; }
|
||||
.mappings-table th { text-align:left; font-size:11px; color:var(--text-muted); padding:8px 4px; border-bottom:1px solid var(--border-hi); }
|
||||
.mappings-table td { padding:6px 4px; border-bottom:1px solid var(--border); vertical-align:middle; }
|
||||
.mappings-table input[type="number"] { width:70px; padding:6px 8px; font-size:12px; }
|
||||
.mappings-table .product-name { font-size:13px; color:#e7eef7; }
|
||||
.mappings-table .btn-remove { background:#e74c3c; padding:4px 8px; font-size:11px; }
|
||||
.mappings-table .product-name { font-size:13px; color:var(--text); }
|
||||
.mappings-table .btn-remove { background:var(--err); padding:4px 8px; font-size:11px; }
|
||||
|
||||
.add-mapping-row { display:flex; gap:8px; margin-top:12px; align-items:flex-end; }
|
||||
.add-mapping-row .field { margin-bottom:0; }
|
||||
@@ -67,19 +67,19 @@ class AliasesCrud extends HTMLElement {
|
||||
.product-selector { position:relative; }
|
||||
.product-dropdown {
|
||||
position:absolute; top:100%; left:0; right:0; z-index:100;
|
||||
background:#0f1520; border:1px solid #253245; border-radius:8px;
|
||||
background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px;
|
||||
max-height:200px; overflow-y:auto; display:none;
|
||||
}
|
||||
.product-dropdown.open { display:block; }
|
||||
.product-option {
|
||||
padding:8px 12px; cursor:pointer; font-size:13px; color:#e7eef7;
|
||||
padding:8px 12px; cursor:pointer; font-size:13px; color:var(--text);
|
||||
display:flex; justify-content:space-between; align-items:center;
|
||||
}
|
||||
.product-option:hover { background:#1a2535; }
|
||||
.product-option .price { font-size:11px; color:#8aa0b5; }
|
||||
.product-option:hover { background:var(--panel-2); }
|
||||
.product-option .price { font-size:11px; color:var(--text-muted); }
|
||||
|
||||
.empty-hint { color:#8aa0b5; font-size:12px; font-style:italic; }
|
||||
.badge { display:inline-block; padding:2px 6px; border-radius:999px; font-size:10px; background:#253245; color:#8aa0b5; margin-left:4px; }
|
||||
.empty-hint { color:var(--text-muted); font-size:12px; font-style:italic; }
|
||||
.badge { display:inline-block; padding:2px 6px; border-radius:999px; font-size:10px; background:var(--border-hi); color:var(--text-muted); margin-left:4px; }
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { api } from "../lib/api.js";
|
||||
import { emit, on } from "../lib/bus.js";
|
||||
import { modal } from "../lib/modal.js";
|
||||
import { toast } from "../lib/toast.js";
|
||||
|
||||
class ChatSimulator extends HTMLElement {
|
||||
constructor() {
|
||||
@@ -10,25 +11,49 @@ class ChatSimulator extends HTMLElement {
|
||||
this._sending = false;
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display:block; height:100%; overflow:hidden; }
|
||||
:host { display:block; height:100%; overflow:hidden; font-family: var(--font-sans); background: var(--panel); }
|
||||
* { box-sizing:border-box; }
|
||||
.container { display:grid; grid-template-columns:1fr 1fr; gap:0; height:100%; overflow:hidden; }
|
||||
.col { display:flex; flex-direction:column; padding:10px 12px; border-right:1px solid #1e2a3a; min-width:0; overflow:hidden; }
|
||||
.container { display:grid; grid-template-columns:1fr 1fr; gap:0; height:100%; min-height:0; overflow:hidden; }
|
||||
.col {
|
||||
display:flex; flex-direction:column;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-right: 1px solid var(--border);
|
||||
min-width:0; min-height:0; overflow:hidden;
|
||||
}
|
||||
.col:last-child { border-right:none; }
|
||||
.muted { color:#8aa0b5; font-size:11px; margin-bottom:6px; }
|
||||
.row { display:flex; gap:8px; align-items:center; }
|
||||
input,textarea,button { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:6px; padding:6px 8px; font-size:12px; box-sizing:border-box; }
|
||||
.muted { color: var(--text-muted); font-size: var(--fs-xs); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.06em; font-weight: var(--fw-semibold); }
|
||||
.row { display:flex; gap: var(--space-2); align-items:center; }
|
||||
input, textarea, button, select {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-hi);
|
||||
border-radius: var(--r-md);
|
||||
padding: 8px 12px;
|
||||
font: 400 var(--fs-sm)/1.4 var(--font-sans);
|
||||
box-sizing:border-box;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); box-shadow: var(--focus-ring); }
|
||||
input { width:100%; min-width:0; }
|
||||
textarea { width:100%; resize:none; height:186px; min-width:0; margin-bottom:10px;}
|
||||
button { cursor:pointer; white-space:nowrap; }
|
||||
button.primary { background:#1f6feb; border-color:#1f6feb; }
|
||||
button:disabled { opacity:.6; cursor:not-allowed; }
|
||||
.status { font-size:11px; color:#8aa0b5; margin-top:6px; }
|
||||
.inputs-col { display:flex; flex-direction:column; gap:6px; flex:1; overflow:hidden; min-width:0; }
|
||||
textarea {
|
||||
width:100%; resize:vertical;
|
||||
min-height:120px; max-height:60vh;
|
||||
min-width:0; margin-bottom: var(--space-3);
|
||||
word-break:break-word; overflow-wrap:anywhere;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
button { cursor:pointer; white-space:nowrap; font-weight: var(--fw-medium); }
|
||||
button:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-hover); }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: var(--text-on-acc); }
|
||||
button.primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--text-on-acc); }
|
||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||
.status { font-size: var(--fs-xs); color: var(--text-muted); margin-top: 8px; word-break:break-word; }
|
||||
.inputs-col { display:flex; flex-direction:column; gap: 10px; flex:1; min-height:0; min-width:0; overflow-y:auto; padding-right: 4px; }
|
||||
.field { display:flex; flex-direction:column; min-width:0; }
|
||||
.field label { font-size:10px; color:#8aa0b5; margin-bottom:2px; }
|
||||
.msg-col { display:flex; flex-direction:column; min-width:0; }
|
||||
.msg-bottom { display:flex; gap:8px; align-items:center; margin-top:6px; }
|
||||
.field label { font-size: var(--fs-xs); color: var(--text-muted); margin-bottom: 4px; font-weight: var(--fw-medium); }
|
||||
.msg-col { display:flex; flex-direction:column; min-width:0; min-height:0; flex:1; }
|
||||
.msg-bottom { display:flex; gap: var(--space-2); align-items:center; margin-top: 8px; flex-wrap:wrap; }
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
@@ -167,14 +192,15 @@ class ChatSimulator extends HTMLElement {
|
||||
console.log("[evolution sim] webhook response:", data);
|
||||
|
||||
if (!data.ok) {
|
||||
statusEl.textContent = "Error enviando (ver consola)";
|
||||
toast({ kind: "error", text: `Sim Evolution: ${data.error || "respuesta no-ok"}` });
|
||||
return;
|
||||
}
|
||||
|
||||
evoTextEl.value = "";
|
||||
evoTextEl.focus();
|
||||
} catch (e) {
|
||||
statusEl.textContent = `Error: ${String(e?.message || e)}`;
|
||||
// safeFetch ya disparó toast; sólo logueamos.
|
||||
console.error("[chat-simulator] send error:", e);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@@ -187,10 +213,10 @@ class ChatSimulator extends HTMLElement {
|
||||
api
|
||||
.retryLast(chat_id)
|
||||
.then((r) => {
|
||||
if (!r?.ok) statusEl.textContent = `Retry error: ${r?.error || "unknown"}`;
|
||||
else statusEl.textContent = "Retry enviado.";
|
||||
if (!r?.ok) toast({ kind: "error", text: `Retry: ${r?.error || "fallo"}` });
|
||||
else toast({ kind: "ok", text: "Retry enviado." });
|
||||
})
|
||||
.catch((e) => (statusEl.textContent = `Retry error: ${String(e?.message || e)}`))
|
||||
.catch((e) => console.error("[chat-simulator] retry error:", e))
|
||||
.finally(() => setSending(false));
|
||||
};
|
||||
retryEl.disabled = false;
|
||||
|
||||
@@ -14,34 +14,68 @@ class ConversationInspector extends HTMLElement {
|
||||
this._playing = false;
|
||||
this._playIdx = 0;
|
||||
this._timer = null;
|
||||
this._userScrolledUp = false;
|
||||
this._scrollRaf = null;
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display:block; padding:12px; height:100%; overflow:hidden; }
|
||||
.box { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; height:100%; display:flex; flex-direction:column; min-height:0; box-sizing:border-box; }
|
||||
.row { display:flex; gap:8px; align-items:center; }
|
||||
.muted { color:#8aa0b5; font-size:12px; }
|
||||
.title { font-weight:800; }
|
||||
.toolbar { display:flex; gap:8px; margin-top:8px; align-items:center; }
|
||||
button { cursor:pointer; background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px; font-size:13px; }
|
||||
.list { display:flex; flex-direction:column; gap:0; overflow-y:auto; padding-right:6px; margin-top:8px; flex:1; min-height:0; }
|
||||
.item { border:1px solid #253245; border-radius:12px; padding:8px 10px; background:#0f1520; font-size:12px; margin-bottom:12px; box-sizing:border-box; }
|
||||
.item.in { background:#0f1520; border-color:#2a3a55; }
|
||||
.item.out { background:#111b2a; border-color:#2a3a55; }
|
||||
.item.active { outline:2px solid #1f6feb; box-shadow: 0 0 0 2px rgba(31,111,235,.25); }
|
||||
.item-row { display:flex; gap:8px; }
|
||||
:host { display:block; padding: var(--space-4); height:100%; overflow:hidden; font-family: var(--font-sans); }
|
||||
.box {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
padding: var(--space-5);
|
||||
height:100%; display:flex; flex-direction:column; min-height:0; box-sizing:border-box;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.row { display:flex; gap: var(--space-2); align-items:center; }
|
||||
.muted { color: var(--text-muted); font-size: var(--fs-sm); }
|
||||
.title { font-weight: var(--fw-semibold); font-size: var(--fs-md); color: var(--text); }
|
||||
.toolbar { display:flex; gap: var(--space-2); margin-top: var(--space-3); align-items:center; }
|
||||
button {
|
||||
cursor:pointer;
|
||||
background: var(--panel); color: var(--text);
|
||||
border: 1px solid var(--border-hi);
|
||||
border-radius: var(--r-md);
|
||||
padding: 6px 12px;
|
||||
font: var(--fw-medium) var(--fs-sm)/1 var(--font-sans);
|
||||
transition: all .15s;
|
||||
}
|
||||
button:hover { border-color: var(--accent); color: var(--accent-hover); background: var(--accent-soft); }
|
||||
button:focus-visible { outline:none; box-shadow: var(--focus-ring); }
|
||||
.list { display:flex; flex-direction:column; gap:0; overflow-y:auto; padding-right: 8px; margin-top: var(--space-3); flex:1; min-height:0; }
|
||||
.item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 10px 12px;
|
||||
background: var(--panel-2);
|
||||
font-size: var(--fs-sm);
|
||||
margin-bottom: var(--space-3);
|
||||
box-sizing:border-box;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.item.in { background: var(--panel-2); border-color: var(--border); }
|
||||
.item.out { background: var(--bot-bubble); border-color: var(--bot-border); }
|
||||
.item.active { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.item-row { display:flex; gap: var(--space-2); }
|
||||
.item-left { flex:1; min-width:0; }
|
||||
.item-right { display:flex; flex-direction:column; gap:4px; align-items:flex-end; justify-content:flex-start; min-width:60px; }
|
||||
.kv { display:grid; grid-template-columns:55px 1fr; gap:4px 6px; }
|
||||
.k { color:#8aa0b5; font-size:10px; letter-spacing:.3px; text-transform:uppercase; }
|
||||
.v { font-size:11px; color:#e7eef7; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.chips { display:flex; flex-direction:column; gap:3px; align-items:flex-end; }
|
||||
.chip { display:inline-flex; align-items:center; gap:3px; padding:2px 5px; border-radius:999px; background:#1d2a3a; border:1px solid #243247; font-size:9px; color:#8aa0b5; white-space:nowrap; }
|
||||
.item-right { display:flex; flex-direction:column; gap: 4px; align-items:flex-end; justify-content:flex-start; min-width:60px; }
|
||||
.kv { display:grid; grid-template-columns: 60px 1fr; gap: 4px 8px; }
|
||||
.k { color: var(--text-muted); font-size: 10px; letter-spacing: 0.06em; text-transform:uppercase; font-weight: var(--fw-semibold); }
|
||||
.v { font-size: var(--fs-xs); color: var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.chips { display:flex; flex-direction:column; gap: 4px; align-items:flex-end; }
|
||||
.chip {
|
||||
display:inline-flex; align-items:center; gap: 4px;
|
||||
padding: 3px 8px; border-radius:999px;
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
font-size: 10px; color: var(--text-muted); white-space:nowrap;
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
.chip .dot { flex-shrink:0; }
|
||||
.cart { margin-top:4px; font-size:10px; color:#c7d8ee; line-height:1.3; }
|
||||
.tool { margin-top:6px; font-size:11px; color:#8aa0b5; }
|
||||
.cart { margin-top: 6px; font-size: 11px; color: var(--bot-name); line-height: var(--lh-base); }
|
||||
.tool { margin-top: 8px; font-size: var(--fs-xs); color: var(--text-muted); }
|
||||
.dot { width:8px; height:8px; border-radius:50%; }
|
||||
.ok { background:#2ecc71; } .warn { background:#f1c40f; } .err { background:#e74c3c; }
|
||||
.ok { background: var(--ok); } .warn { background: var(--warn); } .err { background: var(--err); }
|
||||
</style>
|
||||
|
||||
<div class="box">
|
||||
@@ -83,8 +117,11 @@ class ConversationInspector extends HTMLElement {
|
||||
this.applyHeights();
|
||||
});
|
||||
|
||||
this._unsubScroll = on("ui:chatScroll", ({ chat_id, scrollTop }) => {
|
||||
this._unsubScroll = on("ui:chatScroll", ({ chat_id, scrollTop, userScrolledUp }) => {
|
||||
if (!this.chatId || chat_id !== this.chatId) return;
|
||||
// Si el otro panel está scrolleado-up, sincronizamos. Si no, dejamos
|
||||
// a este panel manejar su propio scroll para evitar saltos cruzados.
|
||||
if (!userScrolledUp) return;
|
||||
const list = this.shadowRoot.getElementById("list");
|
||||
list.scrollTop = scrollTop || 0;
|
||||
});
|
||||
@@ -206,13 +243,10 @@ class ConversationInspector extends HTMLElement {
|
||||
parts.push(`[${activePending.length} pendiente(s)]`);
|
||||
}
|
||||
|
||||
// Checkout info
|
||||
// Checkout info (sólo shipping — el bot no maneja pagos)
|
||||
const checkoutInfo = [];
|
||||
if (order?.is_delivery === true) checkoutInfo.push("🚚");
|
||||
if (order?.is_delivery === false) checkoutInfo.push("🏪");
|
||||
if (order?.payment_type === "cash") checkoutInfo.push("💵");
|
||||
if (order?.payment_type === "link") checkoutInfo.push("💳");
|
||||
if (order?.is_paid) checkoutInfo.push("✅");
|
||||
if (checkoutInfo.length) parts.push(checkoutInfo.join(""));
|
||||
|
||||
return parts.length ? parts.join(" ") : "—";
|
||||
@@ -232,7 +266,6 @@ class ConversationInspector extends HTMLElement {
|
||||
"ensure_woo_customer": "woo customer",
|
||||
"create_order": "create order",
|
||||
"update_order": "update order",
|
||||
"send_payment_link": "payment link",
|
||||
"request_human_takeover": "human takeover",
|
||||
"add_to_cart": "add to cart",
|
||||
"human_response_sent": "human response",
|
||||
@@ -340,8 +373,24 @@ class ConversationInspector extends HTMLElement {
|
||||
this.rowOrder.push(msgId);
|
||||
}
|
||||
|
||||
// Auto-scroll al final
|
||||
list.scrollTop = list.scrollHeight;
|
||||
// Auto-scroll al final, salvo que el usuario esté leyendo arriba.
|
||||
if (!this._userScrolledUp) {
|
||||
list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
this._bindScroll(list);
|
||||
}
|
||||
|
||||
_bindScroll(list) {
|
||||
if (this._scrollBound) return;
|
||||
this._scrollBound = true;
|
||||
list.addEventListener("scroll", () => {
|
||||
if (this._scrollRaf) return;
|
||||
this._scrollRaf = requestAnimationFrame(() => {
|
||||
this._scrollRaf = null;
|
||||
const distFromBottom = list.scrollHeight - list.scrollTop - list.clientHeight;
|
||||
this._userScrolledUp = distFromBottom > 150;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
applyHeights() {
|
||||
@@ -442,7 +491,9 @@ class ConversationInspector extends HTMLElement {
|
||||
`;
|
||||
|
||||
list.appendChild(el);
|
||||
// Optimistic: el usuario acaba de mandar — forzamos al final.
|
||||
list.scrollTop = list.scrollHeight;
|
||||
this._userScrolledUp = false;
|
||||
|
||||
this.rowMap.set(msg.message_id, el);
|
||||
this.rowOrder.push(msg.message_id);
|
||||
|
||||
@@ -14,27 +14,68 @@ class ConversationList extends HTMLElement {
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display:block; padding:12px; }
|
||||
.box { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; margin-bottom:10px; }
|
||||
.row { display:flex; gap:8px; align-items:center; }
|
||||
input,select,button { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px; font-size:13px; }
|
||||
button { cursor:pointer; }
|
||||
button.ghost { background:transparent; }
|
||||
button:disabled { opacity:.6; cursor:not-allowed; }
|
||||
.tabs { display:flex; gap:8px; margin-bottom:10px; }
|
||||
.tab { flex:1; text-align:center; padding:8px; border-radius:8px; border:1px solid #253245; cursor:pointer; color:#8aa0b5; background:#121823; }
|
||||
.tab.active { border-color:#1f6feb; color:#e7eef7; background:#0f1520; }
|
||||
.list { display:flex; flex-direction:column; gap:8px; }
|
||||
.item { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; cursor:pointer; }
|
||||
.item:hover { border-color:#2b3b52; }
|
||||
.item.active { border-color:#1f6feb; }
|
||||
.title { font-weight:800; }
|
||||
.muted { color:#8aa0b5; font-size:12px; }
|
||||
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
|
||||
.chip { display:inline-flex; align-items:center; gap:6px; padding:3px 8px; border-radius:999px; background:#1d2a3a; border:1px solid #243247; font-size:12px; color:#8aa0b5; }
|
||||
:host { display:block; padding: var(--space-4); font-family: var(--font-sans); }
|
||||
.box {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
padding: var(--space-4);
|
||||
margin-bottom: var(--space-3);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.row { display:flex; gap: var(--space-2); align-items:center; }
|
||||
input, select, button {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-hi);
|
||||
border-radius: var(--r-md);
|
||||
padding: 8px 12px;
|
||||
font: 400 var(--fs-sm)/1.4 var(--font-sans);
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
input:focus, select:focus { outline:none; border-color: var(--accent); box-shadow: var(--focus-ring); }
|
||||
button { cursor:pointer; font-weight: var(--fw-medium); }
|
||||
button:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-hover); }
|
||||
button:focus-visible { outline:none; box-shadow: var(--focus-ring); }
|
||||
button.ghost { background:transparent; border-color: transparent; }
|
||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||
.tabs { display:flex; gap: var(--space-2); margin-bottom: var(--space-3); }
|
||||
.tab {
|
||||
flex:1; text-align:center;
|
||||
padding: 8px 12px; border-radius: var(--r-md);
|
||||
border: 1px solid var(--border);
|
||||
cursor:pointer;
|
||||
color: var(--text-muted);
|
||||
background: var(--panel);
|
||||
font: var(--fw-medium) var(--fs-sm)/1 var(--font-sans);
|
||||
transition: all .15s;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
|
||||
.list { display:flex; flex-direction:column; gap: var(--space-2); }
|
||||
.item {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
cursor:pointer;
|
||||
transition: all .15s;
|
||||
}
|
||||
.item:hover { border-color: var(--border-hi); box-shadow: var(--shadow-sm); }
|
||||
.item.active { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.title { font-weight: var(--fw-semibold); font-size: var(--fs-base); color: var(--text); }
|
||||
.muted { color: var(--text-muted); font-size: var(--fs-sm); }
|
||||
.chips { display:flex; flex-wrap:wrap; gap: 6px; margin-top: 8px; }
|
||||
.chip {
|
||||
display:inline-flex; align-items:center; gap: 4px;
|
||||
padding: 3px 8px; border-radius:999px;
|
||||
background: var(--panel-2); border: 1px solid var(--border);
|
||||
font-size: var(--fs-xs); color: var(--text-muted);
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
.dot { width:8px; height:8px; border-radius:50%; }
|
||||
.ok{ background:#2ecc71 } .warn{ background:#f1c40f } .err{ background:#e74c3c }
|
||||
.actions { display:flex; gap:8px; justify-content:flex-end; margin-top:8px; flex-wrap:wrap; }
|
||||
.ok{ background: var(--ok) } .warn{ background: var(--warn) } .err{ background: var(--err) }
|
||||
.actions { display:flex; gap: var(--space-2); justify-content:flex-end; margin-top: var(--space-2); flex-wrap:wrap; }
|
||||
</style>
|
||||
|
||||
<div class="tabs">
|
||||
@@ -62,7 +103,10 @@ class ConversationList extends HTMLElement {
|
||||
</select>
|
||||
<select id="state" style="flex:1">
|
||||
<option value="">State: all</option>
|
||||
<option>IDLE</option><option>BROWSING</option><option>BUILDING_ORDER</option><option>WAITING_ADDRESS</option><option>WAITING_PAYMENT</option><option>COMPLETED</option>
|
||||
<option>IDLE</option>
|
||||
<option>CART</option>
|
||||
<option>SHIPPING</option>
|
||||
<option>AWAITING_HUMAN</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,40 +18,40 @@ class ConversationsCrud extends HTMLElement {
|
||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
|
||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||
|
||||
.toolbar { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; }
|
||||
input, select { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; }
|
||||
input:focus, select:focus { outline:none; border-color:#1f6feb; }
|
||||
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||
button:hover { background:#1a5fd0; }
|
||||
input, select { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; }
|
||||
input:focus, select:focus { outline:none; border-color:var(--accent); }
|
||||
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||
button:hover { background:var(--accent-hover); }
|
||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||
button.secondary { background:#253245; }
|
||||
button.secondary:hover { background:#2d3e52; }
|
||||
button.danger { background:#e74c3c; }
|
||||
button.danger:hover { background:#c0392b; }
|
||||
button.secondary { background:var(--border-hi); }
|
||||
button.secondary:hover { background:var(--border-hi); }
|
||||
button.danger { background:var(--err); }
|
||||
button.danger:hover { background:var(--err); }
|
||||
|
||||
.list { flex:1; overflow-y:auto; }
|
||||
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
||||
.item:hover { border-color:#1f6feb; }
|
||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
||||
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
||||
.item:hover { border-color:var(--accent); }
|
||||
.item.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||
.item-header { display:flex; align-items:center; gap:8px; margin-bottom:4px; }
|
||||
.item-name { font-weight:600; color:#e7eef7; flex:1; }
|
||||
.item-meta { font-size:12px; color:#8aa0b5; }
|
||||
.item-name { font-weight:600; color:var(--text); flex:1; }
|
||||
.item-meta { font-size:12px; color:var(--text-muted); }
|
||||
.dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
|
||||
.ok { background:#2ecc71; } .warn { background:#f1c40f; } .err { background:#e74c3c; }
|
||||
.ok { background:var(--ok); } .warn { background:var(--warn); } .err { background:var(--err); }
|
||||
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; }
|
||||
.chip { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:#253245; color:#8aa0b5; }
|
||||
.chip { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:var(--border-hi); color:var(--text-muted); }
|
||||
|
||||
.detail { flex:1; overflow-y:auto; }
|
||||
.detail-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
||||
.detail-empty { color:var(--text-muted); text-align:center; padding:40px; }
|
||||
.field { margin-bottom:16px; }
|
||||
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.field-value { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:10px 12px; color:#e7eef7; font-size:13px; }
|
||||
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.field-value { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:10px 12px; color:var(--text); font-size:13px; }
|
||||
|
||||
.actions { display:flex; gap:8px; margin-top:16px; flex-wrap:wrap; }
|
||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
||||
.loading { text-align:center; padding:40px; color:var(--text-muted); }
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
@@ -68,11 +68,9 @@ class ConversationsCrud extends HTMLElement {
|
||||
<select id="state">
|
||||
<option value="">State: todos</option>
|
||||
<option>IDLE</option>
|
||||
<option>BROWSING</option>
|
||||
<option>BUILDING_ORDER</option>
|
||||
<option>WAITING_ADDRESS</option>
|
||||
<option>WAITING_PAYMENT</option>
|
||||
<option>COMPLETED</option>
|
||||
<option>CART</option>
|
||||
<option>SHIPPING</option>
|
||||
<option>AWAITING_HUMAN</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="list" id="list">
|
||||
|
||||
@@ -9,12 +9,12 @@ class DebugPanel extends HTMLElement {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display:block; padding:12px; }
|
||||
.box { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; }
|
||||
.muted { color:#8aa0b5; font-size:12px; }
|
||||
.box { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:10px; }
|
||||
.muted { color:var(--text-muted); font-size:12px; }
|
||||
.kv { display:grid; grid-template-columns:90px 1fr; gap:6px 10px; margin:10px 0 12px; }
|
||||
.k { color:#8aa0b5; font-size:12px; letter-spacing:.4px; text-transform:uppercase; }
|
||||
.v { font-size:13px; font-weight:800; color:#e7eef7; }
|
||||
pre { white-space:pre-wrap; word-break:break-word; background:#0f1520; border:1px solid #253245; border-radius:10px; padding:10px; margin:0; font-size:12px; color:#d7e2ef; }
|
||||
.k { color:var(--text-muted); font-size:12px; letter-spacing:.4px; text-transform:uppercase; }
|
||||
.v { font-size:13px; font-weight:800; color:var(--text); }
|
||||
pre { white-space:pre-wrap; word-break:break-word; background:var(--panel-2); border:1px solid var(--border-hi); border-radius:10px; padding:10px; margin:0; font-size:12px; color:var(--text-dim); }
|
||||
</style>
|
||||
|
||||
<div class="box">
|
||||
|
||||
589
public/components/home-dashboard.js
Normal file
589
public/components/home-dashboard.js
Normal file
@@ -0,0 +1,589 @@
|
||||
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);
|
||||
}
|
||||
|
||||
/** Lee una CSS custom property del :root. Fallback al hex provisto. */
|
||||
function cssVar(name, fallback = "") {
|
||||
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return v || fallback;
|
||||
}
|
||||
|
||||
/** rgba con alpha desde un hex (#RRGGBB) o sky/etc — para fills de charts. */
|
||||
function withAlpha(hex, alpha) {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(hex || "");
|
||||
if (!m) return hex;
|
||||
const n = parseInt(m[1], 16);
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
|
||||
}
|
||||
|
||||
class HomeDashboard extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.stats = null;
|
||||
this.loading = false;
|
||||
this.charts = {};
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { font-family: var(--font-sans); }
|
||||
* { box-sizing: border-box; }
|
||||
.container {
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
padding: var(--space-6);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.header {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
.header h1 {
|
||||
font-size: var(--fs-xl);
|
||||
font-weight: var(--fw-semibold);
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
.sync-info { font-size: var(--fs-sm); color: var(--text-muted); }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: var(--space-6);
|
||||
}
|
||||
.chart-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
padding: var(--space-5);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.chart-card.full-width { grid-column: 1 / -1; }
|
||||
.chart-title {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.chart-container { position: relative; height: 260px; }
|
||||
.chart-container.tall { height: 320px; }
|
||||
.chart-container.short { height: 200px; }
|
||||
.loading {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 200px; color: var(--text-muted);
|
||||
}
|
||||
.kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
.kpi-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
padding: var(--space-5);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.kpi-value {
|
||||
font-size: var(--fs-xl);
|
||||
font-weight: var(--fw-bold);
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.kpi-label {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
.donut-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
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>
|
||||
<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(--chart-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(--chart-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(--chart-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: withAlpha(cssVar("--chart-blue"), 0.7),
|
||||
borderColor: cssVar("--chart-blue"),
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: { color: cssVar("--text-muted") },
|
||||
grid: { color: cssVar("--border") },
|
||||
},
|
||||
x: {
|
||||
ticks: { color: cssVar("--text-muted") },
|
||||
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: cssVar("--chart-green"),
|
||||
},
|
||||
{
|
||||
label: "Web",
|
||||
data: webData,
|
||||
backgroundColor: cssVar("--chart-blue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "top",
|
||||
labels: { color: cssVar("--text-muted") },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
ticks: { color: cssVar("--text-muted") },
|
||||
grid: { color: cssVar("--border") },
|
||||
},
|
||||
x: {
|
||||
stacked: true,
|
||||
ticks: { color: cssVar("--text-muted") },
|
||||
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: cssVar("--chart-blue"),
|
||||
backgroundColor: withAlpha(cssVar("--chart-blue"), 0.15),
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
},
|
||||
{
|
||||
label: String(yoy.last_year || "Anterior"),
|
||||
data: yoy.last_year_data || [],
|
||||
borderColor: cssVar("--chart-gray"),
|
||||
backgroundColor: withAlpha(cssVar("--chart-gray"), 0.15),
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "top",
|
||||
labels: { color: cssVar("--text-muted") },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: { color: cssVar("--text-muted") },
|
||||
grid: { color: cssVar("--border") },
|
||||
},
|
||||
x: {
|
||||
ticks: { color: cssVar("--text-muted") },
|
||||
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: [cssVar("--chart-green"), cssVar("--chart-blue")],
|
||||
}],
|
||||
},
|
||||
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: [cssVar("--chart-purple"), cssVar("--chart-orange")],
|
||||
}],
|
||||
},
|
||||
options: this.getDonutOptions(),
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
getDonutOptions() {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "bottom",
|
||||
labels: { color: cssVar("--text-muted") },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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: withAlpha(cssVar("--chart-blue"), 0.7),
|
||||
borderColor: cssVar("--chart-blue"),
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
indexAxis: "y",
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
ticks: { color: cssVar("--text-muted") },
|
||||
grid: { color: cssVar("--border") },
|
||||
},
|
||||
y: {
|
||||
ticks: { color: cssVar("--text-muted") },
|
||||
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: withAlpha(cssVar("--chart-purple"), 0.7),
|
||||
borderColor: cssVar("--chart-purple"),
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
indexAxis: "y",
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
ticks: { color: cssVar("--text-muted") },
|
||||
grid: { color: cssVar("--border") },
|
||||
},
|
||||
y: {
|
||||
ticks: { color: cssVar("--text-muted"), 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: withAlpha(cssVar("--chart-orange"), 0.7),
|
||||
borderColor: cssVar("--chart-orange"),
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
indexAxis: "y",
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
ticks: { color: cssVar("--text-muted") },
|
||||
grid: { color: cssVar("--border") },
|
||||
},
|
||||
y: {
|
||||
ticks: { color: cssVar("--text-muted"), font: { size: 10 } },
|
||||
grid: { display: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("home-dashboard", HomeDashboard);
|
||||
@@ -12,49 +12,84 @@ class OpsShell extends HTMLElement {
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { --bg:#0b0f14; --panel:#121823; --muted:#8aa0b5; --text:#e7eef7; --line:#1e2a3a; --blue:#1f6feb; }
|
||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||
* { box-sizing:border-box; }
|
||||
:host { font-family: var(--font-sans); }
|
||||
.app { height:100vh; background:var(--bg); color:var(--text); display:flex; flex-direction:column; }
|
||||
header { display:flex; gap:12px; align-items:center; padding:12px 16px; border-bottom:1px solid var(--line); flex-wrap:wrap; }
|
||||
header h1 { font-size:14px; margin:0; color:var(--muted); font-weight:700; letter-spacing:.4px; text-transform:uppercase; }
|
||||
.nav { display:flex; gap:4px; margin-left:24px; flex-wrap:wrap; }
|
||||
.nav-btn { background:transparent; border:1px solid var(--line); color:var(--muted); padding:6px 12px; border-radius:6px; font-size:12px; cursor:pointer; transition:all .15s; text-decoration:none; }
|
||||
.nav-btn:hover { border-color:var(--blue); color:var(--text); }
|
||||
.nav-btn.active { background:var(--blue); border-color:var(--blue); color:#fff; }
|
||||
.spacer { flex:1; }
|
||||
.status { font-size:12px; color:var(--muted); }
|
||||
|
||||
/* Notification bell */
|
||||
.notification-bell { position:relative; cursor:pointer; padding:8px; margin-right:12px; }
|
||||
.notification-bell svg { width:20px; height:20px; fill:var(--muted); transition:fill .15s; }
|
||||
.notification-bell:hover svg { fill:var(--text); }
|
||||
.notification-bell.has-pending svg { fill:#f39c12; }
|
||||
.notification-bell .badge {
|
||||
position:absolute; top:2px; right:2px;
|
||||
background:#e74c3c; color:#fff;
|
||||
font-size:10px; padding:2px 6px; border-radius:10px;
|
||||
font-weight:700; min-width:18px; text-align:center;
|
||||
header {
|
||||
display:flex; gap:var(--space-3); align-items:center;
|
||||
padding: var(--space-3) var(--space-6);
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap:wrap;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: var(--fs-md);
|
||||
margin:0; color: var(--text);
|
||||
font-weight: var(--fw-semibold);
|
||||
letter-spacing:-0.01em;
|
||||
}
|
||||
.nav { display:flex; gap: var(--space-1); margin-left: var(--space-6); flex-wrap:wrap; }
|
||||
.nav-btn {
|
||||
position:relative;
|
||||
background:transparent; border:none;
|
||||
color: var(--text-muted);
|
||||
padding: 8px 12px;
|
||||
font: var(--fw-medium) var(--fs-sm)/1 var(--font-sans);
|
||||
cursor:pointer; transition:color .15s;
|
||||
text-decoration:none;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.nav-btn:hover { color: var(--text); background: var(--panel-2); }
|
||||
.nav-btn.active { color: var(--accent); background: var(--accent-soft); }
|
||||
.nav-btn:focus-visible { outline:none; box-shadow: var(--focus-ring); }
|
||||
.spacer { flex:1; }
|
||||
.status {
|
||||
font-size: var(--fs-sm); color: var(--ok);
|
||||
display:flex; align-items:center; gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--ok-soft);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.status .dot { width:7px; height:7px; border-radius:50%; background: var(--ok); }
|
||||
.status.disconnected { color: var(--warn); background: var(--warn-soft); }
|
||||
.status.disconnected .dot { background: var(--warn); animation: pulse 1.2s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
|
||||
|
||||
/* Notification bell */
|
||||
.notification-bell { position:relative; cursor:pointer; padding: 8px; border-radius: var(--r-sm); transition: background .15s; }
|
||||
.notification-bell:hover { background: var(--panel-2); }
|
||||
.notification-bell svg { width:18px; height:18px; fill: var(--text-muted); transition:fill .15s; display:block; }
|
||||
.notification-bell:hover svg { fill: var(--text); }
|
||||
.notification-bell.has-pending svg { fill: var(--warn); }
|
||||
.notification-bell .badge {
|
||||
position:absolute; top:2px; right:2px;
|
||||
background: var(--err); color:#fff;
|
||||
font: var(--fw-bold) 10px/1 var(--font-sans);
|
||||
padding: 3px 6px; border-radius:10px;
|
||||
min-width:18px; text-align:center;
|
||||
box-shadow: 0 0 0 2px var(--panel);
|
||||
}
|
||||
|
||||
/* Layout para chat activo (2 columnas: burbujas + inspector) */
|
||||
.layout-chat { height:100%; display:grid; grid-template-columns:1fr 1fr; grid-template-rows:1fr 310px; min-height:0; overflow:hidden; }
|
||||
.col { border-right:1px solid var(--line); min-height:0; overflow:hidden; }
|
||||
.chatTop { grid-column:1; grid-row:1; border-bottom:1px solid var(--line); }
|
||||
.chatBottom { grid-column:1 / 3; grid-row:2; overflow:hidden; border-top:1px solid var(--line); }
|
||||
.col { border-right:1px solid var(--border); min-height:0; overflow:hidden; }
|
||||
.chatTop { grid-column:1; grid-row:1; border-bottom:1px solid var(--border); }
|
||||
.chatBottom { grid-column:1 / 3; grid-row:2; overflow:hidden; border-top:1px solid var(--border); }
|
||||
.inspectorTop { grid-column:2; grid-row:1; border-right:none; }
|
||||
|
||||
|
||||
/* Layout para CRUDs */
|
||||
.layout-crud { height:100%; display:block; min-height:0; overflow:hidden; }
|
||||
|
||||
|
||||
.view { display:none; flex:1; min-height:0; overflow:hidden; }
|
||||
.view.active { display:flex; flex-direction:column; }
|
||||
</style>
|
||||
|
||||
<div class="app">
|
||||
<header>
|
||||
<h1>Bot Ops Console</h1>
|
||||
<h1>Piaf Console</h1>
|
||||
<nav class="nav">
|
||||
<a class="nav-btn active" href="/chat" data-view="chat">Chat</a>
|
||||
<a class="nav-btn active" href="/home" data-view="home">Home</a>
|
||||
<a class="nav-btn" href="/chat" data-view="chat">Chat</a>
|
||||
<a class="nav-btn" href="/conversaciones" data-view="conversations">Conversaciones</a>
|
||||
<a class="nav-btn" href="/usuarios" data-view="users">Usuarios</a>
|
||||
<a class="nav-btn" href="/productos" data-view="products">Productos</a>
|
||||
@@ -62,19 +97,23 @@ class OpsShell extends HTMLElement {
|
||||
<a class="nav-btn" href="/crosssell" data-view="crosssell">Cross-sell</a>
|
||||
<a class="nav-btn" href="/cantidades" data-view="quantities">Cantidades</a>
|
||||
<a class="nav-btn" href="/pedidos" data-view="orders">Pedidos</a>
|
||||
<a class="nav-btn" href="/config-prompts" data-view="prompts">Prompts</a>
|
||||
<a class="nav-btn" href="/configuracion" data-view="settings">Config</a>
|
||||
<a class="nav-btn" href="/test" data-view="test">Test</a>
|
||||
</nav>
|
||||
<div class="spacer"></div>
|
||||
<div class="notification-bell" id="notificationBell" title="Takeovers pendientes">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.48 1.51-4.5 4-4.5s4 2.02 4 4.5v6z"/></svg>
|
||||
<span class="badge" id="takeoverBadge" style="display:none;">0</span>
|
||||
</div>
|
||||
<div class="status" id="sseStatus">SSE: connecting…</div>
|
||||
<div class="status disconnected" id="sseStatus"><span class="dot"></span><span class="label">Conectando…</span></div>
|
||||
</header>
|
||||
|
||||
<div id="viewChat" class="view active">
|
||||
<div id="viewHome" class="view active">
|
||||
<div class="layout-crud">
|
||||
<home-dashboard></home-dashboard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="viewChat" class="view">
|
||||
<div class="layout-chat">
|
||||
<div class="col chatTop"><run-timeline></run-timeline></div>
|
||||
<div class="col inspectorTop"><conversation-inspector></conversation-inspector></div>
|
||||
@@ -124,18 +163,6 @@ class OpsShell extends HTMLElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="viewTest" class="view">
|
||||
<div class="layout-crud">
|
||||
<test-panel></test-panel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="viewPrompts" class="view">
|
||||
<div class="layout-crud">
|
||||
<prompts-crud></prompts-crud>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="viewTakeovers" class="view">
|
||||
<div class="layout-crud">
|
||||
<takeovers-crud></takeovers-crud>
|
||||
@@ -154,7 +181,10 @@ class OpsShell extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this._unsub = on("sse:status", (s) => {
|
||||
const el = this.shadowRoot.getElementById("sseStatus");
|
||||
el.textContent = s.ok ? "SSE: connected" : "SSE: disconnected (retrying…)";
|
||||
if (!el) return;
|
||||
el.classList.toggle("disconnected", !s.ok);
|
||||
const label = el.querySelector(".label");
|
||||
if (label) label.textContent = s.ok ? "En vivo" : "Reconectando…";
|
||||
});
|
||||
|
||||
// Listen for view switch requests from other components
|
||||
|
||||
@@ -23,15 +23,15 @@ function statusLabel(status) {
|
||||
|
||||
function statusColor(status) {
|
||||
const map = {
|
||||
pending: "#f59e0b",
|
||||
processing: "#3b82f6",
|
||||
"on-hold": "#8b5cf6",
|
||||
completed: "#22c55e",
|
||||
cancelled: "#6b7280",
|
||||
refunded: "#ec4899",
|
||||
failed: "#ef4444",
|
||||
pending: "var(--warn)",
|
||||
processing: "var(--chart-blue)",
|
||||
"on-hold": "var(--chart-purple)",
|
||||
completed: "var(--ok)",
|
||||
cancelled: "var(--text-muted)",
|
||||
refunded: "var(--chart-pink)",
|
||||
failed: "var(--err)",
|
||||
};
|
||||
return map[status] || "#8aa0b5";
|
||||
return map[status] || "var(--text-muted)";
|
||||
}
|
||||
|
||||
class OrdersCrud extends HTMLElement {
|
||||
@@ -41,19 +41,25 @@ class OrdersCrud extends HTMLElement {
|
||||
this.orders = [];
|
||||
this.selectedOrder = null;
|
||||
this.loading = false;
|
||||
|
||||
// Paginación
|
||||
this.page = 1;
|
||||
this.limit = 50;
|
||||
this.totalPages = 1;
|
||||
this.totalOrders = 0;
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
--bg: #0b0f14;
|
||||
--panel: #121823;
|
||||
--muted: #8aa0b5;
|
||||
--text: #e7eef7;
|
||||
--line: #1e2a3a;
|
||||
--blue: #1f6feb;
|
||||
--green: #238636;
|
||||
--red: #da3633;
|
||||
--orange: #f59e0b;
|
||||
--bg: var(--bg);
|
||||
--panel: var(--panel);
|
||||
--muted: var(--text-muted);
|
||||
--text: var(--text);
|
||||
--line: var(--border);
|
||||
--blue: var(--accent);
|
||||
--green: var(--ok);
|
||||
--red: var(--err);
|
||||
--orange: var(--warn);
|
||||
}
|
||||
* { box-sizing: border-box; font-family: system-ui, Segoe UI, Roboto, Arial; }
|
||||
.container {
|
||||
@@ -148,10 +154,10 @@ class OrdersCrud extends HTMLElement {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.badge.test { background: var(--orange); color: #000; }
|
||||
.badge.test { background: var(--orange); color: var(--text); }
|
||||
.badge.real { background: var(--green); color: #fff; }
|
||||
.badge.whatsapp { background: #25d366; color: #fff; }
|
||||
.badge.web { background: var(--muted); color: #000; }
|
||||
.badge.whatsapp { background: var(--ok); color: #fff; }
|
||||
.badge.web { background: var(--muted); color: var(--text); }
|
||||
.status-badge {
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
@@ -214,17 +220,68 @@ class OrdersCrud extends HTMLElement {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
/* Paginación */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
margin-top: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pagination-info {
|
||||
color: var(--muted);
|
||||
}
|
||||
.pagination select {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination select:hover {
|
||||
border-color: var(--blue);
|
||||
}
|
||||
.pagination button {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<div class="panel-title">
|
||||
<span>Pedidos de WooCommerce</span>
|
||||
<span>Pedidos</span>
|
||||
<button id="btnRefresh" class="secondary small">Actualizar</button>
|
||||
</div>
|
||||
<div class="orders-table" id="ordersTable">
|
||||
<div class="empty">Cargando pedidos...</div>
|
||||
</div>
|
||||
<div class="pagination" id="pagination">
|
||||
<div class="pagination-controls">
|
||||
<span>Mostrar:</span>
|
||||
<select id="limitSelect">
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="pagination-controls">
|
||||
<button id="btnPrev" class="secondary small" disabled>← Anterior</button>
|
||||
<span class="pagination-info" id="pageInfo">Página 1 de 1</span>
|
||||
<button id="btnNext" class="secondary small" disabled>Siguiente →</button>
|
||||
</div>
|
||||
<div class="pagination-info" id="totalInfo">0 pedidos</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
@@ -240,6 +297,25 @@ class OrdersCrud extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.shadowRoot.getElementById("btnRefresh").onclick = () => this.loadOrders();
|
||||
|
||||
// Paginación
|
||||
this.shadowRoot.getElementById("limitSelect").onchange = (e) => {
|
||||
this.limit = parseInt(e.target.value);
|
||||
this.page = 1;
|
||||
this.loadOrders();
|
||||
};
|
||||
this.shadowRoot.getElementById("btnPrev").onclick = () => {
|
||||
if (this.page > 1) {
|
||||
this.page--;
|
||||
this.loadOrders();
|
||||
}
|
||||
};
|
||||
this.shadowRoot.getElementById("btnNext").onclick = () => {
|
||||
if (this.page < this.totalPages) {
|
||||
this.page++;
|
||||
this.loadOrders();
|
||||
}
|
||||
};
|
||||
|
||||
// Escuchar cambios de ruta para deep-linking
|
||||
this._unsubRouter = on("router:viewChanged", ({ view, params }) => {
|
||||
if (view === "orders" && params.id) {
|
||||
@@ -248,7 +324,6 @@ class OrdersCrud extends HTMLElement {
|
||||
});
|
||||
|
||||
// Escuchar nuevos pedidos para actualizar automáticamente
|
||||
// Usa retry con backoff porque WooCommerce puede tardar en devolver el pedido recién creado
|
||||
this._unsubOrderCreated = on("order:created", ({ order_id }) => {
|
||||
console.log("[orders-crud] order:created received, order_id:", order_id);
|
||||
this.refreshWithRetry(order_id);
|
||||
@@ -298,9 +373,17 @@ class OrdersCrud extends HTMLElement {
|
||||
container.innerHTML = `<div class="empty">Cargando pedidos...</div>`;
|
||||
|
||||
try {
|
||||
const result = await api.listRecentOrders({ limit: 50 });
|
||||
const result = await api.listOrders({ page: this.page, limit: this.limit });
|
||||
this.orders = result.items || [];
|
||||
|
||||
// Actualizar paginación
|
||||
if (result.pagination) {
|
||||
this.totalPages = result.pagination.pages || 1;
|
||||
this.totalOrders = result.pagination.total || 0;
|
||||
}
|
||||
|
||||
this.renderTable();
|
||||
this.updatePagination();
|
||||
|
||||
// Si hay un pedido pendiente de selección (deep-link), seleccionarlo
|
||||
if (this._pendingOrderId) {
|
||||
@@ -316,6 +399,22 @@ class OrdersCrud extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
updatePagination() {
|
||||
const pageInfo = this.shadowRoot.getElementById("pageInfo");
|
||||
const totalInfo = this.shadowRoot.getElementById("totalInfo");
|
||||
const btnPrev = this.shadowRoot.getElementById("btnPrev");
|
||||
const btnNext = this.shadowRoot.getElementById("btnNext");
|
||||
const limitSelect = this.shadowRoot.getElementById("limitSelect");
|
||||
|
||||
pageInfo.textContent = `Página ${this.page} de ${this.totalPages}`;
|
||||
totalInfo.textContent = `${this.totalOrders.toLocaleString("es-AR")} pedidos`;
|
||||
|
||||
btnPrev.disabled = this.page <= 1;
|
||||
btnNext.disabled = this.page >= this.totalPages;
|
||||
|
||||
limitSelect.value = String(this.limit);
|
||||
}
|
||||
|
||||
renderTable() {
|
||||
const container = this.shadowRoot.getElementById("ordersTable");
|
||||
|
||||
@@ -332,7 +431,6 @@ class OrdersCrud extends HTMLElement {
|
||||
<th>Tipo</th>
|
||||
<th>Estado</th>
|
||||
<th>Envío</th>
|
||||
<th>Pago</th>
|
||||
<th>Cliente</th>
|
||||
<th>Total</th>
|
||||
<th>Fecha</th>
|
||||
@@ -353,13 +451,7 @@ class OrdersCrud extends HTMLElement {
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="status-badge" style="background:${statusColor(order.status)}">${statusLabel(order.status)}</span></td>
|
||||
<td><span class="badge" style="background:${order.is_delivery ? '#3b82f6' : '#8b5cf6'};color:#fff">${order.is_delivery ? 'DEL' : 'RET'}</span></td>
|
||||
<td>
|
||||
<div class="badges">
|
||||
<span class="badge" style="background:${order.is_cash ? '#f59e0b' : '#1f6feb'};color:${order.is_cash ? '#000' : '#fff'}">${order.is_cash ? '$' : 'MP'}</span>
|
||||
<span class="badge" style="background:${order.is_paid ? '#22c55e' : '#ef4444'};color:#fff">${order.is_paid ? '✓' : '✗'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="badge" style="background:${order.is_delivery ? 'var(--chart-blue)' : 'var(--chart-purple)'};color:#fff">${order.is_delivery ? 'DEL' : 'RET'}</span></td>
|
||||
<td class="customer-name" title="${customerName}">${customerName}</td>
|
||||
<td class="total">$${Number(order.total || 0).toLocaleString("es-AR")}</td>
|
||||
<td>${formatDate(order.date_created)}</td>
|
||||
@@ -373,7 +465,7 @@ class OrdersCrud extends HTMLElement {
|
||||
container.querySelectorAll("tr[data-order-id]").forEach(row => {
|
||||
row.onclick = () => {
|
||||
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) {
|
||||
this.selectOrder(order);
|
||||
}
|
||||
@@ -466,7 +558,7 @@ class OrdersCrud extends HTMLElement {
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Método</span>
|
||||
<span class="detail-value">
|
||||
<span class="badge ${order.is_delivery ? 'delivery' : 'pickup'}" style="background:${order.is_delivery ? '#3b82f6' : '#8b5cf6'};color:#fff;padding:3px 8px;border-radius:4px;font-size:10px;">
|
||||
<span class="badge ${order.is_delivery ? 'delivery' : 'pickup'}" style="background:${order.is_delivery ? 'var(--chart-blue)' : 'var(--chart-purple)'};color:#fff;padding:3px 8px;border-radius:4px;font-size:10px;">
|
||||
${order.is_delivery ? 'DELIVERY' : 'RETIRO'}
|
||||
</span>
|
||||
${order.shipping_method ? `<span style="margin-left:8px;color:var(--muted);font-size:11px;">${order.shipping_method}</span>` : ''}
|
||||
@@ -480,28 +572,6 @@ class OrdersCrud extends HTMLElement {
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="detail-title">Pago</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Método</span>
|
||||
<span class="detail-value">
|
||||
<span class="badge" style="background:${order.is_cash ? '#f59e0b' : '#1f6feb'};color:${order.is_cash ? '#000' : '#fff'};padding:3px 8px;border-radius:4px;font-size:10px;">
|
||||
${order.is_cash ? 'EFECTIVO' : 'LINK'}
|
||||
</span>
|
||||
${order.payment_method_title ? `<span style="margin-left:8px;color:var(--muted);font-size:11px;">${order.payment_method_title}</span>` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Estado</span>
|
||||
<span class="detail-value">
|
||||
<span class="badge" style="background:${order.is_paid ? '#22c55e' : '#ef4444'};color:#fff;padding:3px 8px;border-radius:4px;font-size:10px;">
|
||||
${order.is_paid ? 'PAGADO' : 'PENDIENTE'}
|
||||
</span>
|
||||
${order.date_paid ? `<span style="margin-left:8px;color:var(--muted);font-size:11px;">${formatDate(order.date_paid)}</span>` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="detail-title">Cliente</div>
|
||||
<div class="detail-row">
|
||||
|
||||
@@ -19,46 +19,46 @@ class ProductsCrud extends HTMLElement {
|
||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
|
||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||
|
||||
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
|
||||
input, select { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; }
|
||||
input:focus, select:focus { outline:none; border-color:#1f6feb; }
|
||||
input, select { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; }
|
||||
input:focus, select:focus { outline:none; border-color:var(--accent); }
|
||||
input { flex:1; }
|
||||
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||
button:hover { background:#1a5fd0; }
|
||||
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||
button:hover { background:var(--accent-hover); }
|
||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||
button.secondary { background:#253245; }
|
||||
button.secondary:hover { background:#2d3e52; }
|
||||
button.secondary { background:var(--border-hi); }
|
||||
button.secondary:hover { background:var(--border-hi); }
|
||||
|
||||
.list { flex:1; overflow-y:auto; }
|
||||
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; user-select:none; }
|
||||
.item:hover { border-color:#1f6feb; }
|
||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
||||
.item.selected { border-color:#2ecc71; background:#0f2a1a; }
|
||||
.item-name { font-weight:600; color:#e7eef7; margin-bottom:4px; }
|
||||
.item-meta { font-size:12px; color:#8aa0b5; }
|
||||
.item-price { color:#2ecc71; font-weight:600; }
|
||||
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; user-select:none; }
|
||||
.item:hover { border-color:var(--accent); }
|
||||
.item.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||
.item.selected { border-color:var(--ok); background:var(--ok-soft); }
|
||||
.item-name { font-weight:600; color:var(--text); margin-bottom:4px; }
|
||||
.item-meta { font-size:12px; color:var(--text-muted); }
|
||||
.item-price { color:var(--ok); font-weight:600; }
|
||||
|
||||
.detail { flex:1; overflow-y:auto; }
|
||||
.detail-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
||||
.detail-empty { color:var(--text-muted); text-align:center; padding:40px; }
|
||||
.field { margin-bottom:16px; }
|
||||
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.field-value { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:10px 12px; color:#e7eef7; font-size:13px; }
|
||||
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.field-value { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:10px 12px; color:var(--text); font-size:13px; }
|
||||
.field-value.json { font-family:monospace; font-size:11px; white-space:pre-wrap; max-height:200px; overflow-y:auto; }
|
||||
|
||||
.stats { display:flex; gap:16px; margin-bottom:16px; }
|
||||
.stat { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; flex:1; text-align:center; cursor:pointer; transition:all .15s; }
|
||||
.stat:hover { border-color:#1f6feb; }
|
||||
.stat.active { border-color:#1f6feb; background:#111b2a; }
|
||||
.stat-value { font-size:24px; font-weight:700; color:#1f6feb; }
|
||||
.stat-label { font-size:11px; color:#8aa0b5; text-transform:uppercase; margin-top:4px; }
|
||||
.stat { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; flex:1; text-align:center; cursor:pointer; transition:all .15s; }
|
||||
.stat:hover { border-color:var(--accent); }
|
||||
.stat.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||
.stat-value { font-size:24px; font-weight:700; color:var(--accent); }
|
||||
.stat-label { font-size:11px; color:var(--text-muted); text-transform:uppercase; margin-top:4px; }
|
||||
|
||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
||||
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:#253245; color:#8aa0b5; margin-left:8px; }
|
||||
.badge.stock { background:#0f2a1a; color:#2ecc71; }
|
||||
.badge.nostock { background:#241214; color:#e74c3c; }
|
||||
.loading { text-align:center; padding:40px; color:var(--text-muted); }
|
||||
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:var(--border-hi); color:var(--text-muted); margin-left:8px; }
|
||||
.badge.stock { background:var(--ok-soft); color:var(--ok); }
|
||||
.badge.nostock { background:var(--err-soft); color:var(--err); }
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
@@ -272,7 +272,7 @@ class ProductsCrud extends HTMLElement {
|
||||
|
||||
// Mostrar unidad actual si está definida
|
||||
const unit = item.sell_unit || item.payload?._sell_unit_override;
|
||||
const unitBadge = unit ? `<span class="badge" style="background:#1a3a5c;color:#7eb8e7;">${unit === 'unit' ? 'Unidad' : 'Kg'}</span>` : '';
|
||||
const unitBadge = unit ? `<span class="badge" style="background:var(--accent-soft);color:var(--accent-hover);">${unit === 'unit' ? 'Unidad' : 'Kg'}</span>` : '';
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="item-name">${item.name || "Sin nombre"} ${stockBadge} ${unitBadge}</div>
|
||||
@@ -328,7 +328,7 @@ class ProductsCrud extends HTMLElement {
|
||||
} catch (err) {
|
||||
console.error("[products-crud] Error in renderDetail:", err);
|
||||
const detail = this.shadowRoot.getElementById("detail");
|
||||
detail.innerHTML = `<div class="detail-empty" style="color:#e74c3c;">Error: ${err.message}</div>`;
|
||||
detail.innerHTML = `<div class="detail-empty" style="color:var(--err);">Error: ${err.message}</div>`;
|
||||
}
|
||||
|
||||
// Scroll detail panel to top
|
||||
@@ -449,12 +449,12 @@ class ProductsCrud extends HTMLElement {
|
||||
<div class="field">
|
||||
<label>Unidad de venta</label>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<select id="sellUnit" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;">
|
||||
<select id="sellUnit" style="background:var(--panel-2);color:var(--text);border:1px solid var(--border-hi);border-radius:8px;padding:8px 12px;font-size:13px;">
|
||||
<option value="kg" ${currentUnit === "kg" ? "selected" : ""}>Por peso (kg)</option>
|
||||
<option value="unit" ${currentUnit === "unit" ? "selected" : ""}>Por unidad</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">
|
||||
Define si este producto se vende por peso o por unidad
|
||||
</div>
|
||||
</div>
|
||||
@@ -464,16 +464,16 @@ class ProductsCrud extends HTMLElement {
|
||||
${categoriesArray.length > 0
|
||||
? categoriesArray.map(cat => `
|
||||
<span class="category-tag" data-category="${this.escapeHtml(cat)}"
|
||||
style="display:inline-flex;align-items:center;gap:4px;background:#1a3a5c;color:#7eb8e7;padding:4px 8px;border-radius:6px;font-size:12px;">
|
||||
style="display:inline-flex;align-items:center;gap:4px;background:var(--accent-soft);color:var(--accent-hover);padding:4px 8px;border-radius:6px;font-size:12px;">
|
||||
${this.escapeHtml(cat)}
|
||||
<span class="remove-cat" style="cursor:pointer;font-weight:bold;opacity:0.7;">×</span>
|
||||
</span>
|
||||
`).join("")
|
||||
: '<span style="color:#8aa0b5;font-size:12px;">Sin categorías</span>'
|
||||
: '<span style="color:var(--text-muted);font-size:12px;">Sin categorías</span>'
|
||||
}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<select id="addCategorySelect" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
|
||||
<select id="addCategorySelect" style="background:var(--panel-2);color:var(--text);border:1px solid var(--border-hi);border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
|
||||
<option value="">-- Agregar categoría --</option>
|
||||
${this.getAllCategories().filter(c => !categoriesArray.includes(c)).map(cat =>
|
||||
`<option value="${this.escapeHtml(cat)}">${this.escapeHtml(cat)}</option>`
|
||||
@@ -489,7 +489,7 @@ class ProductsCrud extends HTMLElement {
|
||||
<div class="field">
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<button id="saveProduct" style="flex:1;padding:10px;">Guardar cambios</button>
|
||||
<span id="saveStatus" style="font-size:12px;color:#2ecc71;"></span>
|
||||
<span id="saveStatus" style="font-size:12px;color:var(--ok);"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -533,7 +533,7 @@ class ProductsCrud extends HTMLElement {
|
||||
const tag = document.createElement("span");
|
||||
tag.className = "category-tag";
|
||||
tag.dataset.category = categoryName;
|
||||
tag.style = "display:inline-flex;align-items:center;gap:4px;background:#1a3a5c;color:#7eb8e7;padding:4px 8px;border-radius:6px;font-size:12px;";
|
||||
tag.style = "display:inline-flex;align-items:center;gap:4px;background:var(--accent-soft);color:var(--accent-hover);padding:4px 8px;border-radius:6px;font-size:12px;";
|
||||
tag.innerHTML = `${this.escapeHtml(categoryName)}<span class="remove-cat" style="cursor:pointer;font-weight:bold;opacity:0.7;">×</span>`;
|
||||
|
||||
// Bind remove
|
||||
@@ -545,7 +545,7 @@ class ProductsCrud extends HTMLElement {
|
||||
};
|
||||
|
||||
// Remover el mensaje "Sin categorías" si existe
|
||||
const emptyMsg = container.querySelector('span[style*="color:#8aa0b5"]');
|
||||
const emptyMsg = container.querySelector('span[style*="color:var(--text-muted)"]');
|
||||
if (emptyMsg) emptyMsg.remove();
|
||||
|
||||
container.appendChild(tag);
|
||||
@@ -652,8 +652,8 @@ class ProductsCrud extends HTMLElement {
|
||||
detail.innerHTML = `
|
||||
<div class="field">
|
||||
<label>Productos seleccionados</label>
|
||||
<div class="field-value" style="color:#2ecc71;font-weight:600;">${count} productos</div>
|
||||
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">${names}${moreText}</div>
|
||||
<div class="field-value" style="color:var(--ok);font-weight:600;">${count} productos</div>
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">${names}${moreText}</div>
|
||||
<div style="font-size:11px;margin-top:4px;">
|
||||
<span class="badge stock" style="margin-left:0;">${inStockCount} en stock</span>
|
||||
<span class="badge nostock">${count - inStockCount} sin stock</span>
|
||||
@@ -662,26 +662,26 @@ class ProductsCrud extends HTMLElement {
|
||||
<div class="field">
|
||||
<label>Unidad de venta (para todos)</label>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<select id="sellUnit" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
|
||||
<select id="sellUnit" style="background:var(--panel-2);color:var(--text);border:1px solid var(--border-hi);border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
|
||||
<option value="kg">Por peso (kg)</option>
|
||||
<option value="unit">Por unidad</option>
|
||||
</select>
|
||||
<button id="saveUnit" style="padding:8px 16px;">Aplicar</button>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">
|
||||
Se aplicará a todos los productos seleccionados
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Agregar categoría (para todos)</label>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<select id="addCategorySelect" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
|
||||
<select id="addCategorySelect" style="background:var(--panel-2);color:var(--text);border:1px solid var(--border-hi);border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
|
||||
<option value="">-- Seleccionar categoría --</option>
|
||||
${this.getAllCategories().map(cat => `<option value="${this.escapeHtml(cat)}">${this.escapeHtml(cat)}</option>`).join("")}
|
||||
</select>
|
||||
<button id="addCategory" style="padding:8px 16px;">Agregar</button>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">
|
||||
Se agregará esta categoría a todos los productos seleccionados
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,470 +0,0 @@
|
||||
import { api } from "../lib/api.js";
|
||||
import { on } from "../lib/bus.js";
|
||||
import { modal } from "../lib/modal.js";
|
||||
|
||||
const PROMPT_LABELS = {
|
||||
router: "Router (clasificador de dominio)",
|
||||
greeting: "Saludos",
|
||||
orders: "Pedidos",
|
||||
shipping: "Envio/Retiro",
|
||||
payment: "Pago",
|
||||
browse: "Consultas de catalogo",
|
||||
};
|
||||
|
||||
class PromptsCrud extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.items = [];
|
||||
this.selected = null;
|
||||
this.loading = false;
|
||||
this.versions = [];
|
||||
this.availableVariables = [];
|
||||
this.availableModels = [];
|
||||
this.currentSettings = {}; // Valores actuales de las variables
|
||||
this.testResult = null;
|
||||
this.testLoading = false;
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||
.container { display:grid; grid-template-columns:280px 1fr; gap:16px; height:100%; }
|
||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||
|
||||
input, select, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
||||
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
|
||||
textarea { resize:vertical; min-height:200px; font-family:monospace; font-size:12px; line-height:1.5; }
|
||||
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||
button:hover { background:#1a5fd0; }
|
||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||
button.secondary { background:#253245; }
|
||||
button.secondary:hover { background:#2d3e52; }
|
||||
button.danger { background:#e74c3c; }
|
||||
button.danger:hover { background:#c0392b; }
|
||||
button.small { padding:4px 8px; font-size:11px; }
|
||||
|
||||
.list { flex:1; overflow-y:auto; }
|
||||
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
||||
.item:hover { border-color:#1f6feb; }
|
||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
||||
.item-name { font-weight:600; color:#e7eef7; margin-bottom:4px; font-size:14px; }
|
||||
.item-meta { font-size:11px; color:#8aa0b5; }
|
||||
.item-meta .default { color:#2ecc71; }
|
||||
.item-meta .custom { color:#f39c12; }
|
||||
|
||||
.form { flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:16px; }
|
||||
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
||||
.field { }
|
||||
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.field-hint { font-size:11px; color:#8aa0b5; margin-top:4px; }
|
||||
|
||||
.actions { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
||||
|
||||
.variables-list { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
|
||||
.var-item { display:inline-flex; align-items:center; gap:4px; background:#0f1520; border:1px solid #253245; border-radius:4px; padding:2px 4px 2px 2px; }
|
||||
.var-btn { background:#253245; border:none; color:#8aa0b5; padding:4px 8px; border-radius:3px; font-size:11px; cursor:pointer; font-family:monospace; }
|
||||
.var-btn:hover { background:#1f6feb; color:#fff; }
|
||||
.var-value { font-size:10px; color:#6c7a89; max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
|
||||
.versions-list { max-height:150px; overflow-y:auto; margin-top:8px; }
|
||||
.version-item { display:flex; justify-content:space-between; align-items:center; padding:6px 8px; background:#0f1520; border-radius:4px; margin-bottom:4px; font-size:12px; }
|
||||
.version-item.active { border-left:3px solid #2ecc71; }
|
||||
.version-item .ver { color:#e7eef7; }
|
||||
.version-item .date { color:#8aa0b5; font-size:10px; }
|
||||
|
||||
.test-section { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-top:16px; }
|
||||
.test-section h4 { margin:0 0 12px; font-size:13px; color:#8aa0b5; }
|
||||
.test-result { background:#0a0e14; border:1px solid #1e2a3a; border-radius:6px; padding:12px; margin-top:12px; font-family:monospace; font-size:11px; white-space:pre-wrap; max-height:200px; overflow-y:auto; }
|
||||
.test-result.error { border-color:#e74c3c; color:#e74c3c; }
|
||||
.test-result.success { border-color:#2ecc71; }
|
||||
.test-meta { font-size:10px; color:#8aa0b5; margin-top:8px; }
|
||||
|
||||
.row { display:flex; gap:12px; align-items:flex-end; }
|
||||
.row .field { flex:1; }
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<div class="panel-title">Prompts del Sistema</div>
|
||||
<div class="list" id="list">
|
||||
<div class="loading">Cargando...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-title" id="formTitle">Editor de Prompt</div>
|
||||
<div class="form" id="form">
|
||||
<div class="form-empty">Selecciona un prompt para editarlo</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.load();
|
||||
|
||||
// Refrescar settings cuando se vuelve a esta vista (por si cambiaron en Config)
|
||||
this._unsubRouter = on("router:viewChanged", ({ view }) => {
|
||||
if (view === "prompts") {
|
||||
this.refreshSettings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._unsubRouter?.();
|
||||
}
|
||||
|
||||
async refreshSettings() {
|
||||
try {
|
||||
const settings = await api.getSettings();
|
||||
this.currentSettings = {
|
||||
store_name: settings.store_name || "",
|
||||
store_hours: this.formatStoreHours(settings),
|
||||
store_address: settings.store_address || "",
|
||||
store_phone: settings.store_phone || "",
|
||||
bot_name: settings.bot_name || "",
|
||||
current_date: new Date().toLocaleDateString("es-AR"),
|
||||
customer_name: "(nombre del cliente)",
|
||||
state: "(estado actual)",
|
||||
};
|
||||
// Re-renderizar el form si hay uno seleccionado
|
||||
if (this.selected) {
|
||||
this.renderForm();
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug("Error refreshing settings:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.renderList();
|
||||
|
||||
try {
|
||||
// Cargar prompts y settings en paralelo
|
||||
const [data, settings] = await Promise.all([
|
||||
api.prompts(),
|
||||
api.getSettings().catch(() => ({})),
|
||||
]);
|
||||
|
||||
this.items = data.items || [];
|
||||
this.availableVariables = data.available_variables || [];
|
||||
this.availableModels = data.available_models || [];
|
||||
|
||||
// Mapear settings a variables
|
||||
this.currentSettings = {
|
||||
store_name: settings.store_name || "",
|
||||
store_hours: this.formatStoreHours(settings),
|
||||
store_address: settings.store_address || "",
|
||||
store_phone: settings.store_phone || "",
|
||||
bot_name: settings.bot_name || "",
|
||||
current_date: new Date().toLocaleDateString("es-AR"),
|
||||
customer_name: "(nombre del cliente)",
|
||||
state: "(estado actual)",
|
||||
};
|
||||
|
||||
this.loading = false;
|
||||
this.renderList();
|
||||
} catch (e) {
|
||||
console.error("Error loading prompts:", e);
|
||||
this.items = [];
|
||||
this.loading = false;
|
||||
this.renderList();
|
||||
}
|
||||
}
|
||||
|
||||
formatStoreHours(settings) {
|
||||
if (!settings.pickup_days) return "";
|
||||
|
||||
// Mapeo de días cortos a nombres legibles
|
||||
const dayNames = {
|
||||
lun: "Lun", mar: "Mar", mie: "Mié", jue: "Jue",
|
||||
vie: "Vie", sab: "Sáb", dom: "Dom"
|
||||
};
|
||||
|
||||
const days = settings.pickup_days.split(",").map(d => dayNames[d.trim()] || d).join(", ");
|
||||
const start = (settings.pickup_hours_start || "08:00").slice(0, 5);
|
||||
const end = (settings.pickup_hours_end || "20:00").slice(0, 5);
|
||||
|
||||
return `${days} de ${start} a ${end}`;
|
||||
}
|
||||
|
||||
renderList() {
|
||||
const list = this.shadowRoot.getElementById("list");
|
||||
|
||||
if (this.loading) {
|
||||
list.innerHTML = `<div class="loading">Cargando...</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.items.length) {
|
||||
list.innerHTML = `<div class="loading">No se encontraron prompts</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = "";
|
||||
for (const item of this.items) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "item" + (this.selected?.prompt_key === item.prompt_key ? " active" : "");
|
||||
|
||||
const label = PROMPT_LABELS[item.prompt_key] || item.prompt_key;
|
||||
const statusClass = item.is_default ? "default" : "custom";
|
||||
const statusText = item.is_default ? "Default" : `v${item.version}`;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="item-name">${label}</div>
|
||||
<div class="item-meta">
|
||||
<span class="${statusClass}">${statusText}</span>
|
||||
${item.model ? ` | ${item.model}` : ""}
|
||||
</div>
|
||||
`;
|
||||
|
||||
el.onclick = () => this.selectPrompt(item);
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
async selectPrompt(item) {
|
||||
this.selected = item;
|
||||
this.testResult = null;
|
||||
this.renderList();
|
||||
|
||||
// Cargar detalles con versiones
|
||||
try {
|
||||
const details = await api.getPrompt(item.prompt_key);
|
||||
this.selected = { ...item, ...details.current };
|
||||
this.versions = details.versions || [];
|
||||
this.availableVariables = details.available_variables || this.availableVariables;
|
||||
this.availableModels = details.available_models || this.availableModels;
|
||||
this.renderForm();
|
||||
} catch (e) {
|
||||
console.error("Error loading prompt details:", e);
|
||||
this.renderForm();
|
||||
}
|
||||
}
|
||||
|
||||
renderForm() {
|
||||
const form = this.shadowRoot.getElementById("form");
|
||||
const title = this.shadowRoot.getElementById("formTitle");
|
||||
|
||||
if (!this.selected) {
|
||||
title.textContent = "Editor de Prompt";
|
||||
form.innerHTML = `<div class="form-empty">Selecciona un prompt para editarlo</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const label = PROMPT_LABELS[this.selected.prompt_key] || this.selected.prompt_key;
|
||||
title.textContent = `Editar: ${label}`;
|
||||
|
||||
const content = this.selected.content || "";
|
||||
const model = this.selected.model || "gpt-4-turbo";
|
||||
|
||||
form.innerHTML = `
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label>Modelo LLM</label>
|
||||
<select id="modelSelect">
|
||||
${this.availableModels.map(m => `<option value="${m}" ${m === model ? "selected" : ""}>${m}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="flex:0">
|
||||
<label> </label>
|
||||
<button id="resetBtn" class="secondary">Reset a Default</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" style="flex:1; display:flex; flex-direction:column;">
|
||||
<label>Contenido del Prompt</label>
|
||||
<textarea id="contentInput" style="flex:1; min-height:250px;">${this.escapeHtml(content)}</textarea>
|
||||
<div class="field-hint">
|
||||
Variables disponibles (click para insertar):
|
||||
<div class="variables-list" id="variablesList">
|
||||
${this.availableVariables.map(v => {
|
||||
const key = typeof v === 'string' ? v : v.key;
|
||||
const desc = typeof v === 'string' ? '' : (v.description || '');
|
||||
const value = this.currentSettings[key] || '';
|
||||
const displayValue = value ? `= ${value}` : '(vacío)';
|
||||
return `<span class="var-item">
|
||||
<button class="var-btn" data-var="${key}" title="${desc}">{{${key}}}</button>
|
||||
<span class="var-value" title="${this.escapeHtml(value)}">${this.escapeHtml(displayValue)}</span>
|
||||
</span>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.versions.length > 0 ? `
|
||||
<div class="field">
|
||||
<label>Historial de Versiones</label>
|
||||
<div class="versions-list" id="versionsList">
|
||||
${this.versions.map(v => `
|
||||
<div class="version-item ${v.is_active ? "active" : ""}">
|
||||
<span class="ver">v${v.version} ${v.is_active ? "(activa)" : ""}</span>
|
||||
<span class="date">${this.formatDate(v.created_at)}</span>
|
||||
${!v.is_active ? `<button class="small secondary" data-version="${v.version}">Restaurar</button>` : ""}
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
<div class="actions">
|
||||
<button id="saveBtn">Guardar Cambios</button>
|
||||
<button id="testBtn" class="secondary">Probar Prompt</button>
|
||||
</div>
|
||||
|
||||
<div class="test-section" id="testSection" style="display:none;">
|
||||
<h4>Probar Prompt</h4>
|
||||
<div class="field">
|
||||
<label>Mensaje de prueba</label>
|
||||
<input type="text" id="testMessage" placeholder="Ej: Hola, quiero 2kg de asado" />
|
||||
</div>
|
||||
<button id="runTestBtn" style="margin-top:8px;">Ejecutar Prueba</button>
|
||||
<div id="testResultContainer"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event listeners
|
||||
this.shadowRoot.getElementById("saveBtn").onclick = () => this.save();
|
||||
this.shadowRoot.getElementById("resetBtn").onclick = () => this.reset();
|
||||
this.shadowRoot.getElementById("testBtn").onclick = () => this.toggleTestSection();
|
||||
this.shadowRoot.getElementById("runTestBtn").onclick = () => this.runTest();
|
||||
|
||||
// Variable buttons
|
||||
this.shadowRoot.querySelectorAll(".var-btn").forEach(btn => {
|
||||
btn.onclick = () => this.insertVariable(btn.dataset.var);
|
||||
});
|
||||
|
||||
// Version restore buttons
|
||||
this.shadowRoot.querySelectorAll(".versions-list button").forEach(btn => {
|
||||
btn.onclick = () => this.rollback(parseInt(btn.dataset.version, 10));
|
||||
});
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
return (str || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("es-AR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
insertVariable(varName) {
|
||||
const textarea = this.shadowRoot.getElementById("contentInput");
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
const insertion = `{{${varName}}}`;
|
||||
textarea.value = text.slice(0, start) + insertion + text.slice(end);
|
||||
textarea.selectionStart = textarea.selectionEnd = start + insertion.length;
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
toggleTestSection() {
|
||||
const section = this.shadowRoot.getElementById("testSection");
|
||||
section.style.display = section.style.display === "none" ? "block" : "none";
|
||||
}
|
||||
|
||||
async save() {
|
||||
const content = this.shadowRoot.getElementById("contentInput").value;
|
||||
const model = this.shadowRoot.getElementById("modelSelect").value;
|
||||
|
||||
if (!content.trim()) {
|
||||
modal.warn("El contenido no puede estar vacío");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.savePrompt(this.selected.prompt_key, { content, model });
|
||||
modal.success("Prompt guardado correctamente");
|
||||
await this.load();
|
||||
// Re-seleccionar el prompt actual
|
||||
const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key);
|
||||
if (updated) this.selectPrompt(updated);
|
||||
} catch (e) {
|
||||
console.error("Error saving prompt:", e);
|
||||
modal.error("Error guardando: " + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async reset() {
|
||||
const confirmed = await modal.confirm("Esto desactivará todas las versiones custom y volverá al prompt por defecto. ¿Continuar?");
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await api.resetPrompt(this.selected.prompt_key);
|
||||
modal.success("Prompt reseteado a default");
|
||||
await this.load();
|
||||
const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key);
|
||||
if (updated) this.selectPrompt(updated);
|
||||
} catch (e) {
|
||||
console.error("Error resetting prompt:", e);
|
||||
modal.error("Error: " + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async rollback(version) {
|
||||
const confirmed = await modal.confirm(`¿Restaurar versión ${version}? Se creará una nueva versión con ese contenido.`);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await api.rollbackPrompt(this.selected.prompt_key, version);
|
||||
modal.success("Versión restaurada");
|
||||
await this.load();
|
||||
const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key);
|
||||
if (updated) this.selectPrompt(updated);
|
||||
} catch (e) {
|
||||
console.error("Error rolling back:", e);
|
||||
modal.error("Error: " + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async runTest() {
|
||||
const testMessage = this.shadowRoot.getElementById("testMessage").value;
|
||||
if (!testMessage.trim()) {
|
||||
modal.warn("Ingresa un mensaje de prueba");
|
||||
return;
|
||||
}
|
||||
|
||||
const content = this.shadowRoot.getElementById("contentInput").value;
|
||||
const container = this.shadowRoot.getElementById("testResultContainer");
|
||||
container.innerHTML = `<div class="test-result">Ejecutando prueba...</div>`;
|
||||
|
||||
try {
|
||||
const result = await api.testPrompt(this.selected.prompt_key, {
|
||||
content,
|
||||
test_message: testMessage,
|
||||
store_config: { store_name: "Carniceria Demo", bot_name: "Piaf" },
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
let parsed = result.response;
|
||||
try {
|
||||
parsed = JSON.stringify(JSON.parse(result.response), null, 2);
|
||||
} catch (e) { /* no es JSON */ }
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="test-result success">${this.escapeHtml(parsed)}</div>
|
||||
<div class="test-meta">
|
||||
Modelo: ${result.model} | Latencia: ${result.latency_ms}ms |
|
||||
Tokens: ${result.usage?.total_tokens || "?"}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
container.innerHTML = `<div class="test-result error">Error: ${result.error || "Unknown"}</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error testing prompt:", e);
|
||||
container.innerHTML = `<div class="test-result error">Error: ${e.message || e}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("prompts-crud", PromptsCrud);
|
||||
@@ -28,47 +28,47 @@ class QuantitiesCrud extends HTMLElement {
|
||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
|
||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||
|
||||
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
|
||||
input, select { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
||||
input:focus, select:focus { outline:none; border-color:#1f6feb; }
|
||||
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||
button:hover { background:#1a5fd0; }
|
||||
input, select { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
||||
input:focus, select:focus { outline:none; border-color:var(--accent); }
|
||||
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||
button:hover { background:var(--accent-hover); }
|
||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||
button.secondary { background:#253245; }
|
||||
button.secondary:hover { background:#2d3e52; }
|
||||
button.secondary { background:var(--border-hi); }
|
||||
button.secondary:hover { background:var(--border-hi); }
|
||||
|
||||
.list { flex:1; overflow-y:auto; }
|
||||
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; display:flex; justify-content:space-between; align-items:center; }
|
||||
.item:hover { border-color:#1f6feb; }
|
||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
||||
.item-name { font-weight:500; color:#e7eef7; }
|
||||
.item-price { font-size:12px; color:#8aa0b5; }
|
||||
.badge { display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 6px; border-radius:999px; font-size:11px; font-weight:600; background:#1f6feb; color:#fff; }
|
||||
.badge.empty { background:#253245; color:#8aa0b5; }
|
||||
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; display:flex; justify-content:space-between; align-items:center; }
|
||||
.item:hover { border-color:var(--accent); }
|
||||
.item.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||
.item-name { font-weight:500; color:var(--text); }
|
||||
.item-price { font-size:12px; color:var(--text-muted); }
|
||||
.badge { display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 6px; border-radius:999px; font-size:11px; font-weight:600; background:var(--accent); color:#fff; }
|
||||
.badge.empty { background:var(--border-hi); color:var(--text-muted); }
|
||||
|
||||
.form { flex:1; overflow-y:auto; }
|
||||
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
||||
.form-empty { color:var(--text-muted); text-align:center; padding:40px; }
|
||||
.product-header { margin-bottom:16px; }
|
||||
.product-name { font-size:18px; font-weight:600; color:#e7eef7; margin-bottom:4px; }
|
||||
.product-price { font-size:14px; color:#8aa0b5; }
|
||||
.product-name { font-size:18px; font-weight:600; color:var(--text); margin-bottom:4px; }
|
||||
.product-price { font-size:14px; color:var(--text-muted); }
|
||||
|
||||
.qty-grid { width:100%; border-collapse:collapse; }
|
||||
.qty-grid th { text-align:left; font-size:12px; color:#8aa0b5; padding:10px 8px; border-bottom:1px solid #253245; }
|
||||
.qty-grid td { padding:8px; border-bottom:1px solid #1e2a3a; }
|
||||
.qty-grid .event-label { font-size:13px; color:#e7eef7; font-weight:500; }
|
||||
.qty-grid th { text-align:left; font-size:12px; color:var(--text-muted); padding:10px 8px; border-bottom:1px solid var(--border-hi); }
|
||||
.qty-grid td { padding:8px; border-bottom:1px solid var(--border); }
|
||||
.qty-grid .event-label { font-size:13px; color:var(--text); font-weight:500; }
|
||||
.qty-grid input { width:70px; padding:6px 8px; font-size:12px; text-align:center; }
|
||||
.qty-grid select { width:70px; padding:6px 4px; font-size:11px; }
|
||||
|
||||
.cell-group { display:flex; gap:4px; align-items:center; }
|
||||
|
||||
.actions { display:flex; gap:8px; margin-top:16px; }
|
||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
||||
.loading { text-align:center; padding:40px; color:var(--text-muted); }
|
||||
|
||||
.status { font-size:12px; color:#2ecc71; margin-left:auto; }
|
||||
.status.error { color:#e74c3c; }
|
||||
.status { font-size:12px; color:var(--ok); margin-left:auto; }
|
||||
.status.error { color:var(--err); }
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
|
||||
@@ -40,45 +40,45 @@ class RecommendationsCrud extends HTMLElement {
|
||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
|
||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||
|
||||
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
|
||||
input, select, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
||||
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
|
||||
input, select, textarea { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
||||
input:focus, select:focus, textarea:focus { outline:none; border-color:var(--accent); }
|
||||
textarea { min-height:60px; resize:vertical; font-size:13px; }
|
||||
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||
button:hover { background:#1a5fd0; }
|
||||
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||
button:hover { background:var(--accent-hover); }
|
||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||
button.secondary { background:#253245; }
|
||||
button.secondary:hover { background:#2d3e52; }
|
||||
button.danger { background:#e74c3c; }
|
||||
button.danger:hover { background:#c0392b; }
|
||||
button.secondary { background:var(--border-hi); }
|
||||
button.secondary:hover { background:var(--border-hi); }
|
||||
button.danger { background:var(--err); }
|
||||
button.danger:hover { background:var(--err); }
|
||||
button.small { padding:4px 8px; font-size:11px; }
|
||||
|
||||
.list { flex:1; overflow-y:auto; }
|
||||
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
||||
.item:hover { border-color:#1f6feb; }
|
||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
||||
.item-key { font-weight:600; color:#e7eef7; margin-bottom:4px; display:flex; align-items:center; gap:8px; }
|
||||
.item-trigger { font-size:12px; color:#8aa0b5; margin-bottom:4px; }
|
||||
.item-queries { font-size:11px; color:#2ecc71; }
|
||||
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
||||
.item:hover { border-color:var(--accent); }
|
||||
.item.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||
.item-key { font-weight:600; color:var(--text); margin-bottom:4px; display:flex; align-items:center; gap:8px; }
|
||||
.item-trigger { font-size:12px; color:var(--text-muted); margin-bottom:4px; }
|
||||
.item-queries { font-size:11px; color:var(--ok); }
|
||||
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:10px; }
|
||||
.badge.active { background:#0f2a1a; color:#2ecc71; }
|
||||
.badge.inactive { background:#241214; color:#e74c3c; }
|
||||
.badge.priority { background:#253245; color:#8aa0b5; }
|
||||
.badge.type { background:#1a2a4a; color:#5dade2; }
|
||||
.badge.active { background:var(--ok-soft); color:var(--ok); }
|
||||
.badge.inactive { background:var(--err-soft); color:var(--err); }
|
||||
.badge.priority { background:var(--border-hi); color:var(--text-muted); }
|
||||
.badge.type { background:var(--accent-soft); color:var(--accent-hover); }
|
||||
|
||||
.form { flex:1; overflow-y:auto; }
|
||||
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
||||
.form-empty { color:var(--text-muted); text-align:center; padding:40px; }
|
||||
.field { margin-bottom:16px; }
|
||||
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.field-hint { font-size:11px; color:#8aa0b5; margin-top:4px; }
|
||||
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.field-hint { font-size:11px; color:var(--text-muted); margin-top:4px; }
|
||||
.field-row { display:flex; gap:12px; }
|
||||
.field-row .field { flex:1; }
|
||||
|
||||
.actions { display:flex; gap:8px; margin-top:16px; }
|
||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
||||
.loading { text-align:center; padding:40px; color:var(--text-muted); }
|
||||
|
||||
.toggle { display:flex; align-items:center; gap:8px; cursor:pointer; }
|
||||
.toggle input { width:auto; }
|
||||
@@ -88,42 +88,42 @@ class RecommendationsCrud extends HTMLElement {
|
||||
.product-search { margin-bottom:8px; }
|
||||
.product-dropdown {
|
||||
position:absolute; top:100%; left:0; right:0; z-index:100;
|
||||
background:#0f1520; border:1px solid #253245; border-radius:8px;
|
||||
background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px;
|
||||
max-height:200px; overflow-y:auto; display:none;
|
||||
}
|
||||
.product-dropdown.open { display:block; }
|
||||
.product-option {
|
||||
padding:8px 12px; cursor:pointer; font-size:13px; color:#e7eef7;
|
||||
padding:8px 12px; cursor:pointer; font-size:13px; color:var(--text);
|
||||
display:flex; justify-content:space-between; align-items:center;
|
||||
}
|
||||
.product-option:hover { background:#1a2535; }
|
||||
.product-option.selected { background:#1a3a5c; }
|
||||
.product-option .price { font-size:11px; color:#8aa0b5; }
|
||||
.product-option:hover { background:var(--panel-2); }
|
||||
.product-option.selected { background:var(--accent-soft); }
|
||||
.product-option .price { font-size:11px; color:var(--text-muted); }
|
||||
|
||||
.selected-products { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; min-height:30px; }
|
||||
.product-chip {
|
||||
display:inline-flex; align-items:center; gap:4px;
|
||||
background:#253245; color:#e7eef7; padding:4px 8px 4px 12px;
|
||||
background:var(--border-hi); color:var(--text); padding:4px 8px 4px 12px;
|
||||
border-radius:999px; font-size:12px;
|
||||
}
|
||||
.product-chip .remove {
|
||||
cursor:pointer; width:16px; height:16px; border-radius:50%;
|
||||
background:#e74c3c; color:#fff; font-size:10px;
|
||||
background:var(--err); color:#fff; font-size:10px;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.product-chip .remove:hover { background:#c0392b; }
|
||||
.product-chip .remove:hover { background:var(--err); }
|
||||
|
||||
.empty-hint { color:#8aa0b5; font-size:12px; font-style:italic; }
|
||||
.empty-hint { color:var(--text-muted); font-size:12px; font-style:italic; }
|
||||
|
||||
/* Items table styles */
|
||||
.items-table { width:100%; border-collapse:collapse; margin-top:8px; }
|
||||
.items-table th { text-align:left; font-size:11px; color:#8aa0b5; padding:8px 4px; border-bottom:1px solid #253245; }
|
||||
.items-table td { padding:6px 4px; border-bottom:1px solid #1e2a3a; vertical-align:middle; }
|
||||
.items-table th { text-align:left; font-size:11px; color:var(--text-muted); padding:8px 4px; border-bottom:1px solid var(--border-hi); }
|
||||
.items-table td { padding:6px 4px; border-bottom:1px solid var(--border); vertical-align:middle; }
|
||||
.items-table input { padding:6px 8px; font-size:12px; }
|
||||
.items-table input[type="number"] { width:70px; }
|
||||
.items-table select { padding:6px 8px; font-size:12px; width:80px; }
|
||||
.items-table .product-name { font-size:13px; color:#e7eef7; }
|
||||
.items-table .btn-remove { background:#e74c3c; padding:4px 8px; font-size:11px; }
|
||||
.items-table .product-name { font-size:13px; color:var(--text); }
|
||||
.items-table .btn-remove { background:var(--err); padding:4px 8px; font-size:11px; }
|
||||
|
||||
.add-item-row { display:flex; gap:8px; margin-top:12px; align-items:flex-end; }
|
||||
.add-item-row .field { margin-bottom:0; }
|
||||
@@ -131,12 +131,12 @@ class RecommendationsCrud extends HTMLElement {
|
||||
/* Rule type selector */
|
||||
.rule-type-selector { display:flex; gap:8px; margin-bottom:16px; }
|
||||
.rule-type-btn {
|
||||
flex:1; padding:12px; border:2px solid #253245; border-radius:8px;
|
||||
background:#0f1520; color:#8aa0b5; cursor:pointer; text-align:center;
|
||||
flex:1; padding:12px; border:2px solid var(--border-hi); border-radius:8px;
|
||||
background:var(--panel-2); color:var(--text-muted); cursor:pointer; text-align:center;
|
||||
transition:all .15s;
|
||||
}
|
||||
.rule-type-btn:hover { border-color:#1f6feb; }
|
||||
.rule-type-btn.active { border-color:#1f6feb; background:#111b2a; color:#e7eef7; }
|
||||
.rule-type-btn:hover { border-color:var(--accent); }
|
||||
.rule-type-btn.active { border-color:var(--accent); background:var(--accent-soft); color:var(--text); }
|
||||
.rule-type-btn .type-title { font-weight:600; margin-bottom:4px; }
|
||||
.rule-type-btn .type-desc { font-size:11px; }
|
||||
</style>
|
||||
|
||||
@@ -7,32 +7,74 @@ class RunTimeline extends HTMLElement {
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.chatId = null;
|
||||
this.items = [];
|
||||
// Track if user scrolled away from the bottom — pause auto-scroll to not chase them
|
||||
this._userScrolledUp = false;
|
||||
this._scrollRaf = null;
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display:block; padding:12px; height:100%; overflow:hidden; }
|
||||
.box { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; height:100%; display:flex; flex-direction:column; min-height:0; box-sizing:border-box; }
|
||||
.row { display:flex; gap:8px; align-items:center; }
|
||||
.muted { color:#8aa0b5; font-size:12px; }
|
||||
.title { font-weight:800; }
|
||||
.chatlog { display:flex; flex-direction:column; gap:0; overflow-y:auto; padding-right:6px; margin-top:8px; flex:1; min-height:0; }
|
||||
/* WhatsApp-ish dark theme bubbles */
|
||||
.bubble { max-width:90%; margin-bottom:12px; padding:8px 10px; border-radius:14px; border:1px solid #253245; font-size:13px; line-height:1.35; white-space:pre-wrap; word-break:break-word; box-shadow: 0 1px 0 rgba(0,0,0,.35); box-sizing:border-box; }
|
||||
.bubble.user { align-self:flex-end; background:#0f2a1a; border-color:#1f6f43; color:#e7eef7; }
|
||||
.bubble.bot { align-self:flex-start; background:#111b2a; border-color:#2a3a55; color:#e7eef7; }
|
||||
.bubble.err { align-self:flex-start; background:#241214; border-color:#e74c3c; color:#ffe9ea; cursor:pointer; }
|
||||
.bubble.active { outline:2px solid #1f6feb; box-shadow: 0 0 0 2px rgba(31,111,235,.25); }
|
||||
.name { display:block; font-size:12px; font-weight:800; margin-bottom:4px; opacity:.95; }
|
||||
.bubble.user .name { color:#cdebd8; text-align:right; }
|
||||
.bubble.bot .name { color:#c7d8ee; }
|
||||
.bubble.err .name { color:#ffd0d4; }
|
||||
.bubble .meta { display:block; margin-top:6px; font-size:11px; color:#8aa0b5; }
|
||||
.bubble.user .meta { color:#b9d9c6; opacity:.85; }
|
||||
.bubble.bot .meta { color:#a9bed6; opacity:.85; }
|
||||
.bubble.err .meta { color:#ffd0d4; opacity:.85; }
|
||||
.toolbar { display:flex; gap:8px; margin-top:8px; align-items:center; }
|
||||
button { cursor:pointer; background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px; font-size:13px; }
|
||||
pre { white-space:pre-wrap; word-break:break-word; background:#0f1520; border:1px solid #253245; border-radius:10px; padding:10px; margin:0; font-size:12px; color:#d7e2ef; }
|
||||
:host { display:block; padding: var(--space-4); height:100%; overflow:hidden; font-family: var(--font-sans); }
|
||||
.box {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
padding: var(--space-5);
|
||||
height:100%; display:flex; flex-direction:column; min-height:0; box-sizing:border-box;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.row { display:flex; gap: var(--space-2); align-items:center; }
|
||||
.muted { color: var(--text-muted); font-size: var(--fs-sm); }
|
||||
.title { font-weight: var(--fw-semibold); font-size: var(--fs-md); color: var(--text); }
|
||||
.chatlog { display:flex; flex-direction:column; gap:0; overflow-y:auto; padding-right:8px; margin-top: var(--space-3); flex:1; min-height:0; }
|
||||
|
||||
/* WhatsApp-ish light pastel bubbles */
|
||||
.bubble {
|
||||
max-width: 88%; min-width:0;
|
||||
margin-bottom: var(--space-3);
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--r-xl);
|
||||
border: 1px solid;
|
||||
font-size: var(--fs-base); line-height: var(--lh-base);
|
||||
white-space:pre-wrap; word-break:break-word; overflow-wrap:anywhere;
|
||||
box-shadow: var(--shadow-bubble);
|
||||
box-sizing:border-box;
|
||||
}
|
||||
.bubble.user { align-self:flex-end; background: var(--user-bubble); border-color: var(--user-border); color: var(--user-text); }
|
||||
.bubble.bot { align-self:flex-start; background: var(--bot-bubble); border-color: var(--bot-border); color: var(--bot-text); }
|
||||
.bubble.err { align-self:flex-start; background: var(--err-bubble); border-color: var(--err-border); color: var(--err-text); cursor:pointer; }
|
||||
.bubble.active { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
|
||||
.name { display:block; font-size: var(--fs-xs); font-weight: var(--fw-semibold); margin-bottom: 4px; letter-spacing: 0.02em; }
|
||||
.bubble.user .name { color: var(--user-name); text-align:right; }
|
||||
.bubble.bot .name { color: var(--bot-name); }
|
||||
.bubble.err .name { color: var(--err-name); }
|
||||
.bubble .meta { display:block; margin-top: 6px; font-size: var(--fs-xs); color: var(--text-muted); }
|
||||
.bubble.user .meta { color: var(--user-meta); }
|
||||
.bubble.bot .meta { color: var(--bot-meta); }
|
||||
.bubble.err .meta { color: var(--err-meta); }
|
||||
|
||||
.toolbar { display:flex; gap: var(--space-2); margin-top: var(--space-3); align-items:center; }
|
||||
button {
|
||||
cursor:pointer;
|
||||
background: var(--panel); color: var(--text);
|
||||
border: 1px solid var(--border-hi);
|
||||
border-radius: var(--r-md);
|
||||
padding: 8px 14px;
|
||||
font: var(--fw-medium) var(--fs-sm)/1 var(--font-sans);
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
button:hover { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-hover); }
|
||||
button:focus-visible { outline:none; box-shadow: var(--focus-ring); }
|
||||
pre {
|
||||
white-space:pre-wrap; word-break:break-word; overflow-wrap:anywhere; overflow-x:auto; max-width:100%;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 10px 12px;
|
||||
margin:0;
|
||||
font: 400 12px/1.5 var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="box">
|
||||
@@ -197,14 +239,14 @@ class RunTimeline extends HTMLElement {
|
||||
addedOptimistic = true;
|
||||
}
|
||||
|
||||
// auto-scroll al final cuando hay mensajes nuevos
|
||||
// Solo si el usuario estaba cerca del final (dentro de 150px) o si había optimistas
|
||||
const wasNearBottom = this._lastScrollPosition === undefined ||
|
||||
(log.scrollHeight - this._lastScrollPosition - log.clientHeight) < 150;
|
||||
if (addedOptimistic || wasNearBottom) {
|
||||
// Auto-scroll al final cuando hay mensajes nuevos.
|
||||
// Si el usuario scrolleó arriba (>150px del fondo), respetamos su posición
|
||||
// a menos que él mismo haya disparado un optimistic bubble.
|
||||
if (addedOptimistic || !this._userScrolledUp) {
|
||||
log.scrollTop = log.scrollHeight;
|
||||
// Una vez forzado al final, considerarlo "abajo" hasta que vuelva a scrollear arriba.
|
||||
this._userScrolledUp = false;
|
||||
}
|
||||
this._lastScrollPosition = log.scrollTop;
|
||||
|
||||
requestAnimationFrame(() => this.emitLayout());
|
||||
this.bindScroll(log);
|
||||
@@ -215,8 +257,14 @@ class RunTimeline extends HTMLElement {
|
||||
if (this._scrollBound) return;
|
||||
this._scrollBound = true;
|
||||
log.addEventListener("scroll", () => {
|
||||
this._lastScrollPosition = log.scrollTop;
|
||||
emit("ui:chatScroll", { chat_id: this.chatId, scrollTop: log.scrollTop });
|
||||
// Throttle con rAF: el handler real corre 1x por frame.
|
||||
if (this._scrollRaf) return;
|
||||
this._scrollRaf = requestAnimationFrame(() => {
|
||||
this._scrollRaf = null;
|
||||
const distFromBottom = log.scrollHeight - log.scrollTop - log.clientHeight;
|
||||
this._userScrolledUp = distFromBottom > 150;
|
||||
emit("ui:chatScroll", { chat_id: this.chatId, scrollTop: log.scrollTop, userScrolledUp: this._userScrolledUp });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -293,12 +341,11 @@ class RunTimeline extends HTMLElement {
|
||||
bubble.appendChild(metaEl);
|
||||
|
||||
log.appendChild(bubble);
|
||||
|
||||
// Solo hacer scroll si el usuario ya estaba cerca del final (dentro de 100px)
|
||||
const wasNearBottom = (log.scrollHeight - log.scrollTop - log.clientHeight) < 100;
|
||||
if (wasNearBottom) {
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
// El usuario acaba de mandar un mensaje: forzamos al final y reseteamos
|
||||
// el flag "scrolled-up" así los próximos mensajes del bot también auto-scrollean.
|
||||
log.scrollTop = log.scrollHeight;
|
||||
this._userScrolledUp = false;
|
||||
|
||||
// Emit layout update
|
||||
requestAnimationFrame(() => this.emitLayout());
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { api } from "../lib/api.js";
|
||||
|
||||
const DAYS = [
|
||||
{ id: "lun", label: "Lunes", short: "Lun" },
|
||||
{ id: "mar", label: "Martes", short: "Mar" },
|
||||
{ id: "mie", label: "Miércoles", short: "Mié" },
|
||||
{ id: "jue", label: "Jueves", short: "Jue" },
|
||||
{ id: "vie", label: "Viernes", short: "Vie" },
|
||||
{ id: "sab", label: "Sábado", short: "Sáb" },
|
||||
{ id: "dom", label: "Domingo", short: "Dom" },
|
||||
{ id: "lun", label: "Lunes", short: "L" },
|
||||
{ id: "mar", label: "Martes", short: "M" },
|
||||
{ id: "mie", label: "Miércoles", short: "X" },
|
||||
{ id: "jue", label: "Jueves", short: "J" },
|
||||
{ id: "vie", label: "Viernes", short: "V" },
|
||||
{ id: "sab", label: "Sábado", short: "S" },
|
||||
{ id: "dom", label: "Domingo", short: "D" },
|
||||
];
|
||||
|
||||
function makeZoneId(name) {
|
||||
return String(name || "").toLowerCase()
|
||||
.normalize("NFD").replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40) || `zona-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
class SettingsCrud extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
@@ -17,53 +24,79 @@ class SettingsCrud extends HTMLElement {
|
||||
this.settings = null;
|
||||
this.loading = false;
|
||||
this.saving = false;
|
||||
this.zones = [];
|
||||
this.selectedZoneId = null;
|
||||
this._mapEditor = null;
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display:block; height:100%; padding:16px; overflow:auto; }
|
||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||
.container { max-width:800px; margin:0 auto; }
|
||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:20px; margin-bottom:16px; }
|
||||
.panel-title { font-size:16px; font-weight:700; color:#e7eef7; margin-bottom:16px; display:flex; align-items:center; gap:8px; }
|
||||
.panel-title svg { width:20px; height:20px; fill:#1f6feb; }
|
||||
:host { display:block; height:100%; padding:16px 24px 24px; overflow:auto; }
|
||||
* { box-sizing:border-box; font-family:var(--font-sans, system-ui); }
|
||||
.container { max-width:1600px; margin:0 auto; }
|
||||
.settings-grid {
|
||||
display:grid;
|
||||
grid-template-columns:minmax(320px, 360px) minmax(0, 1fr);
|
||||
gap:16px;
|
||||
align-items:start;
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.settings-grid { grid-template-columns:1fr; }
|
||||
}
|
||||
.col { display:flex; flex-direction:column; gap:16px; }
|
||||
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:20px; }
|
||||
.panel-zones { padding:16px 16px 20px; display:flex; flex-direction:column; min-height:560px; }
|
||||
.toolbar {
|
||||
display:flex; align-items:center; justify-content:space-between;
|
||||
gap:16px; padding:12px 4px 16px;
|
||||
position:sticky; top:0; z-index:5; background:var(--bg, #f7fafc);
|
||||
border-bottom:1px solid var(--border);
|
||||
margin-bottom:16px;
|
||||
}
|
||||
.toolbar-title { margin:0; font-size:18px; font-weight:600; color:var(--text); }
|
||||
.toolbar-actions { display:flex; gap:8px; }
|
||||
.toolbar-actions button { padding:8px 16px; font-size:13px; }
|
||||
.zones-map-wrap { display:flex; min-height:520px; min-width:0; }
|
||||
.zones-map-wrap zone-map-editor { flex:1; height:auto; min-height:520px; }
|
||||
.panel-title { font-size:16px; font-weight:700; color:var(--text); margin-bottom:16px; display:flex; align-items:center; gap:8px; }
|
||||
.panel-title svg { width:20px; height:20px; fill:var(--accent); }
|
||||
|
||||
.form-row { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; }
|
||||
.form-row.full { grid-template-columns:1fr; }
|
||||
|
||||
.field { }
|
||||
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:6px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.field-hint { font-size:11px; color:#6c7a89; margin-top:4px; }
|
||||
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:6px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.field-hint { font-size:11px; color:var(--text-muted); margin-top:4px; }
|
||||
|
||||
input, select, textarea {
|
||||
background:#0f1520; color:#e7eef7; border:1px solid #253245;
|
||||
background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi);
|
||||
border-radius:8px; padding:10px 14px; font-size:14px; width:100%;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
|
||||
input:focus, select:focus, textarea:focus { outline:none; border-color:var(--accent); }
|
||||
input:disabled { opacity:.6; cursor:not-allowed; }
|
||||
|
||||
button {
|
||||
cursor:pointer; background:#1f6feb; color:#fff; border:none;
|
||||
cursor:pointer; background:var(--accent); color:#fff; border:none;
|
||||
border-radius:8px; padding:10px 20px; font-size:14px; font-weight:600;
|
||||
}
|
||||
button:hover { background:#1a5fd0; }
|
||||
button:hover { background:var(--accent-hover); }
|
||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||
button.secondary { background:#253245; }
|
||||
button.secondary:hover { background:#2d3e52; }
|
||||
button.secondary { background:var(--border-hi); }
|
||||
button.secondary:hover { background:var(--border-hi); }
|
||||
|
||||
.toggle-row { display:flex; align-items:center; gap:12px; margin-bottom:16px; }
|
||||
.toggle {
|
||||
position:relative; width:48px; height:26px;
|
||||
background:#253245; border-radius:13px; cursor:pointer;
|
||||
background:var(--border-hi); border-radius:13px; cursor:pointer;
|
||||
transition:background .2s; flex-shrink:0;
|
||||
}
|
||||
.toggle.active { background:#1f6feb; }
|
||||
.toggle.active { background:var(--accent); }
|
||||
.toggle::after {
|
||||
content:''; position:absolute; top:3px; left:3px;
|
||||
width:20px; height:20px; background:#fff; border-radius:50%;
|
||||
transition:transform .2s;
|
||||
}
|
||||
.toggle.active::after { transform:translateX(22px); }
|
||||
.toggle-label { font-size:14px; color:#e7eef7; }
|
||||
.toggle-label { font-size:14px; color:var(--text); }
|
||||
|
||||
/* Schedule grid */
|
||||
.schedule-grid { display:flex; flex-direction:column; gap:8px; }
|
||||
@@ -73,17 +106,17 @@ class SettingsCrud extends HTMLElement {
|
||||
gap:12px;
|
||||
align-items:center;
|
||||
padding:8px 12px;
|
||||
background:#0f1520;
|
||||
background:var(--panel-2);
|
||||
border-radius:8px;
|
||||
border:1px solid #1e2a3a;
|
||||
border:1px solid var(--border);
|
||||
}
|
||||
.schedule-row.disabled { opacity:0.4; }
|
||||
.day-label { font-size:13px; color:#e7eef7; font-weight:500; }
|
||||
.day-label { font-size:13px; color:var(--text); font-weight:500; }
|
||||
.day-toggle {
|
||||
width:32px; height:18px; background:#253245; border-radius:9px;
|
||||
width:32px; height:18px; background:var(--border-hi); border-radius:9px;
|
||||
cursor:pointer; position:relative; transition:background .2s;
|
||||
}
|
||||
.day-toggle.active { background:#2ecc71; }
|
||||
.day-toggle.active { background:var(--ok); }
|
||||
.day-toggle::after {
|
||||
content:''; position:absolute; top:2px; left:2px;
|
||||
width:14px; height:14px; background:#fff; border-radius:50%;
|
||||
@@ -95,24 +128,61 @@ class SettingsCrud extends HTMLElement {
|
||||
width:70px; text-align:center; font-family:monospace;
|
||||
font-size:13px; padding:6px 8px; letter-spacing:1px;
|
||||
}
|
||||
.hours-inputs span { color:#6c7a89; font-size:12px; }
|
||||
.hours-inputs span { color:var(--text-muted); font-size:12px; }
|
||||
.hours-inputs.disabled input { opacity:0.4; pointer-events:none; }
|
||||
|
||||
.actions { display:flex; gap:12px; margin-top:24px; }
|
||||
.loading { text-align:center; padding:60px; color:#8aa0b5; }
|
||||
.loading { text-align:center; padding:60px; color:var(--text-muted); }
|
||||
|
||||
.success-msg {
|
||||
background:#2ecc7130; border:1px solid #2ecc71;
|
||||
color:#2ecc71; padding:12px 16px; border-radius:8px;
|
||||
background:var(--ok)30; border:1px solid var(--ok);
|
||||
color:var(--ok); padding:12px 16px; border-radius:8px;
|
||||
margin-bottom:16px; font-size:14px;
|
||||
}
|
||||
.error-msg {
|
||||
background:#e74c3c30; border:1px solid #e74c3c;
|
||||
color:#e74c3c; padding:12px 16px; border-radius:8px;
|
||||
background:var(--err)30; border:1px solid var(--err);
|
||||
color:var(--err); padding:12px 16px; border-radius:8px;
|
||||
margin-bottom:16px; font-size:14px;
|
||||
}
|
||||
|
||||
.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 var(--border); }
|
||||
|
||||
/* Zonas de entrega — editor con mapa */
|
||||
.zones-layout { display:grid; grid-template-columns:300px minmax(0,1fr); gap:16px; height:calc(100vh - 220px); min-height:520px; }
|
||||
@media (max-width: 1100px) { .zones-layout { grid-template-columns:1fr; height:auto; } }
|
||||
.zones-side { display:flex; flex-direction:column; gap:8px; min-width:0; min-height:0; }
|
||||
.zones-side-header { display:flex; align-items:center; justify-content:space-between; }
|
||||
.zones-side-header h4 { margin:0; font-size:13px; color:var(--text); }
|
||||
.zones-side-header button { padding:6px 10px; font-size:12px; }
|
||||
.zones-list { display:flex; flex-direction:column; gap:6px; flex:1; min-height:120px; max-height:50%; overflow-y:auto; padding-right:4px; }
|
||||
.zone-row {
|
||||
display:flex; align-items:center; gap:10px;
|
||||
padding:10px 12px; border-radius:var(--r-md, 10px);
|
||||
background:var(--panel-2); border:1px solid var(--border);
|
||||
cursor:pointer; transition:border-color .15s, background .15s;
|
||||
}
|
||||
.zone-row:hover { border-color:var(--border-hi); }
|
||||
.zone-row.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||
.zone-row.disabled { opacity:.55; }
|
||||
.zone-swatch { width:14px; height:14px; border-radius:4px; flex-shrink:0; }
|
||||
.zone-row-main { flex:1; min-width:0; display:flex; flex-direction:column; gap:2px; }
|
||||
.zone-row-name { font-size:13px; font-weight:500; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.zone-row-meta { font-size:11px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.zones-empty { padding:24px; text-align:center; color:var(--text-muted); font-size:13px; }
|
||||
.zone-form { padding:14px; background:var(--panel-2); border:1px solid var(--border); border-radius:var(--r-md, 10px); display:flex; flex-direction:column; gap:12px; }
|
||||
.zone-form .row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||
.zone-form .row.three { grid-template-columns:2fr 1fr 1fr; }
|
||||
.zone-form label { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; }
|
||||
.zone-form input { padding:8px 10px; font-size:13px; }
|
||||
.zone-days-pick { display:flex; gap:4px; flex-wrap:wrap; }
|
||||
.zone-day-pick { padding:4px 8px; border-radius:6px; font-size:11px; font-weight:600; cursor:pointer;
|
||||
background:var(--border); color:var(--text-muted); border:1px solid transparent; }
|
||||
.zone-day-pick.active { background:var(--accent); color:var(--text-on-acc, #fff); }
|
||||
.zone-row-actions { display:flex; gap:8px; justify-content:flex-end; }
|
||||
.zone-row-actions button { padding:6px 10px; font-size:12px; }
|
||||
.zone-row-actions .danger { background:var(--err); }
|
||||
.zones-summary { margin-top:8px; padding:10px 12px; background:var(--panel-2); border-radius:var(--r-md, 10px); font-size:12px; color:var(--text-muted); }
|
||||
.zones-summary strong { color:var(--text); }
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
@@ -134,10 +204,12 @@ class SettingsCrud extends HTMLElement {
|
||||
|
||||
try {
|
||||
this.settings = await api.getSettings();
|
||||
// Asegurar que schedule existe
|
||||
if (!this.settings.schedule) {
|
||||
this.settings.schedule = { delivery: {}, pickup: {} };
|
||||
}
|
||||
const dz = this.settings.delivery_zones || {};
|
||||
this.zones = Array.isArray(dz.zones) ? dz.zones.map((z) => ({ ...z })) : [];
|
||||
this.selectedZoneId = this.zones[0]?.id || null;
|
||||
this.loading = false;
|
||||
this.render();
|
||||
} catch (e) {
|
||||
@@ -201,6 +273,117 @@ class SettingsCrud extends HTMLElement {
|
||||
}).join("");
|
||||
}
|
||||
|
||||
ZONE_PALETTE_VARS() {
|
||||
return ["--chart-blue", "--chart-green", "--chart-purple", "--chart-orange", "--chart-pink", "--chart-gray"];
|
||||
}
|
||||
|
||||
zoneSwatchColor(idx) {
|
||||
const palette = this.ZONE_PALETTE_VARS();
|
||||
return `var(${palette[idx % palette.length]})`;
|
||||
}
|
||||
|
||||
formatDaysShort(days) {
|
||||
if (!Array.isArray(days) || !days.length) return "\u2014";
|
||||
const order = ["lun","mar","mie","jue","vie","sab","dom"];
|
||||
const idx = days.map((d) => order.indexOf(d)).filter((i) => i >= 0).sort((a, b) => a - b);
|
||||
if (idx.length >= 3 && idx.every((v, i, arr) => i === 0 || v === arr[i - 1] + 1)) {
|
||||
return `${order[idx[0]]}-${order[idx[idx.length - 1]]}`;
|
||||
}
|
||||
return idx.map((i) => order[i]).join("/");
|
||||
}
|
||||
|
||||
renderZonesList() {
|
||||
if (!this.zones.length) {
|
||||
return `<div class="zones-empty">No hay zonas dibujadas todavía. Tocá <strong>Crear zona</strong> y dibujá un polígono en el mapa.</div>`;
|
||||
}
|
||||
return this.zones.map((z, i) => {
|
||||
const cost = z.delivery_cost != null ? `$${Number(z.delivery_cost).toLocaleString("es-AR")}` : "";
|
||||
const days = this.formatDaysShort(z.delivery_days);
|
||||
const start = z.delivery_hours?.start?.slice(0, 5) || "";
|
||||
const end = z.delivery_hours?.end?.slice(0, 5) || "";
|
||||
const hours = start && end ? `${start}-${end}` : "";
|
||||
const meta = [cost, days, hours].filter(Boolean).join(" · ");
|
||||
const active = z.id === this.selectedZoneId ? "active" : "";
|
||||
const disabled = z.enabled === false ? "disabled" : "";
|
||||
return `
|
||||
<div class="zone-row ${active} ${disabled}" data-zone-id="${z.id}">
|
||||
<div class="zone-swatch" style="background:${this.zoneSwatchColor(i)}"></div>
|
||||
<div class="zone-row-main">
|
||||
<div class="zone-row-name">${this.escapeHtml(z.name || z.id)}</div>
|
||||
<div class="zone-row-meta">${this.escapeHtml(meta || "sin configurar")}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
renderZoneForm() {
|
||||
const z = this.zones.find((x) => x.id === this.selectedZoneId);
|
||||
if (!z) {
|
||||
return `<div class="zones-empty">Seleccioná una zona en la lista o dibujá una nueva en el mapa para configurarla.</div>`;
|
||||
}
|
||||
const days = z.delivery_days || [];
|
||||
const start = z.delivery_hours?.start?.slice(0, 5) || "10:00";
|
||||
const end = z.delivery_hours?.end?.slice(0, 5) || "20:00";
|
||||
return `
|
||||
<div class="zone-form" data-zone-id="${z.id}">
|
||||
<div class="row three">
|
||||
<div class="field">
|
||||
<label>Nombre</label>
|
||||
<input type="text" id="zoneName" value="${this.escapeHtml(z.name || "")}" maxlength="60" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Costo de envío ($)</label>
|
||||
<input type="number" id="zoneCost" value="${z.delivery_cost ?? 0}" min="0" step="100" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Mín. pedido ($)</label>
|
||||
<input type="number" id="zoneMin" value="${z.min_order_amount ?? 0}" min="0" step="100" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Días de entrega</label>
|
||||
<div class="zone-days-pick">
|
||||
${DAYS.map((d) => `
|
||||
<span class="zone-day-pick ${days.includes(d.id) ? "active" : ""}" data-day="${d.id}" title="${d.label}">${d.short}</span>
|
||||
`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label>Hora de inicio</label>
|
||||
<input type="time" id="zoneStart" value="${start}" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Hora de fin</label>
|
||||
<input type="time" id="zoneEnd" value="${end}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label>Estado</label>
|
||||
<select id="zoneEnabled">
|
||||
<option value="true" ${z.enabled !== false ? "selected" : ""}>Habilitada</option>
|
||||
<option value="false" ${z.enabled === false ? "selected" : ""}>Deshabilitada (no recibe pedidos)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="zone-row-actions" style="align-self:end;">
|
||||
<button class="secondary" id="zoneFitBtn">Centrar en mapa</button>
|
||||
<button class="danger" id="zoneDeleteBtn">Eliminar zona</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderZonesSummary() {
|
||||
const enabled = this.zones.filter((z) => z.enabled !== false);
|
||||
if (!this.zones.length) {
|
||||
return `<div class="zones-summary">Dibujá al menos una zona en el mapa para que el bot pueda confirmar envíos. Sin zonas, todas las direcciones quedan sin validar.</div>`;
|
||||
}
|
||||
return `<div class="zones-summary"><strong>${enabled.length}</strong> de <strong>${this.zones.length}</strong> zona${this.zones.length > 1 ? "s" : ""} habilitada${enabled.length === 1 ? "" : "s"}.</div>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = this.shadowRoot.getElementById("content");
|
||||
|
||||
@@ -217,82 +400,89 @@ class SettingsCrud extends HTMLElement {
|
||||
const s = this.settings;
|
||||
|
||||
content.innerHTML = `
|
||||
<!-- Info del Negocio -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
|
||||
Información del Negocio
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label>Nombre del negocio</label>
|
||||
<input type="text" id="storeName" value="${this.escapeHtml(s.store_name || "")}" placeholder="Ej: Carnicería Don Pedro" />
|
||||
<div class="field-hint">Se usa en los mensajes del bot</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Nombre del bot</label>
|
||||
<input type="text" id="botName" value="${this.escapeHtml(s.bot_name || "")}" placeholder="Ej: Piaf" />
|
||||
<div class="field-hint">El asistente virtual</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label>Dirección</label>
|
||||
<input type="text" id="storeAddress" value="${this.escapeHtml(s.store_address || "")}" placeholder="Ej: Av. Corrientes 1234, CABA" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Teléfono</label>
|
||||
<input type="text" id="storePhone" value="${this.escapeHtml(s.store_phone || "")}" placeholder="Ej: +54 11 1234-5678" />
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<h2 class="toolbar-title">Configuración</h2>
|
||||
<div class="toolbar-actions">
|
||||
<button id="resetBtn" class="secondary" type="button">Restaurar</button>
|
||||
<button id="saveBtn" type="button" ${this.saving ? "disabled" : ""}>
|
||||
${this.saving ? "Guardando..." : "Guardar"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delivery -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">
|
||||
<svg viewBox="0 0 24 24"><path d="M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z"/></svg>
|
||||
Delivery (Envío a domicilio)
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<div class="toggle ${s.delivery_enabled ? "active" : ""}" id="deliveryToggle"></div>
|
||||
<span class="toggle-label">Delivery habilitado</span>
|
||||
</div>
|
||||
|
||||
<div class="schedule-grid" id="deliverySchedule">
|
||||
${this.renderScheduleGrid("delivery", s.delivery_enabled)}
|
||||
</div>
|
||||
|
||||
<div class="min-order-field">
|
||||
<div class="field">
|
||||
<label>Pedido mínimo para delivery ($)</label>
|
||||
<input type="number" id="deliveryMinOrder" value="${s.delivery_min_order || 0}" min="0" step="100" style="width:150px;" />
|
||||
<div class="settings-grid">
|
||||
<div class="col">
|
||||
<div class="panel">
|
||||
<div class="panel-title">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
|
||||
Información del Negocio
|
||||
</div>
|
||||
<div class="field" style="margin-bottom:12px;">
|
||||
<label>Nombre del negocio</label>
|
||||
<input type="text" id="storeName" value="${this.escapeHtml(s.store_name || "")}" placeholder="Ej: Carnicería Don Pedro" />
|
||||
<div class="field-hint">Se usa en los mensajes del bot</div>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom:12px;">
|
||||
<label>Nombre del bot</label>
|
||||
<input type="text" id="botName" value="${this.escapeHtml(s.bot_name || "")}" placeholder="Ej: Piaf" />
|
||||
<div class="field-hint">El asistente virtual</div>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom:12px;">
|
||||
<label>Dirección</label>
|
||||
<input type="text" id="storeAddress" value="${this.escapeHtml(s.store_address || "")}" placeholder="Av. Corrientes 1234, CABA" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Teléfono</label>
|
||||
<input type="text" id="storePhone" value="${this.escapeHtml(s.store_phone || "")}" placeholder="+54 11 1234-5678" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-title">
|
||||
<svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14l-5-5 1.41-1.41L12 14.17l4.59-4.58L18 11l-6 6z"/></svg>
|
||||
Retiro en Tienda
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<div class="toggle ${s.pickup_enabled ? "active" : ""}" id="pickupToggle"></div>
|
||||
<span class="toggle-label">Retiro habilitado</span>
|
||||
</div>
|
||||
<div class="schedule-grid" id="pickupSchedule">
|
||||
${this.renderScheduleGrid("pickup", s.pickup_enabled)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Retiro en tienda -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">
|
||||
<svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14l-5-5 1.41-1.41L12 14.17l4.59-4.58L18 11l-6 6z"/></svg>
|
||||
Retiro en Tienda
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<div class="toggle ${s.pickup_enabled ? "active" : ""}" id="pickupToggle"></div>
|
||||
<span class="toggle-label">Retiro en tienda habilitado</span>
|
||||
</div>
|
||||
|
||||
<div class="schedule-grid" id="pickupSchedule">
|
||||
${this.renderScheduleGrid("pickup", s.pickup_enabled)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="panel panel-zones">
|
||||
<div class="panel-title" style="margin-bottom:6px;">
|
||||
<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
|
||||
</div>
|
||||
<div class="field-hint" style="margin-bottom:12px;">
|
||||
Dibujá los polígonos en el mapa. Cada zona tiene su costo, días y rango horario.
|
||||
El bot matchea con la ubicación que el cliente comparta por WhatsApp.
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="saveBtn" ${this.saving ? "disabled" : ""}>${this.saving ? "Guardando..." : "Guardar Configuración"}</button>
|
||||
<button id="resetBtn" class="secondary">Restaurar</button>
|
||||
<div class="zones-layout">
|
||||
<div class="zones-side">
|
||||
<div class="zones-side-header">
|
||||
<h4>Zonas</h4>
|
||||
<button id="zoneCreateBtn" type="button">+ Crear zona</button>
|
||||
</div>
|
||||
<div class="zones-list" id="zonesList">
|
||||
${this.renderZonesList()}
|
||||
</div>
|
||||
<div id="zoneFormSlot">
|
||||
${this.renderZoneForm()}
|
||||
</div>
|
||||
${this.renderZonesSummary()}
|
||||
</div>
|
||||
<div class="zones-map-wrap">
|
||||
<zone-map-editor id="zoneMapEditor"></zone-map-editor>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -300,13 +490,6 @@ class SettingsCrud extends HTMLElement {
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Toggle delivery
|
||||
const deliveryToggle = this.shadowRoot.getElementById("deliveryToggle");
|
||||
deliveryToggle?.addEventListener("click", () => {
|
||||
this.settings.delivery_enabled = !this.settings.delivery_enabled;
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Toggle pickup
|
||||
const pickupToggle = this.shadowRoot.getElementById("pickupToggle");
|
||||
pickupToggle?.addEventListener("click", () => {
|
||||
@@ -359,42 +542,183 @@ class SettingsCrud extends HTMLElement {
|
||||
|
||||
// Reset button
|
||||
this.shadowRoot.getElementById("resetBtn")?.addEventListener("click", () => this.load());
|
||||
|
||||
this.setupZoneEditor();
|
||||
}
|
||||
|
||||
setupZoneEditor() {
|
||||
const editor = this.shadowRoot.getElementById("zoneMapEditor");
|
||||
if (!editor) return;
|
||||
this._mapEditor = editor;
|
||||
|
||||
editor.zones = this.zones;
|
||||
if (this.selectedZoneId) editor.selectedId = this.selectedZoneId;
|
||||
|
||||
editor.addEventListener("change", (e) => {
|
||||
const next = e.detail.zones || [];
|
||||
const merged = [];
|
||||
for (const z of next) {
|
||||
const existing = this.zones.find((x) => x.id === z.id);
|
||||
if (existing) merged.push({ ...existing, polygon: z.polygon });
|
||||
else merged.push(z);
|
||||
}
|
||||
this.zones = merged;
|
||||
this.refreshZonesPanel();
|
||||
});
|
||||
|
||||
editor.addEventListener("select", (e) => {
|
||||
this.selectedZoneId = e.detail.id || null;
|
||||
this.refreshZonesPanel();
|
||||
});
|
||||
|
||||
this.shadowRoot.getElementById("zoneCreateBtn")?.addEventListener("click", () => {
|
||||
this._mapEditor?.startDrawing();
|
||||
});
|
||||
|
||||
this.attachZoneSideListeners();
|
||||
}
|
||||
|
||||
attachZoneSideListeners() {
|
||||
this.shadowRoot.querySelectorAll(".zone-row[data-zone-id]").forEach((row) => {
|
||||
row.addEventListener("click", () => {
|
||||
const id = row.dataset.zoneId;
|
||||
this.selectedZoneId = id;
|
||||
if (this._mapEditor) this._mapEditor.selectedId = id;
|
||||
this.refreshZonesPanel();
|
||||
});
|
||||
});
|
||||
|
||||
const z = this.zones.find((x) => x.id === this.selectedZoneId);
|
||||
if (!z) return;
|
||||
|
||||
const onChange = () => this.refreshZonesList();
|
||||
|
||||
const nameEl = this.shadowRoot.getElementById("zoneName");
|
||||
nameEl?.addEventListener("input", () => { z.name = nameEl.value; onChange(); });
|
||||
|
||||
const costEl = this.shadowRoot.getElementById("zoneCost");
|
||||
costEl?.addEventListener("change", () => { z.delivery_cost = Number(costEl.value) || 0; onChange(); });
|
||||
|
||||
const minEl = this.shadowRoot.getElementById("zoneMin");
|
||||
minEl?.addEventListener("change", () => { z.min_order_amount = Number(minEl.value) || 0; });
|
||||
|
||||
const startEl = this.shadowRoot.getElementById("zoneStart");
|
||||
startEl?.addEventListener("change", () => {
|
||||
z.delivery_hours = { ...(z.delivery_hours || {}), start: startEl.value };
|
||||
onChange();
|
||||
});
|
||||
const endEl = this.shadowRoot.getElementById("zoneEnd");
|
||||
endEl?.addEventListener("change", () => {
|
||||
z.delivery_hours = { ...(z.delivery_hours || {}), end: endEl.value };
|
||||
onChange();
|
||||
});
|
||||
|
||||
const enabledEl = this.shadowRoot.getElementById("zoneEnabled");
|
||||
enabledEl?.addEventListener("change", () => {
|
||||
z.enabled = enabledEl.value === "true";
|
||||
onChange();
|
||||
});
|
||||
|
||||
this.shadowRoot.querySelectorAll(".zone-day-pick").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const day = btn.dataset.day;
|
||||
const days = z.delivery_days || [];
|
||||
const idx = days.indexOf(day);
|
||||
if (idx >= 0) days.splice(idx, 1);
|
||||
else days.push(day);
|
||||
z.delivery_days = days;
|
||||
btn.classList.toggle("active", days.includes(day));
|
||||
onChange();
|
||||
});
|
||||
});
|
||||
|
||||
this.shadowRoot.getElementById("zoneFitBtn")?.addEventListener("click", () => {
|
||||
if (this._mapEditor) this._mapEditor.selectedId = z.id;
|
||||
});
|
||||
|
||||
this.shadowRoot.getElementById("zoneDeleteBtn")?.addEventListener("click", () => {
|
||||
if (this._mapEditor) this._mapEditor.removeZone(z.id);
|
||||
this.zones = this.zones.filter((x) => x.id !== z.id);
|
||||
if (this.selectedZoneId === z.id) this.selectedZoneId = this.zones[0]?.id || null;
|
||||
this.refreshZonesPanel();
|
||||
});
|
||||
}
|
||||
|
||||
refreshZonesPanel() {
|
||||
const list = this.shadowRoot.getElementById("zonesList");
|
||||
const formSlot = this.shadowRoot.getElementById("zoneFormSlot");
|
||||
if (list) list.innerHTML = this.renderZonesList();
|
||||
if (formSlot) formSlot.innerHTML = this.renderZoneForm();
|
||||
this.attachZoneSideListeners();
|
||||
}
|
||||
|
||||
refreshZonesList() {
|
||||
const list = this.shadowRoot.getElementById("zonesList");
|
||||
if (list) list.innerHTML = this.renderZonesList();
|
||||
this.shadowRoot.querySelectorAll(".zone-row[data-zone-id]").forEach((row) => {
|
||||
row.addEventListener("click", () => {
|
||||
const id = row.dataset.zoneId;
|
||||
this.selectedZoneId = id;
|
||||
if (this._mapEditor) this._mapEditor.selectedId = id;
|
||||
this.refreshZonesPanel();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
collectScheduleFromInputs() {
|
||||
const schedule = { delivery: {}, pickup: {} };
|
||||
|
||||
for (const type of ["delivery", "pickup"]) {
|
||||
this.shadowRoot.querySelectorAll(`.hour-start[data-type="${type}"]`).forEach(input => {
|
||||
const day = input.dataset.day;
|
||||
const endInput = this.shadowRoot.querySelector(`.hour-end[data-type="${type}"][data-day="${day}"]`);
|
||||
const toggle = this.shadowRoot.querySelector(`.day-toggle[data-type="${type}"][data-day="${day}"]`);
|
||||
|
||||
if (toggle?.classList.contains("active")) {
|
||||
schedule[type][day] = {
|
||||
start: input.value.trim() || (type === "delivery" ? "09:00" : "08:00"),
|
||||
end: endInput?.value.trim() || (type === "delivery" ? "18:00" : "20:00"),
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sólo pickup: el horario de delivery vive ahora dentro de cada zona.
|
||||
const schedule = { pickup: {} };
|
||||
this.shadowRoot.querySelectorAll(`.hour-start[data-type="pickup"]`).forEach((input) => {
|
||||
const day = input.dataset.day;
|
||||
const endInput = this.shadowRoot.querySelector(`.hour-end[data-type="pickup"][data-day="${day}"]`);
|
||||
const toggle = this.shadowRoot.querySelector(`.day-toggle[data-type="pickup"][data-day="${day}"]`);
|
||||
if (toggle?.classList.contains("active")) {
|
||||
schedule.pickup[day] = {
|
||||
start: input.value.trim() || "08:00",
|
||||
end: endInput?.value.trim() || "20:00",
|
||||
};
|
||||
}
|
||||
});
|
||||
return schedule;
|
||||
}
|
||||
|
||||
async save() {
|
||||
// Collect schedule from inputs
|
||||
const schedule = this.collectScheduleFromInputs();
|
||||
|
||||
// Antes de serializar, refrescar polígonos desde el editor por si el
|
||||
// usuario editó vértices y no llegó a disparar otro evento change.
|
||||
if (this._mapEditor) {
|
||||
const live = this._mapEditor.zones;
|
||||
this.zones = this.zones.map((z) => {
|
||||
const fromMap = live.find((x) => x.id === z.id);
|
||||
return fromMap ? { ...z, polygon: fromMap.polygon } : z;
|
||||
});
|
||||
}
|
||||
|
||||
const cleanZones = this.zones
|
||||
.filter((z) => z.polygon && Array.isArray(z.polygon.coordinates))
|
||||
.map((z) => ({
|
||||
id: z.id,
|
||||
name: z.name || z.id,
|
||||
polygon: z.polygon,
|
||||
delivery_cost: Number(z.delivery_cost) || 0,
|
||||
delivery_days: Array.isArray(z.delivery_days) ? z.delivery_days : [],
|
||||
delivery_hours: z.delivery_hours || { start: "10:00", end: "20:00" },
|
||||
min_order_amount: Number(z.min_order_amount) || 0,
|
||||
enabled: z.enabled !== false,
|
||||
}));
|
||||
|
||||
const delivery_zones = { zones: cleanZones };
|
||||
|
||||
const data = {
|
||||
store_name: this.shadowRoot.getElementById("storeName")?.value || "",
|
||||
bot_name: this.shadowRoot.getElementById("botName")?.value || "",
|
||||
store_address: this.shadowRoot.getElementById("storeAddress")?.value || "",
|
||||
store_phone: this.shadowRoot.getElementById("storePhone")?.value || "",
|
||||
delivery_enabled: this.settings.delivery_enabled,
|
||||
pickup_enabled: this.settings.pickup_enabled,
|
||||
delivery_min_order: parseFloat(this.shadowRoot.getElementById("deliveryMinOrder")?.value) || 0,
|
||||
schedule,
|
||||
delivery_zones,
|
||||
};
|
||||
|
||||
// Update settings with form values
|
||||
|
||||
@@ -18,87 +18,87 @@ class TakeoversCrud extends HTMLElement {
|
||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||
.container { display:grid; grid-template-columns:350px 1fr; gap:16px; height:100%; }
|
||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; display:flex; align-items:center; gap:8px; }
|
||||
.panel-title .badge { background:#e74c3c; color:#fff; padding:2px 8px; border-radius:10px; font-size:11px; }
|
||||
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; display:flex; align-items:center; gap:8px; }
|
||||
.panel-title .badge { background:var(--err); color:#fff; padding:2px 8px; border-radius:10px; font-size:11px; }
|
||||
|
||||
input, select, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
||||
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
|
||||
input, select, textarea { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
||||
input:focus, select:focus, textarea:focus { outline:none; border-color:var(--accent); }
|
||||
textarea { resize:vertical; min-height:100px; }
|
||||
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||
button:hover { background:#1a5fd0; }
|
||||
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||
button:hover { background:var(--accent-hover); }
|
||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||
button.secondary { background:#253245; }
|
||||
button.secondary:hover { background:#2d3e52; }
|
||||
button.danger { background:#e74c3c; }
|
||||
button.danger:hover { background:#c0392b; }
|
||||
button.secondary { background:var(--border-hi); }
|
||||
button.secondary:hover { background:var(--border-hi); }
|
||||
button.danger { background:var(--err); }
|
||||
button.danger:hover { background:var(--err); }
|
||||
|
||||
.list { flex:1; overflow-y:auto; }
|
||||
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
||||
.item:hover { border-color:#1f6feb; }
|
||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
||||
.item-query { font-weight:600; color:#f39c12; margin-bottom:4px; font-size:14px; }
|
||||
.item-reason { font-size:12px; color:#8aa0b5; margin-bottom:4px; }
|
||||
.item-time { font-size:11px; color:#6c7a89; }
|
||||
.item-chat { font-size:11px; color:#1f6feb; }
|
||||
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
||||
.item:hover { border-color:var(--accent); }
|
||||
.item.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||
.item-query { font-weight:600; color:var(--warn); margin-bottom:4px; font-size:14px; }
|
||||
.item-reason { font-size:12px; color:var(--text-muted); margin-bottom:4px; }
|
||||
.item-time { font-size:11px; color:var(--text-muted); }
|
||||
.item-chat { font-size:11px; color:var(--accent); }
|
||||
|
||||
.form { flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:16px; }
|
||||
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
||||
.form-empty { color:var(--text-muted); text-align:center; padding:40px; }
|
||||
.field { }
|
||||
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
|
||||
.actions { display:flex; gap:8px; margin-top:16px; }
|
||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
||||
.loading { text-align:center; padding:40px; color:var(--text-muted); }
|
||||
|
||||
.conversation-history { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; max-height:200px; overflow-y:auto; }
|
||||
.conversation-history { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; max-height:200px; overflow-y:auto; }
|
||||
.msg { margin-bottom:8px; padding:8px; border-radius:6px; font-size:12px; }
|
||||
.msg.user { background:#1a2a3a; border-left:3px solid #1f6feb; }
|
||||
.msg.assistant { background:#1a2535; border-left:3px solid #2ecc71; }
|
||||
.msg-role { font-size:10px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; }
|
||||
.msg-content { color:#e7eef7; white-space:pre-wrap; }
|
||||
.msg.user { background:var(--accent-soft); border-left:3px solid var(--accent); }
|
||||
.msg.assistant { background:var(--panel-2); border-left:3px solid var(--ok); }
|
||||
.msg-role { font-size:10px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; }
|
||||
.msg-content { color:var(--text); white-space:pre-wrap; }
|
||||
|
||||
.query-highlight { background:#f39c1230; border:1px solid #f39c12; border-radius:8px; padding:12px; margin-bottom:16px; }
|
||||
.query-highlight label { color:#f39c12; }
|
||||
.query-highlight .query { font-size:16px; font-weight:600; color:#f39c12; margin-top:4px; }
|
||||
.query-highlight { background:var(--warn)30; border:1px solid var(--warn); border-radius:8px; padding:12px; margin-bottom:16px; }
|
||||
.query-highlight label { color:var(--warn); }
|
||||
.query-highlight .query { font-size:16px; font-weight:600; color:var(--warn); margin-top:4px; }
|
||||
|
||||
.alias-section { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-top:12px; }
|
||||
.alias-section h4 { margin:0 0 12px; font-size:13px; color:#8aa0b5; }
|
||||
.alias-section { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-top:12px; }
|
||||
.alias-section h4 { margin:0 0 12px; font-size:13px; color:var(--text-muted); }
|
||||
.checkbox-row { display:flex; align-items:center; gap:8px; margin-bottom:12px; }
|
||||
.checkbox-row input[type="checkbox"] { width:auto; }
|
||||
.checkbox-row label { font-size:13px; color:#e7eef7; text-transform:none; }
|
||||
.checkbox-row label { font-size:13px; color:var(--text); text-transform:none; }
|
||||
|
||||
.product-selector { position:relative; }
|
||||
.product-dropdown {
|
||||
position:absolute; top:100%; left:0; right:0; z-index:100;
|
||||
background:#0f1520; border:1px solid #253245; border-radius:8px;
|
||||
background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px;
|
||||
max-height:200px; overflow-y:auto; display:none;
|
||||
}
|
||||
.product-dropdown.open { display:block; }
|
||||
.product-option {
|
||||
padding:8px 12px; cursor:pointer; font-size:13px; color:#e7eef7;
|
||||
padding:8px 12px; cursor:pointer; font-size:13px; color:var(--text);
|
||||
display:flex; justify-content:space-between; align-items:center;
|
||||
}
|
||||
.product-option:hover { background:#1a2535; }
|
||||
.product-option .price { font-size:11px; color:#8aa0b5; }
|
||||
.product-option:hover { background:var(--panel-2); }
|
||||
.product-option .price { font-size:11px; color:var(--text-muted); }
|
||||
|
||||
.cart-section { background:#0d2818; border:1px solid #2ecc71; border-radius:8px; padding:12px; margin-bottom:12px; }
|
||||
.cart-section h4 { margin:0 0 12px; font-size:13px; color:#2ecc71; display:flex; align-items:center; gap:8px; }
|
||||
.cart-section h4 svg { width:16px; height:16px; fill:#2ecc71; }
|
||||
.cart-section { background:var(--ok-soft); border:1px solid var(--ok); border-radius:8px; padding:12px; margin-bottom:12px; }
|
||||
.cart-section h4 { margin:0 0 12px; font-size:13px; color:var(--ok); display:flex; align-items:center; gap:8px; }
|
||||
.cart-section h4 svg { width:16px; height:16px; fill:var(--ok); }
|
||||
.cart-items-list { margin-bottom:12px; }
|
||||
.cart-item-row { display:flex; align-items:center; gap:8px; padding:8px; background:#0f1520; border-radius:6px; margin-bottom:6px; }
|
||||
.cart-item-row .name { flex:1; font-size:13px; color:#e7eef7; }
|
||||
.cart-item-row { display:flex; align-items:center; gap:8px; padding:8px; background:var(--panel-2); border-radius:6px; margin-bottom:6px; }
|
||||
.cart-item-row .name { flex:1; font-size:13px; color:var(--text); }
|
||||
.cart-item-row .qty { width:60px; text-align:center; }
|
||||
.cart-item-row .unit-select { width:80px; }
|
||||
.cart-item-row .remove-btn { background:#e74c3c; color:#fff; border:none; border-radius:4px; padding:4px 8px; cursor:pointer; font-size:11px; }
|
||||
.cart-item-row .remove-btn:hover { background:#c0392b; }
|
||||
.cart-item-row .remove-btn { background:var(--err); color:#fff; border:none; border-radius:4px; padding:4px 8px; cursor:pointer; font-size:11px; }
|
||||
.cart-item-row .remove-btn:hover { background:var(--err); }
|
||||
.add-cart-row { display:flex; gap:8px; align-items:flex-end; }
|
||||
.add-cart-row .product-selector { flex:1; }
|
||||
.add-cart-row .qty-input { width:70px; }
|
||||
.add-cart-row .unit-select { width:80px; }
|
||||
.add-cart-row button { white-space:nowrap; }
|
||||
|
||||
.no-pending { text-align:center; padding:60px 20px; color:#2ecc71; }
|
||||
.no-pending svg { width:48px; height:48px; fill:#2ecc71; margin-bottom:16px; }
|
||||
.no-pending { text-align:center; padding:60px 20px; color:var(--ok); }
|
||||
.no-pending svg { width:48px; height:48px; fill:var(--ok); margin-bottom:16px; }
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
@@ -349,7 +349,7 @@ class TakeoversCrud extends HTMLElement {
|
||||
if (!container) return;
|
||||
|
||||
if (this.cartItemsToAdd.length === 0) {
|
||||
container.innerHTML = `<div style="font-size:12px;color:#8aa0b5;padding:8px;">Sin items agregados</div>`;
|
||||
container.innerHTML = `<div style="font-size:12px;color:var(--text-muted);padding:8px;">Sin items agregados</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,534 +0,0 @@
|
||||
import { api } from "../lib/api.js";
|
||||
import { modal } from "../lib/modal.js";
|
||||
|
||||
// Datos aleatorios para generar usuarios de prueba
|
||||
const NOMBRES = ["Juan", "María", "Carlos", "Ana", "Pedro", "Laura", "Diego", "Sofía"];
|
||||
const APELLIDOS = ["García", "Rodríguez", "Martínez", "López", "González", "Fernández", "Pérez"];
|
||||
const CALLES = ["Av. Corrientes", "Av. Santa Fe", "Calle Florida", "Av. Rivadavia", "Av. Cabildo", "Av. Libertador"];
|
||||
|
||||
function randomItem(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
function randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function generateTestUser() {
|
||||
const randomPhone = `549${randomInt(1000000000, 9999999999)}`;
|
||||
const wa_chat_id = `${randomPhone}@s.whatsapp.net`;
|
||||
const nombre = randomItem(NOMBRES);
|
||||
const apellido = randomItem(APELLIDOS);
|
||||
|
||||
return {
|
||||
wa_chat_id,
|
||||
phone: randomPhone,
|
||||
name: `${nombre} ${apellido}`,
|
||||
address: {
|
||||
first_name: nombre,
|
||||
last_name: apellido,
|
||||
address_1: `${randomItem(CALLES)} ${randomInt(100, 9000)}`,
|
||||
city: "CABA",
|
||||
state: "Buenos Aires",
|
||||
postcode: `${randomInt(1000, 1999)}`,
|
||||
country: "AR",
|
||||
phone: randomPhone,
|
||||
email: `${randomPhone}@no-email.local`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class TestPanel extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.products = [];
|
||||
this.selectedProducts = [];
|
||||
this.testUser = null;
|
||||
this.lastOrder = null;
|
||||
this.lastPaymentLink = null;
|
||||
this.loading = false;
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
--bg: #0b0f14;
|
||||
--panel: #121823;
|
||||
--muted: #8aa0b5;
|
||||
--text: #e7eef7;
|
||||
--line: #1e2a3a;
|
||||
--blue: #1f6feb;
|
||||
--green: #238636;
|
||||
--red: #da3633;
|
||||
}
|
||||
* { box-sizing: border-box; font-family: system-ui, Segoe UI, Roboto, Arial; }
|
||||
.container {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--blue);
|
||||
}
|
||||
button {
|
||||
background: var(--blue);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
button:hover { opacity: 0.9; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
button.secondary:hover { border-color: var(--blue); color: var(--text); }
|
||||
button.success { background: var(--green); }
|
||||
input, select {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--blue);
|
||||
}
|
||||
.product-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.product-item {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 60px 30px;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.product-item:last-child { border-bottom: none; }
|
||||
.product-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.product-qty { text-align: right; }
|
||||
.product-unit { color: var(--muted); }
|
||||
.remove-btn {
|
||||
background: var(--red);
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.user-info {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.user-info div { margin-bottom: 4px; }
|
||||
.user-info span { color: var(--muted); }
|
||||
.result {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.result.success { border-color: var(--green); }
|
||||
.result.error { border-color: var(--red); }
|
||||
.result-label { color: var(--muted); font-size: 10px; text-transform: uppercase; }
|
||||
.result-value { font-weight: 600; margin-top: 4px; }
|
||||
.result-value.big { font-size: 18px; }
|
||||
.result-link {
|
||||
color: var(--blue);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
word-break: break-all;
|
||||
}
|
||||
.row { display: flex; gap: 8px; align-items: center; }
|
||||
.flex-1 { flex: 1; }
|
||||
.loading { opacity: 0.5; pointer-events: none; }
|
||||
.empty { color: var(--muted); font-size: 12px; text-align: center; padding: 20px; }
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<div class="panel-title">1. Generar Orden de Prueba</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="row">
|
||||
<button id="btnGenerate">Generar Orden Aleatoria</button>
|
||||
<button id="btnClear" class="secondary">Limpiar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Productos seleccionados</div>
|
||||
<div class="product-list" id="productList">
|
||||
<div class="empty">Click "Generar Orden Aleatoria" para comenzar</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Datos del usuario</div>
|
||||
<div class="user-info" id="userInfo">
|
||||
<div class="empty">Se generarán automáticamente</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<button id="btnCreateOrder" class="success" disabled>Crear Orden en WooCommerce</button>
|
||||
</div>
|
||||
|
||||
<div class="section" id="orderResult" style="display:none;">
|
||||
<div class="result success">
|
||||
<div class="result-label">Orden creada</div>
|
||||
<div class="result-value big" id="orderIdValue">—</div>
|
||||
<div style="margin-top:8px;">
|
||||
<span class="result-label">Total:</span>
|
||||
<span id="orderTotalValue">—</span>
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.shadowRoot.getElementById("btnGenerate").onclick = () => this.generateRandomOrder();
|
||||
this.shadowRoot.getElementById("btnClear").onclick = () => this.clearAll();
|
||||
this.shadowRoot.getElementById("btnCreateOrder").onclick = () => this.createOrder();
|
||||
this.shadowRoot.getElementById("btnPaymentLink").onclick = () => this.createPaymentLink();
|
||||
this.shadowRoot.getElementById("btnSimulateWebhook").onclick = () => this.simulateWebhook();
|
||||
|
||||
this.loadProducts();
|
||||
}
|
||||
|
||||
async loadProducts() {
|
||||
try {
|
||||
const result = await api.getProductsWithStock();
|
||||
this.products = result.items || [];
|
||||
console.log(`[test-panel] Loaded ${this.products.length} products with stock`);
|
||||
} catch (e) {
|
||||
console.error("[test-panel] Error loading products:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async generateRandomOrder() {
|
||||
if (this.products.length === 0) {
|
||||
await this.loadProducts();
|
||||
}
|
||||
|
||||
if (this.products.length === 0) {
|
||||
modal.warn("No hay productos con stock disponible");
|
||||
return;
|
||||
}
|
||||
|
||||
// Generar usuario aleatorio
|
||||
this.testUser = generateTestUser();
|
||||
|
||||
// Seleccionar 1-3 productos aleatorios
|
||||
const numProducts = randomInt(1, Math.min(3, this.products.length));
|
||||
const shuffled = [...this.products].sort(() => Math.random() - 0.5);
|
||||
this.selectedProducts = [];
|
||||
|
||||
for (let i = 0; i < numProducts; i++) {
|
||||
const product = shuffled[i];
|
||||
const isKg = product.sell_unit === "kg";
|
||||
const quantity = isKg ? randomInt(200, 2000) : randomInt(1, 5);
|
||||
|
||||
this.selectedProducts.push({
|
||||
product_id: product.woo_product_id,
|
||||
name: product.name,
|
||||
quantity,
|
||||
unit: isKg ? "kg" : "unit",
|
||||
price: product.price,
|
||||
});
|
||||
}
|
||||
|
||||
this.renderProductList();
|
||||
this.renderUserInfo();
|
||||
this.updateButtonStates();
|
||||
}
|
||||
|
||||
renderProductList() {
|
||||
const container = this.shadowRoot.getElementById("productList");
|
||||
|
||||
if (this.selectedProducts.length === 0) {
|
||||
container.innerHTML = `<div class="empty">Click "Generar Orden Aleatoria" para comenzar</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = this.selectedProducts.map((p, i) => `
|
||||
<div class="product-item">
|
||||
<div class="product-name" title="${p.name}">${p.name}</div>
|
||||
<div class="product-qty">${p.unit === "kg" ? `${p.quantity}g` : `${p.quantity}u`}</div>
|
||||
<div class="product-unit">$${Number(p.price || 0).toFixed(0)}</div>
|
||||
<button class="remove-btn" data-index="${i}">X</button>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
container.querySelectorAll(".remove-btn").forEach(btn => {
|
||||
btn.onclick = (e) => {
|
||||
const idx = parseInt(e.target.dataset.index);
|
||||
this.selectedProducts.splice(idx, 1);
|
||||
this.renderProductList();
|
||||
this.updateButtonStates();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
renderUserInfo() {
|
||||
const container = this.shadowRoot.getElementById("userInfo");
|
||||
|
||||
if (!this.testUser) {
|
||||
container.innerHTML = `<div class="empty">Se generarán automáticamente</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const addr = this.testUser.address;
|
||||
container.innerHTML = `
|
||||
<div><span>Nombre:</span> ${addr.first_name} ${addr.last_name}</div>
|
||||
<div><span>Dirección:</span> ${addr.address_1}</div>
|
||||
<div><span>Ciudad:</span> ${addr.city}, ${addr.state}</div>
|
||||
<div><span>Teléfono:</span> ${addr.phone}</div>
|
||||
<div><span>Email:</span> ${addr.email}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
updateButtonStates() {
|
||||
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("btnPaymentLink").disabled = !hasOrder;
|
||||
this.shadowRoot.getElementById("btnSimulateWebhook").disabled = !hasOrder;
|
||||
|
||||
if (hasOrder) {
|
||||
this.shadowRoot.getElementById("inputAmount").value = this.lastOrder.total || "";
|
||||
}
|
||||
}
|
||||
|
||||
async createOrder() {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
|
||||
const btn = this.shadowRoot.getElementById("btnCreateOrder");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Creando...";
|
||||
|
||||
try {
|
||||
const basket = {
|
||||
items: this.selectedProducts.map(p => ({
|
||||
product_id: p.product_id,
|
||||
quantity: p.quantity,
|
||||
unit: p.unit,
|
||||
})),
|
||||
};
|
||||
|
||||
const result = await api.createTestOrder({
|
||||
basket,
|
||||
address: this.testUser?.address || null,
|
||||
wa_chat_id: this.testUser?.wa_chat_id || null,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
this.lastOrder = result;
|
||||
this.shadowRoot.getElementById("orderIdValue").textContent = `#${result.woo_order_id}`;
|
||||
this.shadowRoot.getElementById("orderTotalValue").textContent = `$${Number(result.total || 0).toFixed(2)}`;
|
||||
this.shadowRoot.getElementById("orderResult").style.display = "block";
|
||||
this.shadowRoot.getElementById("inputAmount").value = result.total || "";
|
||||
} else {
|
||||
modal.error("Error: " + (result.error || "Error desconocido"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[test-panel] createOrder error:", e);
|
||||
modal.error("Error creando orden: " + e.message);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
btn.textContent = "Crear Orden en WooCommerce";
|
||||
this.updateButtonStates();
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
this.selectedProducts = [];
|
||||
this.testUser = null;
|
||||
this.lastOrder = null;
|
||||
this.lastPaymentLink = null;
|
||||
|
||||
this.renderProductList();
|
||||
this.renderUserInfo();
|
||||
this.updateButtonStates();
|
||||
|
||||
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 = "";
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("test-panel", TestPanel);
|
||||
@@ -18,45 +18,45 @@ class UsersCrud extends HTMLElement {
|
||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
|
||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||
|
||||
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
|
||||
input, select { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; }
|
||||
input:focus, select:focus { outline:none; border-color:#1f6feb; }
|
||||
input, select { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; }
|
||||
input:focus, select:focus { outline:none; border-color:var(--accent); }
|
||||
input { flex:1; }
|
||||
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||
button:hover { background:#1a5fd0; }
|
||||
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||
button:hover { background:var(--accent-hover); }
|
||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||
button.secondary { background:#253245; }
|
||||
button.secondary:hover { background:#2d3e52; }
|
||||
button.danger { background:#e74c3c; }
|
||||
button.danger:hover { background:#c0392b; }
|
||||
button.secondary { background:var(--border-hi); }
|
||||
button.secondary:hover { background:var(--border-hi); }
|
||||
button.danger { background:var(--err); }
|
||||
button.danger:hover { background:var(--err); }
|
||||
|
||||
.list { flex:1; overflow-y:auto; }
|
||||
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
||||
.item:hover { border-color:#1f6feb; }
|
||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
||||
.item-name { font-weight:600; color:#e7eef7; margin-bottom:4px; }
|
||||
.item-meta { font-size:12px; color:#8aa0b5; }
|
||||
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:#253245; color:#8aa0b5; margin-left:8px; }
|
||||
.badge.woo { background:#0f2a1a; color:#2ecc71; }
|
||||
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
||||
.item:hover { border-color:var(--accent); }
|
||||
.item.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||
.item-name { font-weight:600; color:var(--text); margin-bottom:4px; }
|
||||
.item-meta { font-size:12px; color:var(--text-muted); }
|
||||
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:var(--border-hi); color:var(--text-muted); margin-left:8px; }
|
||||
.badge.woo { background:var(--ok-soft); color:var(--ok); }
|
||||
|
||||
.detail { flex:1; overflow-y:auto; }
|
||||
.detail-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
||||
.detail-empty { color:var(--text-muted); text-align:center; padding:40px; }
|
||||
.field { margin-bottom:16px; }
|
||||
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.field-value { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:10px 12px; color:#e7eef7; font-size:13px; }
|
||||
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.field-value { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:10px 12px; color:var(--text); font-size:13px; }
|
||||
|
||||
.actions { display:flex; gap:8px; margin-top:16px; flex-wrap:wrap; }
|
||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
||||
.loading { text-align:center; padding:40px; color:var(--text-muted); }
|
||||
|
||||
.stats { display:flex; gap:16px; margin-bottom:16px; }
|
||||
.stat { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; flex:1; text-align:center; cursor:pointer; transition:all .15s; }
|
||||
.stat:hover { border-color:#1f6feb; }
|
||||
.stat.active { border-color:#1f6feb; background:#111b2a; }
|
||||
.stat-value { font-size:24px; font-weight:700; color:#1f6feb; }
|
||||
.stat-label { font-size:11px; color:#8aa0b5; text-transform:uppercase; margin-top:4px; }
|
||||
.stat { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; flex:1; text-align:center; cursor:pointer; transition:all .15s; }
|
||||
.stat:hover { border-color:var(--accent); }
|
||||
.stat.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||
.stat-value { font-size:24px; font-weight:700; color:var(--accent); }
|
||||
.stat-label { font-size:11px; color:var(--text-muted); text-transform:uppercase; margin-top:4px; }
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
|
||||
393
public/components/zone-map-editor.js
Normal file
393
public/components/zone-map-editor.js
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* <zone-map-editor> — editor de zonas de delivery sobre un mapa.
|
||||
*
|
||||
* Light DOM (sin shadow) para que Leaflet (que asume un DOM normal con CSS
|
||||
* global) funcione bien dentro de paneles que sí usan shadow DOM (settings-crud).
|
||||
*
|
||||
* Carga Leaflet 1.9 + leaflet-geoman desde CDN al montar. Las primeras
|
||||
* instancias inyectan los <script>/<link>; las siguientes esperan a que
|
||||
* window.L y window.L.PM estén disponibles.
|
||||
*
|
||||
* API pública:
|
||||
* set zones(arr) // [{id, name, polygon, delivery_cost, delivery_days, ...}]
|
||||
* get zones() // serializado actual con coordenadas GeoJSON
|
||||
* set selectedId(id)
|
||||
* on("change", e => ...) // dispara cuando se crea/edita/borra/renombra
|
||||
* on("select", e => ...) // dispara cuando se hace click en un polígono
|
||||
*
|
||||
* Uso:
|
||||
* const ed = document.createElement("zone-map-editor");
|
||||
* ed.zones = [...];
|
||||
* ed.addEventListener("change", e => console.log(e.detail.zones));
|
||||
*/
|
||||
|
||||
const LEAFLET_VERSION = "1.9.4";
|
||||
const GEOMAN_VERSION = "2.18.3";
|
||||
|
||||
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VERSION}/dist/leaflet.css`;
|
||||
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VERSION}/dist/leaflet.js`;
|
||||
const GEOMAN_CSS = `https://unpkg.com/@geoman-io/leaflet-geoman-free@${GEOMAN_VERSION}/dist/leaflet-geoman.css`;
|
||||
const GEOMAN_JS = `https://unpkg.com/@geoman-io/leaflet-geoman-free@${GEOMAN_VERSION}/dist/leaflet-geoman.min.js`;
|
||||
|
||||
const DEFAULT_CENTER = [-34.6037, -58.3816]; // Obelisco (lat, lng — Leaflet usa [lat,lng])
|
||||
const DEFAULT_ZOOM = 12;
|
||||
|
||||
const ZONE_PALETTE = [
|
||||
"--chart-blue", "--chart-green", "--chart-purple",
|
||||
"--chart-orange", "--chart-pink", "--chart-gray",
|
||||
];
|
||||
|
||||
let _scriptsPromise = null;
|
||||
function ensureLeafletScripts() {
|
||||
if (window.L && window.L.PM) return Promise.resolve();
|
||||
if (_scriptsPromise) return _scriptsPromise;
|
||||
_scriptsPromise = (async () => {
|
||||
if (!window.L) await loadScript(LEAFLET_JS);
|
||||
if (!window.L.PM) await loadScript(GEOMAN_JS);
|
||||
})();
|
||||
return _scriptsPromise;
|
||||
}
|
||||
|
||||
function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const existing = document.querySelector(`script[src="${src}"]`);
|
||||
if (existing) {
|
||||
if (existing.dataset.loaded === "1") { resolve(); return; }
|
||||
existing.addEventListener("load", () => resolve());
|
||||
existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)));
|
||||
return;
|
||||
}
|
||||
const s = document.createElement("script");
|
||||
s.src = src;
|
||||
s.async = true;
|
||||
s.onload = () => { s.dataset.loaded = "1"; resolve(); };
|
||||
s.onerror = () => reject(new Error(`Failed to load ${src}`));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForCSS(linkEl, timeoutMs = 4000) {
|
||||
if (linkEl.sheet) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, timeoutMs);
|
||||
linkEl.addEventListener("load", () => { clearTimeout(timer); resolve(); }, { once: true });
|
||||
linkEl.addEventListener("error", () => { clearTimeout(timer); resolve(); }, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function cssVar(name, fallback = "#0ea5e9") {
|
||||
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return v || fallback;
|
||||
}
|
||||
|
||||
function slugify(name) {
|
||||
return String(name || "")
|
||||
.toLowerCase()
|
||||
.normalize("NFD").replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40) || `zona-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
class ZoneMapEditor extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this._zones = []; // estado interno (con polygon GeoJSON)
|
||||
this._layersById = new Map(); // id -> leaflet layer
|
||||
this._selectedId = null;
|
||||
this._map = null;
|
||||
this._mapDiv = null;
|
||||
this._ready = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.style.display = "block";
|
||||
this.style.position = "relative";
|
||||
this.style.width = "100%";
|
||||
this.style.height = this.getAttribute("height") || "480px";
|
||||
|
||||
// El web component vive en light DOM dentro del shadow root del padre.
|
||||
// Los <link> en document.head NO cruzan shadow boundaries, así que los
|
||||
// anclamos como hijos del propio elemento (sí cruzan, porque slot
|
||||
// composition trae nuestros children al árbol del padre con sus assets).
|
||||
const linkLeaflet = document.createElement("link");
|
||||
linkLeaflet.rel = "stylesheet";
|
||||
linkLeaflet.href = LEAFLET_CSS;
|
||||
this.appendChild(linkLeaflet);
|
||||
const linkGeoman = document.createElement("link");
|
||||
linkGeoman.rel = "stylesheet";
|
||||
linkGeoman.href = GEOMAN_CSS;
|
||||
this.appendChild(linkGeoman);
|
||||
|
||||
this._mapDiv = document.createElement("div");
|
||||
this._mapDiv.style.width = "100%";
|
||||
this._mapDiv.style.height = "100%";
|
||||
this._mapDiv.style.borderRadius = "var(--r-md, 10px)";
|
||||
this._mapDiv.style.overflow = "hidden";
|
||||
this._mapDiv.style.border = "1px solid var(--border, #e2e8f0)";
|
||||
this._mapDiv.style.background = "#e2e8f0";
|
||||
this.appendChild(this._mapDiv);
|
||||
|
||||
ensureLeafletScripts()
|
||||
.then(() => waitForCSS(linkLeaflet))
|
||||
.then(() => this._initMap())
|
||||
.catch((err) => {
|
||||
this._mapDiv.innerHTML = `<div style="padding:16px;color:var(--err);font-family:var(--font-sans);">No se pudo cargar el mapa: ${err.message}</div>`;
|
||||
});
|
||||
|
||||
// Si el host estaba oculto al montar (router/visibility), Leaflet calcula
|
||||
// 0×0 y los tiles no se piden. Observamos el resize para recalcular.
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
this._ro = new ResizeObserver(() => {
|
||||
if (this._map) this._map.invalidateSize();
|
||||
});
|
||||
this._ro.observe(this);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._ro) { this._ro.disconnect(); this._ro = null; }
|
||||
if (this._map) {
|
||||
this._map.remove();
|
||||
this._map = null;
|
||||
}
|
||||
this._layersById.clear();
|
||||
this._ready = false;
|
||||
}
|
||||
|
||||
// ───────────── API pública ─────────────
|
||||
|
||||
get zones() {
|
||||
// Devolver el estado interno con polígonos sincronizados desde los layers.
|
||||
return this._zones.map((z) => ({ ...z, polygon: this._serializePolygon(z.id) || z.polygon || null }));
|
||||
}
|
||||
|
||||
set zones(arr) {
|
||||
const list = Array.isArray(arr) ? arr : [];
|
||||
this._zones = list.map((z) => ({
|
||||
id: z.id || slugify(z.name || ""),
|
||||
name: z.name || "",
|
||||
polygon: z.polygon || null,
|
||||
delivery_cost: z.delivery_cost ?? 0,
|
||||
delivery_days: Array.isArray(z.delivery_days) ? z.delivery_days : ["lun","mar","mie","jue","vie","sab"],
|
||||
delivery_hours: z.delivery_hours || { start: "10:00", end: "20:00" },
|
||||
min_order_amount: z.min_order_amount ?? 0,
|
||||
enabled: z.enabled !== false,
|
||||
}));
|
||||
if (this._ready) this._renderLayers();
|
||||
}
|
||||
|
||||
get selectedId() { return this._selectedId; }
|
||||
set selectedId(id) {
|
||||
this._selectedId = id || null;
|
||||
if (!this._ready) return;
|
||||
for (const [zid, layer] of this._layersById) {
|
||||
this._applyLayerStyle(zid, layer, zid === this._selectedId);
|
||||
}
|
||||
if (id && this._layersById.has(id)) {
|
||||
const layer = this._layersById.get(id);
|
||||
try { this._map.fitBounds(layer.getBounds().pad(0.25)); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
upsertZone(zone) {
|
||||
const idx = this._zones.findIndex((z) => z.id === zone.id);
|
||||
if (idx >= 0) this._zones[idx] = { ...this._zones[idx], ...zone };
|
||||
else this._zones.push(zone);
|
||||
this._renderLayers();
|
||||
this._emit("change");
|
||||
}
|
||||
|
||||
removeZone(id) {
|
||||
this._zones = this._zones.filter((z) => z.id !== id);
|
||||
if (this._layersById.has(id)) {
|
||||
this._map.removeLayer(this._layersById.get(id));
|
||||
this._layersById.delete(id);
|
||||
}
|
||||
if (this._selectedId === id) this._selectedId = null;
|
||||
this._emit("change");
|
||||
}
|
||||
|
||||
startDrawing() {
|
||||
if (!this._ready) return;
|
||||
this._map.pm.enableDraw("Polygon", {
|
||||
snappable: true,
|
||||
templineStyle: { color: cssVar("--chart-blue") },
|
||||
hintlineStyle: { color: cssVar("--chart-blue"), dashArray: [5, 5] },
|
||||
pathOptions: this._defaultPathOptions(),
|
||||
});
|
||||
}
|
||||
|
||||
// ───────────── Internals ─────────────
|
||||
|
||||
_initMap() {
|
||||
const L = window.L;
|
||||
const center = DEFAULT_CENTER;
|
||||
this._map = L.map(this._mapDiv, {
|
||||
center, zoom: DEFAULT_ZOOM, zoomControl: true, attributionControl: true,
|
||||
});
|
||||
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
}).addTo(this._map);
|
||||
|
||||
// Geoman: solo botón "draw polygon" + edit/delete contextual desde la UI nuestra.
|
||||
this._map.pm.addControls({
|
||||
position: "topright",
|
||||
drawCircle: false, drawCircleMarker: false, drawMarker: false,
|
||||
drawPolyline: false, drawRectangle: false, drawText: false,
|
||||
cutPolygon: false, rotateMode: false,
|
||||
drawPolygon: true, editMode: true, dragMode: false, removalMode: true,
|
||||
});
|
||||
this._map.pm.setLang("es");
|
||||
this._map.pm.setGlobalOptions({
|
||||
pathOptions: this._defaultPathOptions(),
|
||||
snappable: true,
|
||||
});
|
||||
|
||||
// Listeners globales de geoman.
|
||||
this._map.on("pm:create", (e) => this._handleCreate(e));
|
||||
this._map.on("pm:remove", (e) => this._handleRemove(e));
|
||||
|
||||
this._ready = true;
|
||||
this._renderLayers();
|
||||
// Forzar un invalidateSize después del primer paint por si el contenedor
|
||||
// recién obtuvo tamaño (panel oculto inicial / tabs / etc).
|
||||
requestAnimationFrame(() => this._map && this._map.invalidateSize());
|
||||
setTimeout(() => this._map && this._map.invalidateSize(), 250);
|
||||
}
|
||||
|
||||
_renderLayers() {
|
||||
if (!this._ready) return;
|
||||
const L = window.L;
|
||||
// Borrar layers que ya no existen.
|
||||
for (const [id, layer] of this._layersById) {
|
||||
if (!this._zones.some((z) => z.id === id)) {
|
||||
this._map.removeLayer(layer);
|
||||
this._layersById.delete(id);
|
||||
}
|
||||
}
|
||||
// Crear/actualizar.
|
||||
this._zones.forEach((z, i) => {
|
||||
if (!z.polygon || !Array.isArray(z.polygon.coordinates)) return;
|
||||
const latlngs = z.polygon.coordinates[0].map(([lng, lat]) => [lat, lng]);
|
||||
const existing = this._layersById.get(z.id);
|
||||
if (existing) {
|
||||
existing.setLatLngs(latlngs);
|
||||
this._applyLayerStyle(z.id, existing, z.id === this._selectedId);
|
||||
return;
|
||||
}
|
||||
const color = cssVar(ZONE_PALETTE[i % ZONE_PALETTE.length]);
|
||||
const layer = L.polygon(latlngs, this._pathOptions(color, z.id === this._selectedId)).addTo(this._map);
|
||||
layer.bindTooltip(z.name || z.id, { sticky: true });
|
||||
layer.on("click", () => this._select(z.id));
|
||||
layer.on("pm:edit", () => this._handleEdit(z.id, layer));
|
||||
this._layersById.set(z.id, layer);
|
||||
});
|
||||
}
|
||||
|
||||
_select(id) {
|
||||
this._selectedId = id;
|
||||
for (const [zid, layer] of this._layersById) {
|
||||
this._applyLayerStyle(zid, layer, zid === id);
|
||||
}
|
||||
this._emit("select", { id });
|
||||
}
|
||||
|
||||
_handleCreate(e) {
|
||||
const layer = e.layer;
|
||||
const id = `zona-${Date.now().toString(36)}`;
|
||||
const i = this._zones.length;
|
||||
const color = cssVar(ZONE_PALETTE[i % ZONE_PALETTE.length]);
|
||||
layer.setStyle(this._pathOptions(color, true));
|
||||
|
||||
const polygon = this._toGeoJSONPolygon(layer.getLatLngs());
|
||||
const zone = {
|
||||
id,
|
||||
name: `Zona ${i + 1}`,
|
||||
polygon,
|
||||
delivery_cost: 0,
|
||||
delivery_days: ["lun","mar","mie","jue","vie","sab"],
|
||||
delivery_hours: { start: "10:00", end: "20:00" },
|
||||
min_order_amount: 0,
|
||||
enabled: true,
|
||||
};
|
||||
this._zones.push(zone);
|
||||
this._layersById.set(id, layer);
|
||||
layer.bindTooltip(zone.name, { sticky: true });
|
||||
layer.on("click", () => this._select(id));
|
||||
layer.on("pm:edit", () => this._handleEdit(id, layer));
|
||||
this._select(id);
|
||||
this._emit("change");
|
||||
this._emit("create", { id, zone });
|
||||
}
|
||||
|
||||
_handleEdit(id, layer) {
|
||||
const z = this._zones.find((x) => x.id === id);
|
||||
if (!z) return;
|
||||
z.polygon = this._toGeoJSONPolygon(layer.getLatLngs());
|
||||
this._emit("change");
|
||||
}
|
||||
|
||||
_handleRemove(e) {
|
||||
const layer = e.layer;
|
||||
let removedId = null;
|
||||
for (const [id, l] of this._layersById) {
|
||||
if (l === layer) { removedId = id; break; }
|
||||
}
|
||||
if (!removedId) return;
|
||||
this._zones = this._zones.filter((z) => z.id !== removedId);
|
||||
this._layersById.delete(removedId);
|
||||
if (this._selectedId === removedId) this._selectedId = null;
|
||||
this._emit("change");
|
||||
}
|
||||
|
||||
_serializePolygon(id) {
|
||||
const layer = this._layersById.get(id);
|
||||
if (!layer) return null;
|
||||
return this._toGeoJSONPolygon(layer.getLatLngs());
|
||||
}
|
||||
|
||||
_toGeoJSONPolygon(latlngs) {
|
||||
// Leaflet pasa anidado: [[{lat,lng}...]] para polígonos simples.
|
||||
const ring = Array.isArray(latlngs[0]) ? latlngs[0] : latlngs;
|
||||
const coords = ring.map((p) => [p.lng, p.lat]);
|
||||
// Cerrar el anillo si no está cerrado (GeoJSON lo requiere).
|
||||
const first = coords[0];
|
||||
const last = coords[coords.length - 1];
|
||||
if (!first || !last || first[0] !== last[0] || first[1] !== last[1]) {
|
||||
if (first) coords.push([first[0], first[1]]);
|
||||
}
|
||||
return { type: "Polygon", coordinates: [coords] };
|
||||
}
|
||||
|
||||
_defaultPathOptions() {
|
||||
return this._pathOptions(cssVar("--chart-blue"), false);
|
||||
}
|
||||
|
||||
_pathOptions(color, selected) {
|
||||
return {
|
||||
color,
|
||||
weight: selected ? 3 : 2,
|
||||
opacity: 0.95,
|
||||
fillColor: color,
|
||||
fillOpacity: selected ? 0.28 : 0.18,
|
||||
};
|
||||
}
|
||||
|
||||
_applyLayerStyle(id, layer, selected) {
|
||||
const i = this._zones.findIndex((z) => z.id === id);
|
||||
const color = cssVar(ZONE_PALETTE[(i < 0 ? 0 : i) % ZONE_PALETTE.length]);
|
||||
layer.setStyle(this._pathOptions(color, selected));
|
||||
}
|
||||
|
||||
_emit(name, detail = {}) {
|
||||
detail = { zones: this.zones, ...detail };
|
||||
this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("zone-map-editor")) {
|
||||
customElements.define("zone-map-editor", ZoneMapEditor);
|
||||
}
|
||||
@@ -3,10 +3,12 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Bot Ops Console</title>
|
||||
<title>Piaf Console</title>
|
||||
<link rel="stylesheet" href="/styles/theme.css" />
|
||||
</head>
|
||||
<body>
|
||||
<ops-shell></ops-shell>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,3 +1,35 @@
|
||||
import { toast } from "./toast.js";
|
||||
|
||||
/**
|
||||
* fetch wrapper que dispara toast en error de red o respuesta no-OK.
|
||||
* Devuelve la respuesta parseada como JSON. Si la respuesta tiene
|
||||
* `{ ok: false, error }`, también dispara toast.
|
||||
*/
|
||||
export async function safeFetch(url, opts = {}, { silent = false, label = null } = {}) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, opts);
|
||||
} catch (err) {
|
||||
if (!silent) toast({ kind: "error", text: `${label || "Red"}: ${err?.message || "sin conexión"}` });
|
||||
throw err;
|
||||
}
|
||||
if (!res.ok) {
|
||||
let body = null;
|
||||
try { body = await res.json(); } catch (_) {}
|
||||
const msg = body?.error || body?.message || `${res.status} ${res.statusText}`;
|
||||
if (!silent) toast({ kind: "error", text: `${label || "Error"}: ${msg}` });
|
||||
const err = new Error(msg);
|
||||
err.status = res.status;
|
||||
err.body = body;
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
return await res.json();
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async conversations({ q = "", status = "", state = "" } = {}) {
|
||||
const u = new URL("/conversations", location.origin);
|
||||
@@ -46,16 +78,16 @@ export const api = {
|
||||
},
|
||||
|
||||
async simEvolution(payload) {
|
||||
return fetch("/webhook/evolution", {
|
||||
return safeFetch("/webhook/evolution", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(r => r.json());
|
||||
}, { label: "Sim Evolution" });
|
||||
},
|
||||
|
||||
async retryLast(chat_id) {
|
||||
if (!chat_id) throw new Error("chat_id_required");
|
||||
return fetch(`/conversations/${encodeURIComponent(chat_id)}/retry-last`, { method: "POST" }).then(r => r.json());
|
||||
return safeFetch(`/conversations/${encodeURIComponent(chat_id)}/retry-last`, { method: "POST" }, { label: "Retry" });
|
||||
},
|
||||
|
||||
// Products CRUD
|
||||
@@ -185,39 +217,21 @@ export const api = {
|
||||
return fetch("/products/sync-from-woo", { method: "POST" }).then(r => r.json());
|
||||
},
|
||||
|
||||
// --- Testing ---
|
||||
async listRecentOrders({ limit = 20 } = {}) {
|
||||
const u = new URL("/test/orders", location.origin);
|
||||
// --- Orders & Stats ---
|
||||
async listOrders({ page = 1, limit = 50 } = {}) {
|
||||
const u = new URL("/api/orders", location.origin);
|
||||
u.searchParams.set("page", String(page));
|
||||
u.searchParams.set("limit", String(limit));
|
||||
return fetch(u).then(r => r.json());
|
||||
},
|
||||
|
||||
async getProductsWithStock() {
|
||||
return fetch("/test/products-with-stock").then(r => r.json());
|
||||
async getOrderStats() {
|
||||
return fetch("/api/stats/orders").then(r => r.json());
|
||||
},
|
||||
|
||||
async createTestOrder({ basket, address, wa_chat_id }) {
|
||||
return fetch("/test/order", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ basket, address, wa_chat_id }),
|
||||
}).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());
|
||||
// Alias para compatibilidad
|
||||
async listRecentOrders({ limit = 20 } = {}) {
|
||||
return this.listOrders({ page: 1, limit });
|
||||
},
|
||||
|
||||
// --- Prompts CRUD ---
|
||||
|
||||
@@ -11,114 +11,81 @@
|
||||
|
||||
const STYLES = `
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 10000;
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
font-family: var(--font-sans, system-ui);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideIn {
|
||||
from { transform: translateY(-20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
from { transform: translateY(-12px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
background: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
background: var(--panel, #ffffff);
|
||||
border-radius: var(--r-lg, 12px);
|
||||
padding: 24px;
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: var(--shadow-lg, 0 12px 28px rgba(15,23,42,.10));
|
||||
border: 1px solid var(--border, #e2e8f0);
|
||||
animation: slideIn 0.2s ease-out;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
width: 32px; height: 32px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 16px; font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-icon.success { background: #22c55e20; color: #22c55e; }
|
||||
.modal-icon.error { background: #ef444420; color: #ef4444; }
|
||||
.modal-icon.warn { background: #f59e0b20; color: #f59e0b; }
|
||||
.modal-icon.info { background: #3b82f620; color: #3b82f6; }
|
||||
.modal-icon.confirm { background: #8b5cf620; color: #8b5cf6; }
|
||||
|
||||
.modal-icon.success { background: var(--ok-soft, #d1fae5); color: var(--ok, #10b981); }
|
||||
.modal-icon.error { background: var(--err-soft, #fee2e2); color: var(--err, #ef4444); }
|
||||
.modal-icon.warn { background: var(--warn-soft, #fef3c7); color: var(--warn, #f59e0b); }
|
||||
.modal-icon.info { background: var(--accent-soft, #e0f2fe); color: var(--accent, #0ea5e9); }
|
||||
.modal-icon.confirm { background: var(--accent-soft, #e0f2fe); color: var(--accent-hover, #0284c7); }
|
||||
.modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
font-size: 16px; font-weight: 600;
|
||||
color: var(--text, #0f172a);
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.modal-message {
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-dim, #475569);
|
||||
font-size: 14px; line-height: 1.5;
|
||||
margin-bottom: 20px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.modal-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
display: flex; gap: 8px; justify-content: flex-end;
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--r-md, 10px);
|
||||
font-size: 13px; font-weight: 500;
|
||||
cursor: pointer; border: 1px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.modal-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.modal-btn:focus-visible { outline: none; box-shadow: var(--focus-ring, 0 0 0 3px rgba(14,165,233,.3)); }
|
||||
.modal-btn.primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
background: var(--accent, #0ea5e9); color: var(--text-on-acc, #fff);
|
||||
}
|
||||
|
||||
.modal-btn.primary:hover { background: var(--accent-hover, #0284c7); }
|
||||
.modal-btn.secondary {
|
||||
background: #333;
|
||||
color: #ccc;
|
||||
border: 1px solid #444;
|
||||
background: var(--panel, #fff); color: var(--text, #0f172a);
|
||||
border-color: var(--border-hi, #cbd5e1);
|
||||
}
|
||||
|
||||
.modal-btn.secondary:hover { border-color: var(--accent, #0ea5e9); color: var(--accent-hover, #0284c7); }
|
||||
.modal-btn.danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
background: var(--err, #ef4444); color: #fff;
|
||||
}
|
||||
.modal-btn.danger:hover { filter: brightness(0.95); }
|
||||
`;
|
||||
|
||||
// Inyectar estilos una sola vez
|
||||
|
||||
@@ -2,7 +2,8 @@ import { emit } from "./bus.js";
|
||||
|
||||
// Mapeo de rutas a vistas
|
||||
const ROUTES = [
|
||||
{ pattern: /^\/$/, view: "chat", params: [] },
|
||||
{ pattern: /^\/$/, view: "home", params: [] },
|
||||
{ pattern: /^\/home$/, view: "home", params: [] },
|
||||
{ pattern: /^\/chat$/, view: "chat", params: [] },
|
||||
{ pattern: /^\/conversaciones$/, view: "conversations", params: [] },
|
||||
{ pattern: /^\/usuarios$/, view: "users", params: [] },
|
||||
@@ -15,7 +16,6 @@ const ROUTES = [
|
||||
{ pattern: /^\/cantidades$/, view: "quantities", params: [] },
|
||||
{ pattern: /^\/pedidos$/, view: "orders", params: [] },
|
||||
{ pattern: /^\/pedidos\/([^/]+)$/, view: "orders", params: ["id"] },
|
||||
{ pattern: /^\/test$/, view: "test", params: [] },
|
||||
{ pattern: /^\/config-prompts$/, view: "prompts", params: [] },
|
||||
{ pattern: /^\/atencion-humana$/, view: "takeovers", params: [] },
|
||||
{ pattern: /^\/configuracion$/, view: "settings", params: [] },
|
||||
@@ -23,6 +23,7 @@ const ROUTES = [
|
||||
|
||||
// Mapeo de vistas a rutas base (para navegación sin parámetros)
|
||||
const VIEW_TO_PATH = {
|
||||
home: "/home",
|
||||
chat: "/chat",
|
||||
conversations: "/conversaciones",
|
||||
users: "/usuarios",
|
||||
@@ -31,7 +32,6 @@ const VIEW_TO_PATH = {
|
||||
crosssell: "/crosssell",
|
||||
quantities: "/cantidades",
|
||||
orders: "/pedidos",
|
||||
test: "/test",
|
||||
prompts: "/config-prompts",
|
||||
takeovers: "/atencion-humana",
|
||||
settings: "/configuracion",
|
||||
@@ -54,8 +54,8 @@ export function parseRoute(pathname) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback a chat si no matchea ninguna ruta
|
||||
return { view: "chat", params: {} };
|
||||
// Fallback a home si no matchea ninguna ruta
|
||||
return { view: "home", params: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,15 +1,71 @@
|
||||
import { emit } from "./bus.js";
|
||||
|
||||
export function connectSSE() {
|
||||
const es = new EventSource("/stream");
|
||||
/**
|
||||
* SSE client con reconnect exponencial y try-catch en parseo.
|
||||
* Si el server reinicia o un evento viene malformado, no rompe la app.
|
||||
*/
|
||||
|
||||
es.addEventListener("hello", () => emit("sse:status", { ok: true }));
|
||||
es.addEventListener("conversation.upsert", (e) => emit("conversation:upsert", JSON.parse(e.data)));
|
||||
es.addEventListener("run.created", (e) => emit("run:created", JSON.parse(e.data)));
|
||||
es.addEventListener("takeover.created", (e) => emit("takeover:created", JSON.parse(e.data)));
|
||||
es.addEventListener("order.created", (e) => emit("order:created", JSON.parse(e.data)));
|
||||
let _es = null;
|
||||
let _retryDelay = 1000;
|
||||
let _retryTimer = null;
|
||||
const MAX_RETRY = 30_000;
|
||||
|
||||
es.onerror = () => emit("sse:status", { ok: false });
|
||||
const EVENTS = [
|
||||
["conversation.upsert", "conversation:upsert"],
|
||||
["run.created", "run:created"],
|
||||
["takeover.created", "takeover:created"],
|
||||
["order.created", "order:created"],
|
||||
];
|
||||
|
||||
return es;
|
||||
function safeParse(rawData, evName) {
|
||||
try {
|
||||
return JSON.parse(rawData);
|
||||
} catch (err) {
|
||||
console.error(`[sse] bad payload for ${evName}:`, err?.message || err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function attach(es) {
|
||||
es.addEventListener("hello", () => {
|
||||
_retryDelay = 1000; // reset on success
|
||||
emit("sse:status", { ok: true });
|
||||
});
|
||||
|
||||
for (const [serverName, busName] of EVENTS) {
|
||||
es.addEventListener(serverName, (e) => {
|
||||
const data = safeParse(e.data, serverName);
|
||||
if (data !== null) emit(busName, data);
|
||||
});
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
emit("sse:status", { ok: false });
|
||||
try { es.close(); } catch (_) {}
|
||||
if (_es === es) _es = null;
|
||||
scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (_retryTimer) return;
|
||||
const delay = _retryDelay;
|
||||
_retryTimer = setTimeout(() => {
|
||||
_retryTimer = null;
|
||||
connectSSE();
|
||||
}, delay);
|
||||
_retryDelay = Math.min(_retryDelay * 2, MAX_RETRY);
|
||||
}
|
||||
|
||||
export function connectSSE() {
|
||||
if (_retryTimer) {
|
||||
clearTimeout(_retryTimer);
|
||||
_retryTimer = null;
|
||||
}
|
||||
if (_es) {
|
||||
try { _es.close(); } catch (_) {}
|
||||
}
|
||||
_es = new EventSource("/stream");
|
||||
attach(_es);
|
||||
return _es;
|
||||
}
|
||||
|
||||
95
public/lib/toast.js
Normal file
95
public/lib/toast.js
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Toast service global. Sin dependencias.
|
||||
* Inyecta un container en <body> y empuja toasts apilados.
|
||||
*
|
||||
* Uso:
|
||||
* import { toast } from "./toast.js";
|
||||
* toast({ kind: "error", text: "No se pudo guardar" });
|
||||
* toast({ kind: "ok", text: "Listo", ms: 2000 });
|
||||
*/
|
||||
|
||||
// Lee var del :root con fallback. Permite que la paleta de toasts se adapte
|
||||
// al tema sin que el archivo conozca los hex.
|
||||
function v(name, fallback) {
|
||||
try {
|
||||
const c = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return c || fallback;
|
||||
} catch { return fallback; }
|
||||
}
|
||||
|
||||
function kindColors() {
|
||||
return {
|
||||
error: { bg: v("--err-soft", "#fee2e2"), border: v("--err", "#ef4444"), text: v("--err-text", "#7f1d1d") },
|
||||
ok: { bg: v("--ok-soft", "#d1fae5"), border: v("--ok", "#10b981"), text: v("--user-text", "#064e3b") },
|
||||
warn: { bg: v("--warn-soft", "#fef3c7"), border: v("--warn", "#f59e0b"), text: v("--text", "#0f172a") },
|
||||
info: { bg: v("--accent-soft","#e0f2fe"),border: v("--accent","#0ea5e9"),text: v("--bot-text", "#1e3a8a") },
|
||||
};
|
||||
}
|
||||
|
||||
let _container = null;
|
||||
|
||||
function ensureContainer() {
|
||||
if (_container) return _container;
|
||||
_container = document.createElement("div");
|
||||
_container.id = "toast-stack";
|
||||
Object.assign(_container.style, {
|
||||
position: "fixed",
|
||||
right: "16px",
|
||||
bottom: "16px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
zIndex: "9999",
|
||||
pointerEvents: "none",
|
||||
maxWidth: "420px",
|
||||
});
|
||||
document.body.appendChild(_container);
|
||||
return _container;
|
||||
}
|
||||
|
||||
export function toast({ kind = "error", text = "", ms = 4000 } = {}) {
|
||||
if (!text) return;
|
||||
const COLORS = kindColors();
|
||||
const colors = COLORS[kind] || COLORS.info;
|
||||
const el = document.createElement("div");
|
||||
Object.assign(el.style, {
|
||||
background: colors.bg,
|
||||
border: `1px solid ${colors.border}`,
|
||||
color: colors.text,
|
||||
padding: "12px 16px",
|
||||
borderRadius: "12px",
|
||||
fontSize: "13px",
|
||||
fontWeight: "500",
|
||||
fontFamily: "var(--font-sans, system-ui)",
|
||||
boxShadow: "var(--shadow-md, 0 4px 12px rgba(15,23,42,.06))",
|
||||
pointerEvents: "auto",
|
||||
cursor: "pointer",
|
||||
transform: "translateX(120%)",
|
||||
transition: "transform .25s ease, opacity .25s ease",
|
||||
opacity: "0",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
});
|
||||
el.textContent = String(text);
|
||||
|
||||
const c = ensureContainer();
|
||||
c.appendChild(el);
|
||||
|
||||
// Animar entrada
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transform = "translateX(0)";
|
||||
el.style.opacity = "1";
|
||||
});
|
||||
|
||||
const dismiss = () => {
|
||||
el.style.transform = "translateX(120%)";
|
||||
el.style.opacity = "0";
|
||||
setTimeout(() => el.remove(), 280);
|
||||
};
|
||||
el.addEventListener("click", dismiss);
|
||||
setTimeout(dismiss, Math.max(800, ms));
|
||||
}
|
||||
|
||||
export function toastError(text) { toast({ kind: "error", text }); }
|
||||
export function toastOk(text) { toast({ kind: "ok", text, ms: 2500 }); }
|
||||
export function toastWarn(text) { toast({ kind: "warn", text }); }
|
||||
@@ -70,7 +70,6 @@ function upsertConversation(chat_id, patch) {
|
||||
* - call LLM (structured output)
|
||||
* - product search (LIMITED) + resolve ids
|
||||
* - create/update Woo order
|
||||
* - create MercadoPago link
|
||||
* - save state
|
||||
*/
|
||||
async function processMessage({ chat_id, from, text }) {
|
||||
@@ -82,7 +81,7 @@ async function processMessage({ chat_id, from, text }) {
|
||||
// Minimal simulated LLM output (replace later)
|
||||
const plan = {
|
||||
reply: `Recibido: "${text}". ¿Querés retiro o envío?`,
|
||||
next_state: "BUILDING_ORDER",
|
||||
next_state: "CART",
|
||||
intent: "create_order",
|
||||
missing_fields: ["delivery_or_pickup"],
|
||||
order_action: "none",
|
||||
@@ -93,7 +92,6 @@ async function processMessage({ chat_id, from, text }) {
|
||||
ok: true,
|
||||
checks: [
|
||||
{ name: "required_keys_present", ok: true },
|
||||
{ name: "no_checkout_without_payment_link", ok: true },
|
||||
{ name: "no_order_action_without_items", ok: true },
|
||||
],
|
||||
};
|
||||
@@ -111,7 +109,6 @@ async function processMessage({ chat_id, from, text }) {
|
||||
invariants,
|
||||
final_reply: plan.reply,
|
||||
order_id: null,
|
||||
payment_link: null,
|
||||
latency_ms: Date.now() - started_at,
|
||||
};
|
||||
|
||||
|
||||
BIN
public/styles/fonts/Inter-Variable.woff2
Normal file
BIN
public/styles/fonts/Inter-Variable.woff2
Normal file
Binary file not shown.
1483
public/styles/fonts/JetBrainsMono-Variable.woff2
Normal file
1483
public/styles/fonts/JetBrainsMono-Variable.woff2
Normal file
File diff suppressed because one or more lines are too long
156
public/styles/theme.css
Normal file
156
public/styles/theme.css
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Tema global Botino — light pastel frío (azul/verde).
|
||||
*
|
||||
* CSS custom properties heredan a través del shadow DOM, así que cualquier
|
||||
* componente puede usar `var(--bg)` sin re-declarar nada.
|
||||
*/
|
||||
|
||||
/* ────────────────────────────────────────────────────────────
|
||||
* Tipografía: Inter + JetBrains Mono (variable, self-hosted)
|
||||
* ──────────────────────────────────────────────────────────── */
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
src: url("/styles/fonts/Inter-Variable.woff2") format("woff2");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono";
|
||||
src: url("/styles/fonts/JetBrainsMono-Variable.woff2") format("woff2");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────
|
||||
* Tokens de diseño
|
||||
* ──────────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
/* Surfaces */
|
||||
--bg: #f7fafc; /* casi blanco con tinte azul muy sutil */
|
||||
--panel: #ffffff; /* tarjetas / paneles principales */
|
||||
--panel-2: #f1f5f9; /* slate-100 */
|
||||
--panel-3: #e2e8f0; /* slate-200 */
|
||||
|
||||
/* Borders */
|
||||
--border: #e2e8f0; /* slate-200 */
|
||||
--border-hi: #cbd5e1; /* slate-300 */
|
||||
--border-active:#0ea5e9; /* sky-500 */
|
||||
|
||||
/* Text */
|
||||
--text: #0f172a; /* slate-900 */
|
||||
--text-dim: #475569; /* slate-600 */
|
||||
--text-muted: #64748b; /* slate-500 */
|
||||
--text-on-acc: #ffffff;
|
||||
--muted: var(--text-muted);
|
||||
|
||||
/* Accents — azul/verde fríos como protagonistas */
|
||||
--accent: #0ea5e9; /* sky-500 */
|
||||
--accent-hover: #0284c7; /* sky-600 */
|
||||
--accent-soft: #e0f2fe; /* sky-100 */
|
||||
--ok: #10b981; /* emerald-500 */
|
||||
--ok-soft: #d1fae5; /* emerald-100 */
|
||||
--warn: #f59e0b; /* amber-500 */
|
||||
--warn-soft: #fef3c7;
|
||||
--err: #ef4444; /* red-500 */
|
||||
--err-soft: #fee2e2;
|
||||
|
||||
/* Bubbles — light pastel */
|
||||
--user-bubble: #d1fae5; /* emerald-100 (cliente) */
|
||||
--user-border: #6ee7b7; /* emerald-300 */
|
||||
--user-text: #064e3b; /* emerald-900 */
|
||||
--user-name: #047857; /* emerald-700 */
|
||||
--user-meta: #059669; /* emerald-600 */
|
||||
|
||||
--bot-bubble: #dbeafe; /* blue-100 (bot) */
|
||||
--bot-border: #93c5fd; /* blue-300 */
|
||||
--bot-text: #1e3a8a; /* blue-900 */
|
||||
--bot-name: #1d4ed8; /* blue-700 */
|
||||
--bot-meta: #2563eb; /* blue-600 */
|
||||
|
||||
--err-bubble: #fee2e2;
|
||||
--err-border: #fca5a5;
|
||||
--err-text: #7f1d1d;
|
||||
--err-name: #b91c1c;
|
||||
--err-meta: #dc2626;
|
||||
|
||||
/* Charts — paleta pastel coordinada (~400 saturación) */
|
||||
--chart-blue: #38bdf8; /* sky-400 */
|
||||
--chart-green: #34d399; /* emerald-400 */
|
||||
--chart-purple: #a78bfa; /* violet-400 */
|
||||
--chart-orange: #fb923c; /* orange-400 */
|
||||
--chart-pink: #f472b6; /* pink-400 */
|
||||
--chart-gray: #94a3b8; /* slate-400 */
|
||||
--chart-blue-soft: rgba(56, 189, 248, 0.20);
|
||||
--chart-green-soft: rgba(52, 211, 153, 0.20);
|
||||
|
||||
/* Spacing scale */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
|
||||
/* Radii */
|
||||
--r-sm: 8px;
|
||||
--r-md: 10px;
|
||||
--r-lg: 12px;
|
||||
--r-xl: 16px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.06);
|
||||
--shadow-lg: 0 12px 28px rgba(15, 23, 42, 0.10);
|
||||
--shadow-bubble: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
|
||||
/* Type scale */
|
||||
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, "Fira Mono", monospace;
|
||||
--fs-xs: 11px;
|
||||
--fs-sm: 12px;
|
||||
--fs-base: 14px;
|
||||
--fs-md: 15px;
|
||||
--fs-lg: 18px;
|
||||
--fs-xl: 24px;
|
||||
--fw-regular: 400;
|
||||
--fw-medium: 500;
|
||||
--fw-semibold: 600;
|
||||
--fw-bold: 700;
|
||||
--lh-base: 1.5;
|
||||
--lh-tight: 1.3;
|
||||
|
||||
/* Focus ring (accesibilidad) */
|
||||
--focus-ring: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────
|
||||
* Reset / defaults
|
||||
* ──────────────────────────────────────────────────────────── */
|
||||
html, body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-base);
|
||||
line-height: var(--lh-base);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
/* Scrollbars finos / pastel */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-hi) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
*::-webkit-scrollbar-thumb { background: var(--border-hi); border-radius: 4px; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
/* Selection con tinte pastel */
|
||||
::selection { background: var(--accent-soft); color: var(--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 { 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";
|
||||
|
||||
export function createApp({ tenantId }) {
|
||||
@@ -23,7 +22,6 @@ export function createApp({ tenantId }) {
|
||||
// --- Integraciones / UI ---
|
||||
app.use(createSimulatorRouter({ tenantId }));
|
||||
app.use(createEvolutionRouter());
|
||||
app.use("/payments/meli", createMercadoPagoRouter());
|
||||
app.use(createWooWebhooksRouter());
|
||||
|
||||
// Home (UI)
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import {
|
||||
handleListPrompts,
|
||||
handleGetPrompt,
|
||||
handleSavePrompt,
|
||||
handleRollbackPrompt,
|
||||
handleResetPrompt,
|
||||
handleGetPromptVersion,
|
||||
handleTestPrompt,
|
||||
} from "../handlers/prompts.js";
|
||||
|
||||
/**
|
||||
* GET /prompts - Lista todos los prompts del tenant
|
||||
*/
|
||||
export const makeListPrompts = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const result = await handleListPrompts({ tenantId });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error("[prompts] List error:", err);
|
||||
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /prompts/:key - Obtiene un prompt específico con versiones
|
||||
*/
|
||||
export const makeGetPrompt = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const promptKey = req.params.key;
|
||||
const result = await handleGetPrompt({ tenantId, promptKey });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error("[prompts] Get error:", err);
|
||||
if (err.message.includes("Invalid prompt_key")) {
|
||||
return res.status(400).json({ ok: false, error: "invalid_prompt_key" });
|
||||
}
|
||||
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /prompts/:key - Crea/actualiza un prompt (nueva versión)
|
||||
*/
|
||||
export const makeSavePrompt = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const promptKey = req.params.key;
|
||||
const { content, model, created_by } = req.body || {};
|
||||
|
||||
if (!content) {
|
||||
return res.status(400).json({ ok: false, error: "content_required" });
|
||||
}
|
||||
|
||||
const result = await handleSavePrompt({
|
||||
tenantId,
|
||||
promptKey,
|
||||
content,
|
||||
model,
|
||||
createdBy: created_by || null,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error("[prompts] Save error:", err);
|
||||
if (err.message.includes("Invalid prompt_key")) {
|
||||
return res.status(400).json({ ok: false, error: "invalid_prompt_key" });
|
||||
}
|
||||
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /prompts/:key/rollback/:version - Restaura una versión anterior
|
||||
*/
|
||||
export const makeRollbackPrompt = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const { key, version } = req.params;
|
||||
const { created_by } = req.body || {};
|
||||
|
||||
const result = await handleRollbackPrompt({
|
||||
tenantId,
|
||||
promptKey: key,
|
||||
toVersion: version,
|
||||
createdBy: created_by || null,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error("[prompts] Rollback error:", err);
|
||||
if (err.message.includes("not found")) {
|
||||
return res.status(404).json({ ok: false, error: "version_not_found" });
|
||||
}
|
||||
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /prompts/:key/reset - Resetea al default
|
||||
*/
|
||||
export const makeResetPrompt = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const promptKey = req.params.key;
|
||||
|
||||
const result = await handleResetPrompt({ tenantId, promptKey });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error("[prompts] Reset error:", err);
|
||||
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /prompts/:key/versions/:version - Obtiene contenido de una versión específica
|
||||
*/
|
||||
export const makeGetPromptVersion = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const { key, version } = req.params;
|
||||
|
||||
const result = await handleGetPromptVersion({ tenantId, promptKey: key, version });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error("[prompts] GetVersion error:", err);
|
||||
if (err.message.includes("not found")) {
|
||||
return res.status(404).json({ ok: false, error: "version_not_found" });
|
||||
}
|
||||
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /prompts/:key/test - Prueba un prompt con un mensaje
|
||||
*/
|
||||
export const makeTestPrompt = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const promptKey = req.params.key;
|
||||
const { content, test_message, store_config } = req.body || {};
|
||||
|
||||
if (!test_message) {
|
||||
return res.status(400).json({ ok: false, error: "test_message_required" });
|
||||
}
|
||||
|
||||
const result = await handleTestPrompt({
|
||||
tenantId,
|
||||
promptKey,
|
||||
content,
|
||||
testMessage: test_message,
|
||||
storeConfig: store_config || {},
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error("[prompts] Test error:", err);
|
||||
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
|
||||
}
|
||||
};
|
||||
@@ -1,92 +1,27 @@
|
||||
import {
|
||||
handleListRecentOrders,
|
||||
handleGetProductsWithStock,
|
||||
handleCreateTestOrder,
|
||||
handleCreatePaymentLink,
|
||||
handleSimulateMpWebhook,
|
||||
} from "../handlers/testing.js";
|
||||
import { handleListOrders } from "../handlers/testing.js";
|
||||
import { handleGetOrderStats } from "../handlers/stats.js";
|
||||
|
||||
export const makeListRecentOrders = (tenantIdOrFn) => async (req, res) => {
|
||||
export const makeListOrders = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const result = await handleListRecentOrders({ tenantId, limit });
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
const result = await handleListOrders({ tenantId, page, limit });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error("[testing] listRecentOrders error:", err);
|
||||
console.error("[testing] listOrders error:", err);
|
||||
res.status(500).json({ ok: false, error: err.message || "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
export const makeGetProductsWithStock = (tenantIdOrFn) => async (req, res) => {
|
||||
export const makeGetOrderStats = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const result = await handleGetProductsWithStock({ tenantId });
|
||||
const result = await handleGetOrderStats({ tenantId });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error("[testing] getProductsWithStock error:", err);
|
||||
console.error("[stats] getOrderStats error:", err);
|
||||
res.status(500).json({ ok: false, error: err.message || "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
export const makeCreateTestOrder = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const { basket, address, wa_chat_id } = req.body || {};
|
||||
|
||||
if (!basket?.items?.length) {
|
||||
return res.status(400).json({ ok: false, error: "basket_required" });
|
||||
}
|
||||
|
||||
const result = await handleCreateTestOrder({ tenantId, basket, address, wa_chat_id });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error("[testing] createTestOrder error:", err);
|
||||
res.status(500).json({ ok: false, error: err.message || "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
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" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import { pool } from "../../shared/db/pool.js";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Prompt Templates - CRUD con versionado
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Prompt keys válidos
|
||||
export const PROMPT_KEYS = ["router", "greeting", "orders", "shipping", "payment", "browse"];
|
||||
|
||||
// Modelos por defecto para cada prompt
|
||||
export const DEFAULT_MODELS = {
|
||||
router: "gpt-4o-mini",
|
||||
greeting: "gpt-4-turbo",
|
||||
orders: "gpt-4-turbo",
|
||||
shipping: "gpt-4o-mini",
|
||||
payment: "gpt-4o-mini",
|
||||
browse: "gpt-4-turbo",
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtiene el prompt activo para un tenant y key
|
||||
* @returns {Object|null} { id, prompt_key, content, model, version, is_active, created_at, created_by }
|
||||
*/
|
||||
export async function getActivePrompt({ tenantId, promptKey }) {
|
||||
const sql = `
|
||||
SELECT id, prompt_key, content, model, version, is_active, created_at, created_by
|
||||
FROM prompt_templates
|
||||
WHERE tenant_id = $1 AND prompt_key = $2 AND is_active = true
|
||||
LIMIT 1
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenantId, promptKey]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista todos los prompts activos de un tenant
|
||||
*/
|
||||
export async function listActivePrompts({ tenantId }) {
|
||||
const sql = `
|
||||
SELECT id, prompt_key, content, model, version, is_active, created_at, created_by
|
||||
FROM prompt_templates
|
||||
WHERE tenant_id = $1 AND is_active = true
|
||||
ORDER BY prompt_key
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenantId]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene todas las versiones de un prompt
|
||||
*/
|
||||
export async function getPromptVersions({ tenantId, promptKey, limit = 20 }) {
|
||||
const sql = `
|
||||
SELECT id, prompt_key, content, model, version, is_active, created_at, created_by
|
||||
FROM prompt_templates
|
||||
WHERE tenant_id = $1 AND prompt_key = $2
|
||||
ORDER BY version DESC
|
||||
LIMIT $3
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenantId, promptKey, limit]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene una versión específica de un prompt
|
||||
*/
|
||||
export async function getPromptVersion({ tenantId, promptKey, version }) {
|
||||
const sql = `
|
||||
SELECT id, prompt_key, content, model, version, is_active, created_at, created_by
|
||||
FROM prompt_templates
|
||||
WHERE tenant_id = $1 AND prompt_key = $2 AND version = $3
|
||||
LIMIT 1
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenantId, promptKey, version]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Desactiva el prompt activo actual (para crear nueva versión)
|
||||
*/
|
||||
export async function deactivatePrompt({ tenantId, promptKey }) {
|
||||
const sql = `
|
||||
UPDATE prompt_templates
|
||||
SET is_active = false
|
||||
WHERE tenant_id = $1 AND prompt_key = $2 AND is_active = true
|
||||
`;
|
||||
await pool.query(sql, [tenantId, promptKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea una nueva versión del prompt (automáticamente desactiva la anterior)
|
||||
* @returns {Object} El prompt creado con su versión
|
||||
*/
|
||||
export async function createPrompt({ tenantId, promptKey, content, model, createdBy = null }) {
|
||||
// Validar prompt_key
|
||||
if (!PROMPT_KEYS.includes(promptKey)) {
|
||||
throw new Error(`Invalid prompt_key: ${promptKey}. Valid keys: ${PROMPT_KEYS.join(", ")}`);
|
||||
}
|
||||
|
||||
// Desactivar versión anterior
|
||||
await deactivatePrompt({ tenantId, promptKey });
|
||||
|
||||
// Insertar nueva versión (el trigger calcula la versión automáticamente)
|
||||
const sql = `
|
||||
INSERT INTO prompt_templates (tenant_id, prompt_key, content, model, is_active, created_by)
|
||||
VALUES ($1, $2, $3, $4, true, $5)
|
||||
RETURNING id, prompt_key, content, model, version, is_active, created_at, created_by
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [
|
||||
tenantId,
|
||||
promptKey,
|
||||
content,
|
||||
model || DEFAULT_MODELS[promptKey] || "gpt-4-turbo",
|
||||
createdBy,
|
||||
]);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaura una versión anterior del prompt (crea nueva versión con el contenido antiguo)
|
||||
*/
|
||||
export async function rollbackPrompt({ tenantId, promptKey, toVersion, createdBy = null }) {
|
||||
// Obtener la versión a restaurar
|
||||
const oldVersion = await getPromptVersion({ tenantId, promptKey, version: toVersion });
|
||||
if (!oldVersion) {
|
||||
throw new Error(`Version ${toVersion} not found for prompt ${promptKey}`);
|
||||
}
|
||||
|
||||
// Crear nueva versión con el contenido antiguo
|
||||
return createPrompt({
|
||||
tenantId,
|
||||
promptKey,
|
||||
content: oldVersion.content,
|
||||
model: oldVersion.model,
|
||||
createdBy,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resetea un prompt a su default (desactiva todas las versiones custom)
|
||||
*/
|
||||
export async function resetPromptToDefault({ tenantId, promptKey }) {
|
||||
const sql = `
|
||||
UPDATE prompt_templates
|
||||
SET is_active = false
|
||||
WHERE tenant_id = $1 AND prompt_key = $2
|
||||
`;
|
||||
await pool.query(sql, [tenantId, promptKey]);
|
||||
return { success: true, message: `Prompt ${promptKey} reset to default` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina todas las versiones de un prompt (usar con cuidado)
|
||||
*/
|
||||
export async function deleteAllPromptVersions({ tenantId, promptKey }) {
|
||||
const sql = `
|
||||
DELETE FROM prompt_templates
|
||||
WHERE tenant_id = $1 AND prompt_key = $2
|
||||
RETURNING id
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenantId, promptKey]);
|
||||
return { deleted: rows.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene estadísticas de prompts de un tenant
|
||||
*/
|
||||
export async function getPromptStats({ tenantId }) {
|
||||
const sql = `
|
||||
SELECT
|
||||
prompt_key,
|
||||
COUNT(*) as total_versions,
|
||||
MAX(version) as latest_version,
|
||||
MAX(CASE WHEN is_active THEN version END) as active_version,
|
||||
MAX(created_at) as last_updated
|
||||
FROM prompt_templates
|
||||
WHERE tenant_id = $1
|
||||
GROUP BY prompt_key
|
||||
ORDER BY prompt_key
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenantId]);
|
||||
return rows;
|
||||
}
|
||||
@@ -3,23 +3,25 @@ import { pool } from "../../shared/db/pool.js";
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Tenant Settings - CRUD
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Modelo actual:
|
||||
// - Datos del comercio: store_name, bot_name, store_address, store_phone.
|
||||
// - Pickup (retiro en tienda): pickup_enabled + pickup_days CSV +
|
||||
// pickup_hours_start/end TIME, y `schedule.pickup` JSONB para horario por día.
|
||||
// - Delivery: TODO vive en `delivery_zones.zones[]` (polígonos GeoJSON con
|
||||
// costo, días y rango horario por zona). El bot valida zona usando la
|
||||
// ubicación que el cliente comparte por WhatsApp.
|
||||
|
||||
/**
|
||||
* Obtiene la configuración del tenant
|
||||
*/
|
||||
export async function getSettings({ tenantId }) {
|
||||
const sql = `
|
||||
SELECT
|
||||
SELECT
|
||||
id, tenant_id,
|
||||
store_name, bot_name, store_address, store_phone,
|
||||
delivery_enabled, delivery_days,
|
||||
delivery_hours_start::text as delivery_hours_start,
|
||||
delivery_hours_end::text as delivery_hours_end,
|
||||
delivery_min_order,
|
||||
pickup_enabled, pickup_days,
|
||||
pickup_hours_start::text as pickup_hours_start,
|
||||
pickup_hours_end::text as pickup_hours_end,
|
||||
schedule,
|
||||
delivery_zones,
|
||||
created_at, updated_at
|
||||
FROM tenant_settings
|
||||
WHERE tenant_id = $1
|
||||
@@ -29,62 +31,47 @@ export async function getSettings({ tenantId }) {
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea o actualiza la configuración del tenant (upsert)
|
||||
*/
|
||||
export async function upsertSettings({ tenantId, settings }) {
|
||||
const {
|
||||
store_name,
|
||||
bot_name,
|
||||
store_address,
|
||||
store_phone,
|
||||
delivery_enabled,
|
||||
delivery_days,
|
||||
delivery_hours_start,
|
||||
delivery_hours_end,
|
||||
delivery_min_order,
|
||||
pickup_enabled,
|
||||
pickup_days,
|
||||
pickup_hours_start,
|
||||
pickup_hours_end,
|
||||
schedule,
|
||||
delivery_zones,
|
||||
} = settings;
|
||||
|
||||
const sql = `
|
||||
INSERT INTO tenant_settings (
|
||||
tenant_id, store_name, bot_name, store_address, store_phone,
|
||||
delivery_enabled, delivery_days, delivery_hours_start, delivery_hours_end, delivery_min_order,
|
||||
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)
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
store_name = COALESCE(EXCLUDED.store_name, tenant_settings.store_name),
|
||||
bot_name = COALESCE(EXCLUDED.bot_name, tenant_settings.bot_name),
|
||||
store_address = COALESCE(EXCLUDED.store_address, tenant_settings.store_address),
|
||||
store_phone = COALESCE(EXCLUDED.store_phone, tenant_settings.store_phone),
|
||||
delivery_enabled = COALESCE(EXCLUDED.delivery_enabled, tenant_settings.delivery_enabled),
|
||||
delivery_days = COALESCE(EXCLUDED.delivery_days, tenant_settings.delivery_days),
|
||||
delivery_hours_start = COALESCE(EXCLUDED.delivery_hours_start, tenant_settings.delivery_hours_start),
|
||||
delivery_hours_end = COALESCE(EXCLUDED.delivery_hours_end, tenant_settings.delivery_hours_end),
|
||||
delivery_min_order = COALESCE(EXCLUDED.delivery_min_order, tenant_settings.delivery_min_order),
|
||||
pickup_enabled = COALESCE(EXCLUDED.pickup_enabled, tenant_settings.pickup_enabled),
|
||||
pickup_days = COALESCE(EXCLUDED.pickup_days, tenant_settings.pickup_days),
|
||||
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),
|
||||
schedule = COALESCE(EXCLUDED.schedule, tenant_settings.schedule),
|
||||
delivery_zones = COALESCE(EXCLUDED.delivery_zones, tenant_settings.delivery_zones),
|
||||
updated_at = NOW()
|
||||
RETURNING
|
||||
RETURNING
|
||||
id, tenant_id,
|
||||
store_name, bot_name, store_address, store_phone,
|
||||
delivery_enabled, delivery_days,
|
||||
delivery_hours_start::text as delivery_hours_start,
|
||||
delivery_hours_end::text as delivery_hours_end,
|
||||
delivery_min_order,
|
||||
pickup_enabled, pickup_days,
|
||||
pickup_hours_start::text as pickup_hours_start,
|
||||
pickup_hours_end::text as pickup_hours_end,
|
||||
schedule,
|
||||
delivery_zones,
|
||||
created_at, updated_at
|
||||
`;
|
||||
|
||||
@@ -94,164 +81,110 @@ export async function upsertSettings({ tenantId, settings }) {
|
||||
bot_name || null,
|
||||
store_address || null,
|
||||
store_phone || null,
|
||||
delivery_enabled ?? null,
|
||||
delivery_days || null,
|
||||
delivery_hours_start || null,
|
||||
delivery_hours_end || null,
|
||||
delivery_min_order ?? null,
|
||||
pickup_enabled ?? null,
|
||||
pickup_days || null,
|
||||
pickup_hours_start || null,
|
||||
pickup_hours_end || 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);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea horarios desde schedule JSONB para mostrar de forma natural
|
||||
* Agrupa días con mismos horarios: "Lun a Vie de 9 a 14hs, Sáb de 9 a 13hs"
|
||||
* Formatea schedule.pickup ({ lun: { start, end } }) en prosa para mostrar al
|
||||
* cliente. Agrupa días con mismos horarios: "Lun a Vie de 9 a 14hs".
|
||||
*/
|
||||
function formatScheduleHours(scheduleType, enabled) {
|
||||
if (!enabled || !scheduleType || typeof scheduleType !== "object") {
|
||||
function formatScheduleHours(scheduleObj, enabled) {
|
||||
if (!enabled || !scheduleObj || typeof scheduleObj !== "object") {
|
||||
return enabled === false ? "No disponible" : "";
|
||||
}
|
||||
|
||||
const dayOrder = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
|
||||
const dayNames = {
|
||||
lun: "Lunes", mar: "Martes", mie: "Miércoles",
|
||||
jue: "Jueves", vie: "Viernes", sab: "Sábado", dom: "Domingo"
|
||||
lun: "Lunes", mar: "Martes", mie: "Miércoles",
|
||||
jue: "Jueves", vie: "Viernes", sab: "Sábado", dom: "Domingo",
|
||||
};
|
||||
|
||||
// Agrupar días por horario
|
||||
const groups = {};
|
||||
for (const day of dayOrder) {
|
||||
const slot = scheduleType[day];
|
||||
const slot = scheduleObj[day];
|
||||
if (!slot || !slot.start || !slot.end) continue;
|
||||
|
||||
const key = `${slot.start}-${slot.end}`;
|
||||
if (!groups[key]) {
|
||||
groups[key] = { start: slot.start, end: slot.end, days: [] };
|
||||
}
|
||||
if (!groups[key]) groups[key] = { start: slot.start, end: slot.end, days: [] };
|
||||
groups[key].days.push(day);
|
||||
}
|
||||
|
||||
if (Object.keys(groups).length === 0) return "";
|
||||
if (!Object.keys(groups).length) return "";
|
||||
|
||||
// Formatear cada grupo
|
||||
const parts = Object.values(groups).map(g => {
|
||||
const parts = Object.values(groups).map((g) => {
|
||||
const days = g.days;
|
||||
let dayStr;
|
||||
|
||||
// Detectar rangos consecutivos
|
||||
if (days.length >= 3) {
|
||||
const indices = days.map(d => dayOrder.indexOf(d));
|
||||
const isConsecutive = indices.every((v, i, arr) => i === 0 || v === arr[i-1] + 1);
|
||||
if (isConsecutive) {
|
||||
dayStr = `${dayNames[days[0]]} a ${dayNames[days[days.length-1]]}`;
|
||||
} else {
|
||||
dayStr = days.map(d => dayNames[d]).join(", ");
|
||||
}
|
||||
const indices = days.map((d) => dayOrder.indexOf(d));
|
||||
const isConsecutive = indices.every((v, i, arr) => i === 0 || v === arr[i - 1] + 1);
|
||||
dayStr = isConsecutive
|
||||
? `${dayNames[days[0]]} a ${dayNames[days[days.length - 1]]}`
|
||||
: days.map((d) => dayNames[d]).join(", ");
|
||||
} else if (days.length === 2) {
|
||||
dayStr = `${dayNames[days[0]]} y ${dayNames[days[1]]}`;
|
||||
} else {
|
||||
dayStr = dayNames[days[0]];
|
||||
}
|
||||
|
||||
const startH = g.start.slice(0, 5);
|
||||
const endH = g.end.slice(0, 5);
|
||||
return `${dayStr} de ${startH} a ${endH}`;
|
||||
return `${dayStr} de ${g.start.slice(0, 5)} a ${g.end.slice(0, 5)}`;
|
||||
});
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function formatLegacyPickupHours(enabled, days, start, end) {
|
||||
if (!enabled) return "No disponible";
|
||||
if (!days || !start || !end) return "";
|
||||
const daysFormatted = days.split(",").map((d) => d.trim()).join(", ");
|
||||
return `${daysFormatted} de ${start.slice(0, 5)} a ${end.slice(0, 5)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la configuración formateada para usar en prompts (storeConfig)
|
||||
* Forma de la storeConfig que consume el agente y su workingMemory.
|
||||
*/
|
||||
export async function getStoreConfig({ tenantId }) {
|
||||
const settings = await getSettings({ tenantId });
|
||||
|
||||
|
||||
if (!settings) {
|
||||
// Valores por defecto si no hay configuración
|
||||
return {
|
||||
name: "la carnicería",
|
||||
botName: "Piaf",
|
||||
hours: "",
|
||||
address: "",
|
||||
phone: "",
|
||||
deliveryHours: "",
|
||||
pickupHours: "",
|
||||
schedule: null,
|
||||
delivery_zones: {},
|
||||
};
|
||||
}
|
||||
|
||||
const schedule = settings.schedule || {};
|
||||
|
||||
// Usar nuevo formato schedule si existe, sino legacy
|
||||
let deliveryHours, pickupHours;
|
||||
|
||||
if (schedule.delivery && Object.keys(schedule.delivery).length > 0) {
|
||||
deliveryHours = formatScheduleHours(schedule.delivery, settings.delivery_enabled);
|
||||
} else {
|
||||
// Legacy format
|
||||
deliveryHours = formatLegacyHours(
|
||||
settings.delivery_enabled,
|
||||
settings.delivery_days,
|
||||
settings.delivery_hours_start,
|
||||
settings.delivery_hours_end
|
||||
);
|
||||
}
|
||||
|
||||
if (schedule.pickup && Object.keys(schedule.pickup).length > 0) {
|
||||
pickupHours = formatScheduleHours(schedule.pickup, settings.pickup_enabled);
|
||||
} else {
|
||||
// Legacy format
|
||||
pickupHours = formatLegacyHours(
|
||||
settings.pickup_enabled,
|
||||
settings.pickup_days,
|
||||
settings.pickup_hours_start,
|
||||
settings.pickup_hours_end
|
||||
);
|
||||
}
|
||||
|
||||
// Combinar horarios para store_hours (usa pickup como horario de tienda)
|
||||
let storeHours = "";
|
||||
if (settings.pickup_enabled) {
|
||||
storeHours = pickupHours;
|
||||
}
|
||||
const pickupHours = schedule.pickup && Object.keys(schedule.pickup).length
|
||||
? formatScheduleHours(schedule.pickup, settings.pickup_enabled)
|
||||
: formatLegacyPickupHours(
|
||||
settings.pickup_enabled,
|
||||
settings.pickup_days,
|
||||
settings.pickup_hours_start,
|
||||
settings.pickup_hours_end,
|
||||
);
|
||||
|
||||
return {
|
||||
name: settings.store_name || "la carnicería",
|
||||
botName: settings.bot_name || "Piaf",
|
||||
hours: storeHours,
|
||||
hours: settings.pickup_enabled ? pickupHours : "",
|
||||
address: settings.store_address || "",
|
||||
phone: settings.store_phone || "",
|
||||
deliveryHours,
|
||||
pickupHours,
|
||||
deliveryEnabled: settings.delivery_enabled,
|
||||
pickupEnabled: settings.pickup_enabled,
|
||||
schedule,
|
||||
// Campos legacy para compatibilidad
|
||||
delivery_days: settings.delivery_days,
|
||||
delivery_hours_start: settings.delivery_hours_start,
|
||||
delivery_hours_end: settings.delivery_hours_end,
|
||||
delivery_zones: settings.delivery_zones || {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatear horarios en formato legacy (días + rango único)
|
||||
*/
|
||||
function formatLegacyHours(enabled, days, start, end) {
|
||||
if (!enabled) return "No disponible";
|
||||
if (!days || !start || !end) return "";
|
||||
|
||||
const daysFormatted = days.split(",").map(d => d.trim()).join(", ");
|
||||
const startFormatted = start?.slice(0, 5) || "";
|
||||
const endFormatted = end?.slice(0, 5) || "";
|
||||
|
||||
return `${daysFormatted} de ${startFormatted} a ${endFormatted}`;
|
||||
}
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
import {
|
||||
getActivePrompt,
|
||||
listActivePrompts,
|
||||
getPromptVersions,
|
||||
getPromptVersion,
|
||||
createPrompt,
|
||||
rollbackPrompt,
|
||||
resetPromptToDefault,
|
||||
getPromptStats,
|
||||
PROMPT_KEYS,
|
||||
DEFAULT_MODELS,
|
||||
} from "../db/promptsRepo.js";
|
||||
import { loadDefaultPrompt, AVAILABLE_VARIABLES, invalidatePromptCache } from "../../3-turn-engine/nlu/promptLoader.js";
|
||||
|
||||
/**
|
||||
* Lista todos los prompts del tenant (activos + defaults para los que no tienen custom)
|
||||
*/
|
||||
export async function handleListPrompts({ tenantId }) {
|
||||
const activePrompts = await listActivePrompts({ tenantId });
|
||||
const stats = await getPromptStats({ tenantId });
|
||||
|
||||
// Construir lista completa con defaults para los que no tienen custom
|
||||
const promptsMap = new Map(activePrompts.map(p => [p.prompt_key, p]));
|
||||
|
||||
const items = PROMPT_KEYS.map(key => {
|
||||
const custom = promptsMap.get(key);
|
||||
const stat = stats.find(s => s.prompt_key === key);
|
||||
|
||||
if (custom) {
|
||||
return {
|
||||
prompt_key: key,
|
||||
content: custom.content,
|
||||
model: custom.model,
|
||||
version: custom.version,
|
||||
is_default: false,
|
||||
total_versions: stat?.total_versions || 1,
|
||||
last_updated: custom.created_at,
|
||||
created_by: custom.created_by,
|
||||
};
|
||||
} else {
|
||||
// Cargar default
|
||||
let defaultContent = "";
|
||||
try {
|
||||
defaultContent = loadDefaultPrompt(key);
|
||||
} catch (e) {
|
||||
defaultContent = `[Error loading default: ${e.message}]`;
|
||||
}
|
||||
|
||||
return {
|
||||
prompt_key: key,
|
||||
content: defaultContent,
|
||||
model: DEFAULT_MODELS[key] || "gpt-4-turbo",
|
||||
version: null,
|
||||
is_default: true,
|
||||
total_versions: stat?.total_versions || 0,
|
||||
last_updated: null,
|
||||
created_by: null,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
available_variables: AVAILABLE_VARIABLES,
|
||||
available_models: ["gpt-4-turbo", "gpt-4o-mini", "gpt-4o", "gpt-3.5-turbo"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene un prompt específico con su historial de versiones
|
||||
*/
|
||||
export async function handleGetPrompt({ tenantId, promptKey }) {
|
||||
if (!PROMPT_KEYS.includes(promptKey)) {
|
||||
throw new Error(`Invalid prompt_key: ${promptKey}`);
|
||||
}
|
||||
|
||||
const current = await getActivePrompt({ tenantId, promptKey });
|
||||
const versions = await getPromptVersions({ tenantId, promptKey, limit: 20 });
|
||||
|
||||
let defaultContent = "";
|
||||
try {
|
||||
defaultContent = loadDefaultPrompt(promptKey);
|
||||
} catch (e) {
|
||||
defaultContent = `[Error: ${e.message}]`;
|
||||
}
|
||||
|
||||
return {
|
||||
prompt_key: promptKey,
|
||||
current: current || {
|
||||
content: defaultContent,
|
||||
model: DEFAULT_MODELS[promptKey] || "gpt-4-turbo",
|
||||
version: null,
|
||||
is_default: true,
|
||||
},
|
||||
default_content: defaultContent,
|
||||
default_model: DEFAULT_MODELS[promptKey] || "gpt-4-turbo",
|
||||
versions: versions.map(v => ({
|
||||
version: v.version,
|
||||
is_active: v.is_active,
|
||||
created_at: v.created_at,
|
||||
created_by: v.created_by,
|
||||
content_preview: v.content.slice(0, 100) + (v.content.length > 100 ? "..." : ""),
|
||||
})),
|
||||
available_variables: AVAILABLE_VARIABLES,
|
||||
available_models: ["gpt-4-turbo", "gpt-4o-mini", "gpt-4o", "gpt-3.5-turbo"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea o actualiza un prompt (crea nueva versión)
|
||||
*/
|
||||
export async function handleSavePrompt({ tenantId, promptKey, content, model, createdBy }) {
|
||||
if (!PROMPT_KEYS.includes(promptKey)) {
|
||||
throw new Error(`Invalid prompt_key: ${promptKey}`);
|
||||
}
|
||||
|
||||
if (!content || content.trim().length === 0) {
|
||||
throw new Error("Content is required");
|
||||
}
|
||||
|
||||
const result = await createPrompt({
|
||||
tenantId,
|
||||
promptKey,
|
||||
content: content.trim(),
|
||||
model: model || DEFAULT_MODELS[promptKey] || "gpt-4-turbo",
|
||||
createdBy,
|
||||
});
|
||||
|
||||
// Invalidar cache
|
||||
invalidatePromptCache(tenantId, promptKey);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: result,
|
||||
message: `Prompt ${promptKey} saved as version ${result.version}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaura una versión anterior del prompt
|
||||
*/
|
||||
export async function handleRollbackPrompt({ tenantId, promptKey, toVersion, createdBy }) {
|
||||
if (!PROMPT_KEYS.includes(promptKey)) {
|
||||
throw new Error(`Invalid prompt_key: ${promptKey}`);
|
||||
}
|
||||
|
||||
const result = await rollbackPrompt({
|
||||
tenantId,
|
||||
promptKey,
|
||||
toVersion: parseInt(toVersion, 10),
|
||||
createdBy,
|
||||
});
|
||||
|
||||
// Invalidar cache
|
||||
invalidatePromptCache(tenantId, promptKey);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: result,
|
||||
message: `Prompt ${promptKey} rolled back to version ${toVersion}, new version is ${result.version}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resetea un prompt al default (desactiva todas las versiones custom)
|
||||
*/
|
||||
export async function handleResetPrompt({ tenantId, promptKey }) {
|
||||
if (!PROMPT_KEYS.includes(promptKey)) {
|
||||
throw new Error(`Invalid prompt_key: ${promptKey}`);
|
||||
}
|
||||
|
||||
await resetPromptToDefault({ tenantId, promptKey });
|
||||
|
||||
// Invalidar cache
|
||||
invalidatePromptCache(tenantId, promptKey);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: `Prompt ${promptKey} reset to default`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el contenido de una versión específica
|
||||
*/
|
||||
export async function handleGetPromptVersion({ tenantId, promptKey, version }) {
|
||||
if (!PROMPT_KEYS.includes(promptKey)) {
|
||||
throw new Error(`Invalid prompt_key: ${promptKey}`);
|
||||
}
|
||||
|
||||
const versionData = await getPromptVersion({
|
||||
tenantId,
|
||||
promptKey,
|
||||
version: parseInt(version, 10)
|
||||
});
|
||||
|
||||
if (!versionData) {
|
||||
throw new Error(`Version ${version} not found for prompt ${promptKey}`);
|
||||
}
|
||||
|
||||
return { item: versionData };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueba un prompt con un mensaje de ejemplo
|
||||
*/
|
||||
export async function handleTestPrompt({ tenantId, promptKey, content, testMessage, storeConfig = {} }) {
|
||||
// Importar dinámicamente para evitar dependencias circulares
|
||||
const { loadPrompt } = await import("../../3-turn-engine/nlu/promptLoader.js");
|
||||
|
||||
// Si se proporciona content, usarlo directamente
|
||||
// Si no, cargar el prompt actual
|
||||
let promptContent = content;
|
||||
let model = DEFAULT_MODELS[promptKey] || "gpt-4-turbo";
|
||||
|
||||
if (!promptContent) {
|
||||
const loaded = await loadPrompt({ tenantId, promptKey, variables: storeConfig });
|
||||
promptContent = loaded.content;
|
||||
model = loaded.model;
|
||||
} else {
|
||||
// Aplicar variables al content proporcionado
|
||||
for (const [key, value] of Object.entries(storeConfig)) {
|
||||
promptContent = promptContent.replace(new RegExp(`{{${key}}}`, "g"), value || "");
|
||||
}
|
||||
promptContent = promptContent.replace(/\{\{[^}]+\}\}/g, "");
|
||||
}
|
||||
|
||||
// Importar OpenAI
|
||||
const OpenAI = (await import("openai")).default;
|
||||
const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("OPENAI_API_KEY not configured");
|
||||
}
|
||||
|
||||
const openai = new OpenAI({ apiKey });
|
||||
|
||||
// Hacer la llamada de prueba
|
||||
const startTime = Date.now();
|
||||
const response = await openai.chat.completions.create({
|
||||
model,
|
||||
temperature: 0.2,
|
||||
max_tokens: 500,
|
||||
response_format: { type: "json_object" },
|
||||
messages: [
|
||||
{ role: "system", content: promptContent },
|
||||
{ role: "user", content: testMessage },
|
||||
],
|
||||
});
|
||||
const endTime = Date.now();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
response: response?.choices?.[0]?.message?.content || "",
|
||||
model,
|
||||
usage: response?.usage,
|
||||
latency_ms: endTime - startTime,
|
||||
};
|
||||
}
|
||||
@@ -1,233 +1,139 @@
|
||||
import { getSettings, upsertSettings, getStoreConfig } from "../db/settingsRepo.js";
|
||||
|
||||
// Días de la semana para validación
|
||||
const VALID_DAYS = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
|
||||
|
||||
/**
|
||||
* Genera schedule por defecto con horarios uniformes
|
||||
*/
|
||||
function createDefaultSchedule() {
|
||||
const defaultDays = ["lun", "mar", "mie", "jue", "vie", "sab"];
|
||||
const delivery = {};
|
||||
function defaultPickupSchedule() {
|
||||
const days = ["lun", "mar", "mie", "jue", "vie", "sab"];
|
||||
const pickup = {};
|
||||
|
||||
for (const day of defaultDays) {
|
||||
delivery[day] = { start: "09:00", end: "18:00" };
|
||||
pickup[day] = { start: "08:00", end: "20:00" };
|
||||
}
|
||||
|
||||
return { delivery, pickup };
|
||||
for (const d of days) pickup[d] = { start: "08:00", end: "20:00" };
|
||||
return { pickup };
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la configuración actual del tenant
|
||||
*/
|
||||
export async function handleGetSettings({ tenantId }) {
|
||||
const settings = await getSettings({ tenantId });
|
||||
|
||||
// Si no hay configuración, devolver defaults
|
||||
|
||||
if (!settings) {
|
||||
return {
|
||||
store_name: "Mi Negocio",
|
||||
bot_name: "Piaf",
|
||||
store_address: "",
|
||||
store_phone: "",
|
||||
delivery_enabled: true,
|
||||
delivery_days: "lun,mar,mie,jue,vie,sab",
|
||||
delivery_hours_start: "09:00",
|
||||
delivery_hours_end: "18:00",
|
||||
delivery_min_order: 0,
|
||||
pickup_enabled: true,
|
||||
pickup_days: "lun,mar,mie,jue,vie,sab",
|
||||
pickup_hours_start: "08:00",
|
||||
pickup_hours_end: "20:00",
|
||||
schedule: createDefaultSchedule(),
|
||||
schedule: defaultPickupSchedule(),
|
||||
delivery_zones: {},
|
||||
is_default: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Si no tiene schedule, generar desde datos legacy
|
||||
// Si schedule está vacío pero tenemos los campos legacy de pickup, generar
|
||||
// schedule.pickup para que la UI pueda editar el grid por día.
|
||||
let schedule = settings.schedule;
|
||||
if (!schedule || Object.keys(schedule).length === 0) {
|
||||
schedule = buildScheduleFromLegacy(settings);
|
||||
if (!schedule || !schedule.pickup || !Object.keys(schedule.pickup).length) {
|
||||
schedule = buildPickupScheduleFromLegacy(settings);
|
||||
}
|
||||
|
||||
return {
|
||||
...settings,
|
||||
// Formatear horarios TIME a HH:MM
|
||||
delivery_hours_start: settings.delivery_hours_start?.slice(0, 5) || "09:00",
|
||||
delivery_hours_end: settings.delivery_hours_end?.slice(0, 5) || "18: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",
|
||||
schedule,
|
||||
delivery_zones: settings.delivery_zones || {},
|
||||
is_default: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye schedule desde datos legacy
|
||||
*/
|
||||
function buildScheduleFromLegacy(settings) {
|
||||
const schedule = { delivery: {}, pickup: {} };
|
||||
|
||||
// Delivery
|
||||
if (settings.delivery_enabled && settings.delivery_days) {
|
||||
const days = settings.delivery_days.split(",").map(d => d.trim());
|
||||
const start = settings.delivery_hours_start?.slice(0, 5) || "09:00";
|
||||
const end = settings.delivery_hours_end?.slice(0, 5) || "18:00";
|
||||
for (const day of days) {
|
||||
if (VALID_DAYS.includes(day)) {
|
||||
schedule.delivery[day] = { start, end };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pickup
|
||||
if (settings.pickup_enabled && settings.pickup_days) {
|
||||
const days = settings.pickup_days.split(",").map(d => d.trim());
|
||||
function buildPickupScheduleFromLegacy(settings) {
|
||||
const out = { pickup: {} };
|
||||
if (settings?.pickup_enabled && settings?.pickup_days) {
|
||||
const days = settings.pickup_days.split(",").map((d) => d.trim());
|
||||
const start = settings.pickup_hours_start?.slice(0, 5) || "08:00";
|
||||
const end = settings.pickup_hours_end?.slice(0, 5) || "20:00";
|
||||
for (const day of days) {
|
||||
if (VALID_DAYS.includes(day)) {
|
||||
schedule.pickup[day] = { start, end };
|
||||
}
|
||||
if (VALID_DAYS.includes(day)) out.pickup[day] = { start, end };
|
||||
}
|
||||
}
|
||||
|
||||
return schedule;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida la estructura del schedule
|
||||
*/
|
||||
function validateSchedule(schedule) {
|
||||
if (!schedule || typeof schedule !== "object") return;
|
||||
|
||||
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
|
||||
|
||||
for (const type of ["delivery", "pickup"]) {
|
||||
const typeSchedule = schedule[type];
|
||||
if (!typeSchedule || typeof typeSchedule !== "object") continue;
|
||||
|
||||
for (const [day, slot] of Object.entries(typeSchedule)) {
|
||||
if (!VALID_DAYS.includes(day)) {
|
||||
throw new Error(`Invalid day in schedule.${type}: ${day}`);
|
||||
}
|
||||
|
||||
if (slot === null) continue; // null = no disponible
|
||||
|
||||
if (typeof slot !== "object" || !slot.start || !slot.end) {
|
||||
throw new Error(`Invalid slot format for ${type}.${day}`);
|
||||
}
|
||||
|
||||
if (!timeRegex.test(slot.start)) {
|
||||
throw new Error(`Invalid start time for ${type}.${day}: ${slot.start}`);
|
||||
}
|
||||
if (!timeRegex.test(slot.end)) {
|
||||
throw new Error(`Invalid end time for ${type}.${day}: ${slot.end}`);
|
||||
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/;
|
||||
const pickup = schedule.pickup;
|
||||
if (!pickup || typeof pickup !== "object") return;
|
||||
for (const [day, slot] of Object.entries(pickup)) {
|
||||
if (!VALID_DAYS.includes(day)) throw new Error(`Invalid day in schedule.pickup: ${day}`);
|
||||
if (slot === null) continue;
|
||||
if (typeof slot !== "object" || !slot.start || !slot.end) {
|
||||
throw new Error(`Invalid slot format for pickup.${day}`);
|
||||
}
|
||||
if (!timeRegex.test(slot.start)) throw new Error(`Invalid start time for pickup.${day}: ${slot.start}`);
|
||||
if (!timeRegex.test(slot.end)) throw new Error(`Invalid end time for pickup.${day}: ${slot.end}`);
|
||||
}
|
||||
}
|
||||
|
||||
function syncPickupLegacyFromSchedule(settings) {
|
||||
const pickup = settings?.schedule?.pickup;
|
||||
if (!pickup) return;
|
||||
const days = Object.keys(pickup).filter((d) => pickup[d] && pickup[d].start && pickup[d].end);
|
||||
days.sort((a, b) => VALID_DAYS.indexOf(a) - VALID_DAYS.indexOf(b));
|
||||
if (days.length) {
|
||||
settings.pickup_days = days.join(",");
|
||||
const first = pickup[days[0]];
|
||||
settings.pickup_hours_start = first.start;
|
||||
settings.pickup_hours_end = first.end;
|
||||
} else {
|
||||
settings.pickup_days = "";
|
||||
}
|
||||
}
|
||||
|
||||
function validateDeliveryZones(dz) {
|
||||
if (!dz || typeof dz !== "object") return;
|
||||
if (dz.zones && !Array.isArray(dz.zones)) {
|
||||
throw new Error("delivery_zones.zones must be an array");
|
||||
}
|
||||
for (const z of dz.zones || []) {
|
||||
if (!z?.id || !z?.name) throw new Error("Each zone needs id + name");
|
||||
if (z.polygon && (z.polygon.type !== "Polygon" || !Array.isArray(z.polygon.coordinates))) {
|
||||
throw new Error(`Invalid polygon GeoJSON for zone ${z.id}`);
|
||||
}
|
||||
if (Array.isArray(z.delivery_days)) {
|
||||
for (const d of z.delivery_days) {
|
||||
if (!VALID_DAYS.includes(d)) throw new Error(`Invalid delivery day in zone ${z.id}: ${d}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincroniza campos legacy desde schedule
|
||||
*/
|
||||
function syncLegacyFromSchedule(settings) {
|
||||
const schedule = settings.schedule;
|
||||
if (!schedule) return;
|
||||
|
||||
// Sincronizar delivery
|
||||
if (schedule.delivery) {
|
||||
const deliveryDays = Object.keys(schedule.delivery).filter(d => schedule.delivery[d] !== null);
|
||||
if (deliveryDays.length > 0) {
|
||||
// Ordenar días
|
||||
deliveryDays.sort((a, b) => VALID_DAYS.indexOf(a) - VALID_DAYS.indexOf(b));
|
||||
settings.delivery_days = deliveryDays.join(",");
|
||||
|
||||
// Usar primer horario como legacy
|
||||
const firstSlot = schedule.delivery[deliveryDays[0]];
|
||||
if (firstSlot) {
|
||||
settings.delivery_hours_start = firstSlot.start;
|
||||
settings.delivery_hours_end = firstSlot.end;
|
||||
}
|
||||
} else {
|
||||
settings.delivery_days = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Sincronizar pickup
|
||||
if (schedule.pickup) {
|
||||
const pickupDays = Object.keys(schedule.pickup).filter(d => schedule.pickup[d] !== null);
|
||||
if (pickupDays.length > 0) {
|
||||
pickupDays.sort((a, b) => VALID_DAYS.indexOf(a) - VALID_DAYS.indexOf(b));
|
||||
settings.pickup_days = pickupDays.join(",");
|
||||
|
||||
const firstSlot = schedule.pickup[pickupDays[0]];
|
||||
if (firstSlot) {
|
||||
settings.pickup_hours_start = firstSlot.start;
|
||||
settings.pickup_hours_end = firstSlot.end;
|
||||
}
|
||||
} else {
|
||||
settings.pickup_days = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarda la configuración del tenant
|
||||
*/
|
||||
export async function handleSaveSettings({ tenantId, settings }) {
|
||||
// Validaciones básicas
|
||||
if (!settings.store_name?.trim()) {
|
||||
throw new Error("store_name is required");
|
||||
}
|
||||
if (!settings.bot_name?.trim()) {
|
||||
throw new Error("bot_name is required");
|
||||
}
|
||||
if (!settings.store_name?.trim()) throw new Error("store_name is required");
|
||||
if (!settings.bot_name?.trim()) throw new Error("bot_name is required");
|
||||
|
||||
// Validar schedule si viene
|
||||
if (settings.schedule) {
|
||||
validateSchedule(settings.schedule);
|
||||
// Sincronizar campos legacy desde schedule
|
||||
syncLegacyFromSchedule(settings);
|
||||
} else {
|
||||
// Legacy: validar días individuales
|
||||
if (settings.delivery_days) {
|
||||
const days = settings.delivery_days.split(",").map(d => d.trim().toLowerCase());
|
||||
for (const day of days) {
|
||||
if (!VALID_DAYS.includes(day)) {
|
||||
throw new Error(`Invalid delivery day: ${day}`);
|
||||
}
|
||||
}
|
||||
settings.delivery_days = days.join(",");
|
||||
}
|
||||
syncPickupLegacyFromSchedule(settings);
|
||||
}
|
||||
|
||||
if (settings.pickup_days) {
|
||||
const days = settings.pickup_days.split(",").map(d => d.trim().toLowerCase());
|
||||
for (const day of days) {
|
||||
if (!VALID_DAYS.includes(day)) {
|
||||
throw new Error(`Invalid pickup day: ${day}`);
|
||||
}
|
||||
}
|
||||
settings.pickup_days = days.join(",");
|
||||
}
|
||||
if (settings.delivery_zones) {
|
||||
validateDeliveryZones(settings.delivery_zones);
|
||||
}
|
||||
|
||||
// Validar horarios legacy
|
||||
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
|
||||
|
||||
if (settings.delivery_hours_start && !timeRegex.test(settings.delivery_hours_start)) {
|
||||
throw new Error("Invalid delivery_hours_start format (use HH:MM)");
|
||||
}
|
||||
if (settings.delivery_hours_end && !timeRegex.test(settings.delivery_hours_end)) {
|
||||
throw new Error("Invalid delivery_hours_end format (use HH:MM)");
|
||||
}
|
||||
if (settings.pickup_hours_start && !timeRegex.test(settings.pickup_hours_start)) {
|
||||
throw new Error("Invalid pickup_hours_start format (use HH:MM)");
|
||||
}
|
||||
if (settings.pickup_hours_end && !timeRegex.test(settings.pickup_hours_end)) {
|
||||
throw new Error("Invalid pickup_hours_end format (use HH:MM)");
|
||||
if (settings.pickup_days) {
|
||||
const days = settings.pickup_days.split(",").map((d) => d.trim().toLowerCase());
|
||||
for (const d of days) {
|
||||
if (!VALID_DAYS.includes(d)) throw new Error(`Invalid pickup day: ${d}`);
|
||||
}
|
||||
settings.pickup_days = days.join(",");
|
||||
}
|
||||
|
||||
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
|
||||
if (settings.pickup_hours_start && !timeRegex.test(settings.pickup_hours_start)) {
|
||||
throw new Error("Invalid pickup_hours_start format (use HH:MM)");
|
||||
}
|
||||
if (settings.pickup_hours_end && !timeRegex.test(settings.pickup_hours_end)) {
|
||||
throw new Error("Invalid pickup_hours_end format (use HH:MM)");
|
||||
}
|
||||
|
||||
const result = await upsertSettings({ tenantId, settings });
|
||||
@@ -236,18 +142,14 @@ export async function handleSaveSettings({ tenantId, settings }) {
|
||||
ok: true,
|
||||
settings: {
|
||||
...result,
|
||||
delivery_hours_start: result.delivery_hours_start?.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_end: result.pickup_hours_end?.slice(0, 5),
|
||||
delivery_zones: result.delivery_zones || {},
|
||||
},
|
||||
message: "Configuración guardada correctamente",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el storeConfig formateado para prompts
|
||||
*/
|
||||
export async function handleGetStoreConfig({ tenantId }) {
|
||||
return await getStoreConfig({ tenantId });
|
||||
}
|
||||
|
||||
44
src/modules/0-ui/handlers/stats.js
Normal file
44
src/modules/0-ui/handlers/stats.js
Normal file
@@ -0,0 +1,44 @@
|
||||
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 }) {
|
||||
// Sync en background — no bloqueamos el request
|
||||
const syncPromise = syncOrdersIncremental({ tenantId }).catch(err =>
|
||||
console.error("[stats] sync error:", err)
|
||||
);
|
||||
|
||||
// Respondemos con lo que hay en DB mientras sincroniza
|
||||
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,
|
||||
|
||||
// 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 (sincronizando en background)
|
||||
synced: 0,
|
||||
total_in_cache: totals.total_orders ?? 0,
|
||||
};
|
||||
}
|
||||
@@ -154,7 +154,6 @@ export async function handleRespondToTakeover({
|
||||
invariants: { ok: true, checks: [] },
|
||||
final_reply: response,
|
||||
order_id: null,
|
||||
payment_link: null,
|
||||
latency_ms: 0,
|
||||
});
|
||||
|
||||
@@ -348,13 +347,10 @@ function summarizeContext(contextSnapshot) {
|
||||
summary.push(`Pendiente: ${pendingItems}`);
|
||||
}
|
||||
|
||||
// Shipping/Payment
|
||||
// Shipping (sin payment — el bot no maneja pagos)
|
||||
if (ctx.order?.is_delivery !== null) {
|
||||
summary.push(ctx.order.is_delivery ? "Delivery" : "Retiro");
|
||||
}
|
||||
if (ctx.order?.payment_type) {
|
||||
summary.push(`Pago: ${ctx.order.payment_type}`);
|
||||
}
|
||||
|
||||
|
||||
return summary.join(" | ") || "Sin contexto";
|
||||
}
|
||||
|
||||
@@ -1,131 +1,25 @@
|
||||
import { createOrder, listRecentOrders } from "../../4-woo-orders/wooOrders.js";
|
||||
import { createPreference, reconcilePayment } from "../../6-mercadopago/mercadoPago.js";
|
||||
import { listProducts } from "../db/repo.js";
|
||||
import { syncOrdersIncremental } from "../../4-woo-orders/wooOrders.js";
|
||||
import * as ordersRepo from "../../4-woo-orders/ordersRepo.js";
|
||||
|
||||
/**
|
||||
* Lista pedidos recientes de WooCommerce
|
||||
* Lista pedidos desde cache local (con sync incremental)
|
||||
*/
|
||||
export async function handleListRecentOrders({ tenantId, limit = 20 }) {
|
||||
const orders = await listRecentOrders({ tenantId, limit });
|
||||
return { items: orders };
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene productos con stock para testing
|
||||
*/
|
||||
export async function handleGetProductsWithStock({ tenantId }) {
|
||||
const allProducts = await listProducts({ tenantId, limit: 500 });
|
||||
const withStock = allProducts.filter(p =>
|
||||
p.stock_status === "instock" &&
|
||||
p.price &&
|
||||
Number(p.price) > 0
|
||||
);
|
||||
return { items: withStock };
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea una orden de prueba en WooCommerce
|
||||
*/
|
||||
export async function handleCreateTestOrder({ tenantId, basket, address, wa_chat_id }) {
|
||||
if (!basket?.items?.length) {
|
||||
throw new Error("basket_empty");
|
||||
}
|
||||
export async function handleListOrders({ tenantId, page = 1, limit = 50 }) {
|
||||
// 1. Sincronizar pedidos nuevos de Woo
|
||||
await syncOrdersIncremental({ tenantId });
|
||||
|
||||
const order = await createOrder({
|
||||
tenantId,
|
||||
wooCustomerId: null, // Sin customer de Woo para testing
|
||||
basket,
|
||||
address,
|
||||
run_id: `test-${Date.now()}`,
|
||||
});
|
||||
|
||||
// Calcular total desde line_items
|
||||
let total = 0;
|
||||
if (order?.raw?.line_items) {
|
||||
for (const item of order.raw.line_items) {
|
||||
total += Number(item.total) || 0;
|
||||
}
|
||||
} else if (order?.raw?.total) {
|
||||
total = Number(order.raw.total) || 0;
|
||||
}
|
||||
// 2. Obtener pedidos paginados desde cache
|
||||
const orders = await ordersRepo.listOrders({ tenantId, page, limit });
|
||||
const total = await ordersRepo.countOrders({ tenantId });
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
woo_order_id: order?.id || null,
|
||||
total,
|
||||
line_items: order?.line_items || [],
|
||||
raw: order?.raw || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`,
|
||||
items: orders,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
import crypto from "crypto";
|
||||
import { parseEvolutionWebhook } from "../services/evolutionParser.js";
|
||||
import { resolveTenantId, processMessage } from "../../2-identity/services/pipeline.js";
|
||||
import { processMessage } from "../../2-identity/services/pipeline.js";
|
||||
import { getTenantId } from "../../shared/tenant.js";
|
||||
import { debug as dbg } from "../../shared/debug.js";
|
||||
|
||||
export async function handleEvolutionWebhook(body) {
|
||||
const t0 = Date.now();
|
||||
const parsed = parseEvolutionWebhook(body);
|
||||
if (!parsed.ok) {
|
||||
if (!parsed.ok) {
|
||||
return { status: 200, payload: { ok: true, ignored: parsed.reason } };
|
||||
}
|
||||
|
||||
if (dbg.perf || dbg.evolution) {
|
||||
console.log("[perf] evolution.webhook.start", {
|
||||
tenant_key: parsed.tenant_key || null,
|
||||
chat_id: parsed.chat_id,
|
||||
message_id: parsed.message_id || null,
|
||||
ts: parsed.ts || null,
|
||||
});
|
||||
}
|
||||
|
||||
const tenantId = await resolveTenantId({
|
||||
chat_id: parsed.chat_id,
|
||||
tenant_key: parsed.tenant_key,
|
||||
to_phone: null,
|
||||
});
|
||||
const tenantId = getTenantId();
|
||||
|
||||
const pm = await processMessage({
|
||||
tenantId,
|
||||
@@ -31,9 +27,10 @@ if (!parsed.ok) {
|
||||
from: parsed.chat_id.replace("@s.whatsapp.net", ""),
|
||||
displayName: parsed.from_name || null,
|
||||
text: parsed.text,
|
||||
inboundLocation: parsed.location || null,
|
||||
provider: "evolution",
|
||||
message_id: parsed.message_id || crypto.randomUUID(),
|
||||
meta: { pushName: parsed.from_name, ts: parsed.ts, instance: parsed.tenant_key, source: parsed.source },
|
||||
meta: { pushName: parsed.from_name, ts: parsed.ts, source: parsed.source },
|
||||
});
|
||||
|
||||
if (dbg.perf || dbg.evolution) {
|
||||
@@ -48,4 +45,3 @@ if (!parsed.ok) {
|
||||
|
||||
return { status: 200, payload: { ok: true } };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
import crypto from "crypto";
|
||||
import { resolveTenantId } from "../../2-identity/services/pipeline.js";
|
||||
import { processMessage } from "../../2-identity/services/pipeline.js";
|
||||
import { getTenantId } from "../../shared/tenant.js";
|
||||
|
||||
export async function handleSimSend(body) {
|
||||
const { chat_id, from_phone, text } = body || {};
|
||||
if (!chat_id || !from_phone || !text) {
|
||||
return { status: 400, payload: { ok: false, error: "chat_id, from_phone, text are required" } };
|
||||
const { chat_id, from_phone, text, location } = body || {};
|
||||
if (!chat_id || !from_phone || (!text && !location)) {
|
||||
return { status: 400, payload: { ok: false, error: "chat_id, from_phone, and text or location are required" } };
|
||||
}
|
||||
|
||||
// Aceptar location share desde el simulator. Mismo formato que el parser de
|
||||
// Evolution: { lat, lng, label? }.
|
||||
const inboundLocation =
|
||||
location && typeof location.lat === "number" && typeof location.lng === "number"
|
||||
? { lat: location.lat, lng: location.lng, label: location.label || null }
|
||||
: null;
|
||||
|
||||
const provider = "sim";
|
||||
const message_id = crypto.randomUUID();
|
||||
const tenantId = await resolveTenantId({
|
||||
chat_id,
|
||||
tenant_key: body?.tenant_key,
|
||||
to_phone: body?.to_phone,
|
||||
});
|
||||
const tenantId = getTenantId();
|
||||
|
||||
const result = await processMessage({
|
||||
tenantId,
|
||||
chat_id,
|
||||
from: from_phone,
|
||||
text,
|
||||
text: text || "",
|
||||
inboundLocation,
|
||||
provider,
|
||||
message_id,
|
||||
});
|
||||
|
||||
return { status: 200, payload: { ok: true, run_id: result.run_id, reply: result.reply } };
|
||||
}
|
||||
|
||||
|
||||
@@ -10,11 +10,12 @@ import { makeSearchProducts, makeListProducts, makeGetProduct, makeSyncProducts,
|
||||
import { makeListAliases, makeCreateAlias, makeUpdateAlias, makeDeleteAlias } from "../../0-ui/controllers/aliases.js";
|
||||
import { makeListRecommendations, makeGetRecommendation, makeCreateRecommendation, makeUpdateRecommendation, makeDeleteRecommendation } from "../../0-ui/controllers/recommendations.js";
|
||||
import { makeListProductQtyRules, makeGetProductQtyRules, makeSaveProductQtyRules } from "../../0-ui/controllers/quantities.js";
|
||||
import { makeListPrompts, makeGetPrompt, makeSavePrompt, makeRollbackPrompt, makeResetPrompt, makeGetPromptVersion, makeTestPrompt } from "../../0-ui/controllers/prompts.js";
|
||||
// Prompts CRUD removido: el agente nuevo usa un system prompt único hardcoded.
|
||||
import { makeListPendingTakeovers, makeListAllTakeovers, makeGetTakeover, makeRespondToTakeover, makeCancelTakeover, makeCheckPendingTakeover } from "../../0-ui/controllers/takeovers.js";
|
||||
import { makeGetSettings, makeSaveSettings } from "../../0-ui/controllers/settings.js";
|
||||
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js";
|
||||
import { makeListRecentOrders, makeGetProductsWithStock, makeCreateTestOrder, makeCreatePaymentLink, makeSimulateMpWebhook } from "../../0-ui/controllers/testing.js";
|
||||
import { makeListOrders, makeGetOrderStats } from "../../0-ui/controllers/testing.js";
|
||||
import { getAgentMetrics } from "../../3-turn-engine/agent/runTurn.js";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
@@ -50,6 +51,7 @@ export function createSimulatorRouter({ tenantId }) {
|
||||
* --- UI data endpoints ---
|
||||
*/
|
||||
router.post("/sim/send", makeSimSend());
|
||||
router.get("/api/metrics/agent", (req, res) => res.json(getAgentMetrics()));
|
||||
|
||||
router.get("/conversations", makeGetConversations(getTenantId));
|
||||
router.get("/conversations/state", makeGetConversationState(getTenantId));
|
||||
@@ -81,13 +83,8 @@ export function createSimulatorRouter({ tenantId }) {
|
||||
router.put("/quantities/:wooProductId", makeSaveProductQtyRules(getTenantId));
|
||||
|
||||
// --- Prompts routes ---
|
||||
router.get("/prompts", makeListPrompts(getTenantId));
|
||||
router.get("/prompts/:key", makeGetPrompt(getTenantId));
|
||||
router.post("/prompts/:key", makeSavePrompt(getTenantId));
|
||||
router.post("/prompts/:key/rollback/:version", makeRollbackPrompt(getTenantId));
|
||||
router.post("/prompts/:key/reset", makeResetPrompt(getTenantId));
|
||||
router.get("/prompts/:key/versions/:version", makeGetPromptVersion(getTenantId));
|
||||
router.post("/prompts/:key/test", makeTestPrompt(getTenantId));
|
||||
// /prompts/* removido tras flip a agente tool-calling. El system prompt
|
||||
// único vive en src/modules/3-turn-engine/agent/systemPrompt.js.
|
||||
|
||||
// --- Human Takeovers routes ---
|
||||
router.get("/takeovers", makeListPendingTakeovers(getTenantId));
|
||||
@@ -107,12 +104,9 @@ export function createSimulatorRouter({ tenantId }) {
|
||||
router.get("/runs", makeListRuns(getTenantId));
|
||||
router.get("/runs/:run_id", makeGetRunById(getTenantId));
|
||||
|
||||
// --- Testing routes ---
|
||||
router.get("/test/orders", makeListRecentOrders(getTenantId));
|
||||
router.get("/test/products-with-stock", makeGetProductsWithStock(getTenantId));
|
||||
router.post("/test/order", makeCreateTestOrder(getTenantId));
|
||||
router.post("/test/payment-link", makeCreatePaymentLink(getTenantId));
|
||||
router.post("/test/simulate-webhook", makeSimulateMpWebhook(getTenantId));
|
||||
// --- API routes (orders) ---
|
||||
router.get("/api/orders", makeListOrders(getTenantId));
|
||||
router.get("/api/stats/orders", makeGetOrderStats(getTenantId));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -34,8 +34,19 @@ export function parseEvolutionWebhook(reqBody) {
|
||||
(typeof msg.extendedTextMessage?.text === "string" && msg.extendedTextMessage.text) ||
|
||||
"";
|
||||
|
||||
// extract location share (WhatsApp pin). Evolution wraps Baileys formats:
|
||||
// - locationMessage: { degreesLatitude, degreesLongitude, name?, address? }
|
||||
// - liveLocationMessage: { degreesLatitude, degreesLongitude }
|
||||
const loc = msg.locationMessage || msg.liveLocationMessage || null;
|
||||
const lat = loc?.degreesLatitude;
|
||||
const lng = loc?.degreesLongitude;
|
||||
const location =
|
||||
typeof lat === "number" && typeof lng === "number"
|
||||
? { lat, lng, label: loc?.name || loc?.address || null }
|
||||
: null;
|
||||
|
||||
const cleanText = String(text).trim();
|
||||
if (!cleanText) return { ok: false, reason: "empty_text" };
|
||||
if (!cleanText && !location) return { ok: false, reason: "empty_message" };
|
||||
|
||||
// metadata
|
||||
const pushName = data.pushName || null;
|
||||
@@ -48,6 +59,7 @@ export function parseEvolutionWebhook(reqBody) {
|
||||
chat_id: remoteJid,
|
||||
message_id: messageId || null,
|
||||
text: cleanText,
|
||||
location,
|
||||
from_name: pushName,
|
||||
message_type: messageType || null,
|
||||
ts,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { refreshProductByWooId } from "../../shared/wooSnapshot.js";
|
||||
import { getTenantByKey } from "../db/repo.js";
|
||||
import { getTenantId } from "../../shared/tenant.js";
|
||||
import { insertAuditLog } from "../../0-ui/db/repo.js";
|
||||
|
||||
function unauthorized(res) {
|
||||
@@ -48,11 +48,8 @@ export function makeWooProductWebhook() {
|
||||
const { id, parentId, resource, action, changes } = parseWooPayload(req.body || {});
|
||||
if (!id) return res.status(400).json({ ok: false, error: "missing_id" });
|
||||
|
||||
// Determinar tenant por query ?tenant_key=...
|
||||
const tenantKey = req.query?.tenant_key || process.env.TENANT_KEY || null;
|
||||
if (!tenantKey) return res.status(400).json({ ok: false, error: "missing_tenant_key" });
|
||||
const tenant = await getTenantByKey(String(tenantKey).toLowerCase());
|
||||
if (!tenant?.id) return res.status(404).json({ ok: false, error: "tenant_not_found" });
|
||||
// Mono-tenant: el tenant es el único cargado al boot.
|
||||
const tenant = { id: getTenantId() };
|
||||
|
||||
const parentForVariation =
|
||||
resource && String(resource).includes("variation") ? parentId || null : null;
|
||||
|
||||
@@ -461,21 +461,6 @@ export async function deleteIdentityMapByChat({ tenant_id, wa_chat_id, provider
|
||||
return rowCount || 0;
|
||||
}
|
||||
|
||||
export async function getTenantByKey(key) {
|
||||
const { rows } = await pool.query(`select id, key, name from tenants where key=$1`, [key]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function getTenantIdByChannel({ channel_type, channel_key }) {
|
||||
const q = `
|
||||
select tenant_id
|
||||
from tenant_channels
|
||||
where channel_type=$1 and channel_key=$2
|
||||
`;
|
||||
const { rows } = await pool.query(q, [channel_type, channel_key]);
|
||||
return rows[0]?.tenant_id || null;
|
||||
}
|
||||
|
||||
export async function getExternalCustomerIdByChat({ tenant_id, wa_chat_id, provider = "woo" }) {
|
||||
const q = `
|
||||
select external_customer_id
|
||||
@@ -535,32 +520,57 @@ export async function getDecryptedTenantEcommerceConfig({
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function searchProductAliases({ tenant_id, q = "", limit = 20 }) {
|
||||
export async function searchProductAliases({ tenant_id, q = "", limit = 20, threshold = 0.3 }) {
|
||||
const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 20));
|
||||
const query = String(q || "").trim();
|
||||
if (!query) return [];
|
||||
const normalized = query.toLowerCase();
|
||||
const like = `%${query}%`;
|
||||
const nlike = `%${normalized}%`;
|
||||
const sql = `
|
||||
select tenant_id, alias, normalized_alias, woo_product_id, category_hint, boost, metadata, updated_at
|
||||
select tenant_id, alias, normalized_alias, woo_product_id, category_hint, boost, metadata, updated_at,
|
||||
greatest(similarity(alias, $2), similarity(normalized_alias, $3)) as sim
|
||||
from product_aliases
|
||||
where tenant_id=$1
|
||||
and (alias ilike $2 or normalized_alias ilike $3)
|
||||
order by boost desc, updated_at desc
|
||||
where tenant_id = $1
|
||||
and (alias % $2 or normalized_alias % $3)
|
||||
order by sim desc, boost desc, updated_at desc
|
||||
limit $4
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenant_id, like, nlike, lim]);
|
||||
return rows.map((r) => ({
|
||||
tenant_id: r.tenant_id,
|
||||
alias: r.alias,
|
||||
normalized_alias: r.normalized_alias,
|
||||
woo_product_id: r.woo_product_id,
|
||||
category_hint: r.category_hint,
|
||||
boost: r.boost,
|
||||
metadata: r.metadata,
|
||||
updated_at: r.updated_at,
|
||||
}));
|
||||
const { rows } = await pool.query(sql, [tenant_id, query, normalized, lim]);
|
||||
return rows
|
||||
.filter((r) => Number(r.sim) >= threshold)
|
||||
.map((r) => ({
|
||||
tenant_id: r.tenant_id,
|
||||
alias: r.alias,
|
||||
normalized_alias: r.normalized_alias,
|
||||
woo_product_id: r.woo_product_id,
|
||||
category_hint: r.category_hint,
|
||||
boost: r.boost,
|
||||
metadata: r.metadata,
|
||||
updated_at: r.updated_at,
|
||||
similarity: Number(r.sim),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function searchAliasProductMappings({ tenant_id, q = "", limit = 50, threshold = 0.3 }) {
|
||||
const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 50));
|
||||
const query = String(q || "").trim();
|
||||
if (!query) return [];
|
||||
const normalized = query.toLowerCase();
|
||||
const sql = `
|
||||
select alias, woo_product_id, score, similarity(alias, $2) as sim
|
||||
from alias_product_mappings
|
||||
where tenant_id = $1 and alias % $2
|
||||
order by sim desc, score desc
|
||||
limit $3
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenant_id, normalized, lim]);
|
||||
return rows
|
||||
.filter((r) => Number(r.sim) >= threshold)
|
||||
.map((r) => ({
|
||||
alias: r.alias,
|
||||
woo_product_id: Number(r.woo_product_id),
|
||||
score: Number(r.score || 1),
|
||||
similarity: Number(r.sim),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getRecoRules({ tenant_id }) {
|
||||
@@ -733,51 +743,4 @@ export async function upsertProductEmbedding({
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,16 +8,14 @@ import {
|
||||
getExternalCustomerIdByChat,
|
||||
upsertExternalCustomerMap,
|
||||
updateRunLatency,
|
||||
getTenantByKey,
|
||||
getTenantIdByChannel,
|
||||
} from "../db/repo.js";
|
||||
import { getTenantId } from "../../shared/tenant.js";
|
||||
import { sseSend } from "../../shared/sse.js";
|
||||
import { createWooCustomer, getWooCustomerById } from "./woo.js";
|
||||
import { debug as dbg } from "../../shared/debug.js";
|
||||
import { runTurnV3 } from "../../3-turn-engine/turnEngineV3.js";
|
||||
import { safeNextState } from "../../3-turn-engine/fsm.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 { sendTextMessage, isEvolutionEnabled } from "../../1-intake/services/evolutionSender.js";
|
||||
|
||||
@@ -123,6 +121,7 @@ export async function processMessage({
|
||||
message_id,
|
||||
displayName = null,
|
||||
meta = null,
|
||||
inboundLocation = null,
|
||||
}) {
|
||||
const { started_at, mark, msBetween } = makePerf();
|
||||
const prev = await touchConversationState({ tenant_id: tenantId, wa_chat_id: chat_id });
|
||||
@@ -130,12 +129,15 @@ const prev = await touchConversationState({ tenant_id: tenantId, wa_chat_id: cha
|
||||
mark("start");
|
||||
const stageDebug = dbg.perf;
|
||||
mark("after_touchConversationState");
|
||||
// Detectar conversación nueva (más de 24 horas sin actividad)
|
||||
const staleThresholdMs = 24 * 60 * 60 * 1000; // 24 horas
|
||||
// TTL stale: 24h general, 7d si la conversación quedó PAUSED, sin TTL si AWAITING_HUMAN.
|
||||
const stateNow = prev?.state || "IDLE";
|
||||
let staleThresholdMs = 24 * 60 * 60 * 1000;
|
||||
if (stateNow === "PAUSED") staleThresholdMs = 7 * 24 * 60 * 60 * 1000;
|
||||
if (stateNow === "AWAITING_HUMAN") staleThresholdMs = Infinity;
|
||||
const isStale =
|
||||
prev?.state_updated_at &&
|
||||
Date.now() - new Date(prev.state_updated_at).getTime() > staleThresholdMs;
|
||||
const prev_state = isStale ? "IDLE" : prev?.state || "IDLE";
|
||||
const prev_state = isStale ? "IDLE" : stateNow;
|
||||
let externalCustomerId = await getExternalCustomerIdByChat({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: chat_id,
|
||||
@@ -143,7 +145,7 @@ let externalCustomerId = await getExternalCustomerIdByChat({
|
||||
});
|
||||
mark("after_getExternalCustomerIdByChat");
|
||||
|
||||
await insertMessage({
|
||||
const inserted = await insertMessage({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: chat_id,
|
||||
provider,
|
||||
@@ -155,6 +157,15 @@ let externalCustomerId = await getExternalCustomerIdByChat({
|
||||
});
|
||||
mark("after_insertMessage_in");
|
||||
|
||||
// Idempotency: si el message_id ya estaba insertado (Evolution suele
|
||||
// reentregar webhooks), evitamos volver a procesar el turn entero.
|
||||
if (!inserted) {
|
||||
if (dbg.perf || dbg.evolution) {
|
||||
console.log("[pipeline] duplicate message ignored", { message_id, chat_id });
|
||||
}
|
||||
return { run_id: null, reply: null, duplicate: true };
|
||||
}
|
||||
|
||||
mark("before_getRecentMessagesForLLM_for_plan");
|
||||
const history = await getRecentMessagesForLLM({
|
||||
tenant_id: tenantId,
|
||||
@@ -169,17 +180,34 @@ let externalCustomerId = await getExternalCustomerIdByChat({
|
||||
let reducedContext = prev?.context && typeof prev?.context === "object" ? { ...prev.context } : {};
|
||||
if (isStale) {
|
||||
// Conversación nueva: resetear carrito pero mantener datos del cliente
|
||||
// (external_customer_id) y la "última entrega" para que el bot pueda
|
||||
// ofrecer "te lo mando al mismo lugar que la otra vez?".
|
||||
reducedContext = {
|
||||
external_customer_id: reducedContext.external_customer_id,
|
||||
// Resetear order y pending
|
||||
last_delivery: reducedContext.last_delivery || null,
|
||||
order: null,
|
||||
order_basket: null,
|
||||
pending_items: null,
|
||||
// Marcar que fue reseteado
|
||||
_reset_reason: "stale",
|
||||
_reset_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Si llegó una ubicación compartida (WhatsApp pin), guardarla en pending
|
||||
// para que el agente la lea via working_memory y matchee zona en set_address.
|
||||
if (inboundLocation && typeof inboundLocation.lat === "number" && typeof inboundLocation.lng === "number") {
|
||||
const baseOrder = reducedContext.order && typeof reducedContext.order === "object" ? reducedContext.order : {};
|
||||
const merged = { ...baseOrder };
|
||||
if (!Array.isArray(merged.cart)) merged.cart = [];
|
||||
if (!Array.isArray(merged.pending)) merged.pending = [];
|
||||
merged.pending_location = {
|
||||
lat: inboundLocation.lat,
|
||||
lng: inboundLocation.lng,
|
||||
label: inboundLocation.label || null,
|
||||
received_at: new Date().toISOString(),
|
||||
};
|
||||
reducedContext.order = merged;
|
||||
}
|
||||
let decision;
|
||||
let plan;
|
||||
let llmMeta;
|
||||
@@ -206,7 +234,6 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
||||
ok: true,
|
||||
checks: [
|
||||
{ name: "required_keys_present", ok: true },
|
||||
{ name: "no_checkout_without_payment_link", ok: true },
|
||||
{ name: "no_order_action_without_items", ok: true },
|
||||
],
|
||||
};
|
||||
@@ -258,16 +285,14 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
||||
...baseAddress,
|
||||
phone: baseAddress.phone || phoneFromWa,
|
||||
};
|
||||
// Obtener shipping_method y payment_method del contexto (preferir decision que es el resultado del turn)
|
||||
// shipping_method del contexto (delivery|pickup). El cobro se gestiona offline.
|
||||
const shippingMethod = decision?.context_patch?.shipping_method || reducedContext?.shipping_method || null;
|
||||
const paymentMethod = decision?.context_patch?.payment_method || reducedContext?.payment_method || null;
|
||||
const order = await createOrder({
|
||||
tenantId,
|
||||
wooCustomerId: externalCustomerId,
|
||||
basket: basketToUse,
|
||||
address: addressWithPhone,
|
||||
shippingMethod,
|
||||
paymentMethod,
|
||||
run_id: null,
|
||||
});
|
||||
actionPatch.woo_order_id = order?.id || null;
|
||||
@@ -313,25 +338,6 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
||||
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) {
|
||||
newTools.push({ type: act.type, ok: false, error: String(e?.message || e) });
|
||||
@@ -409,9 +415,9 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
||||
};
|
||||
// El nuevo FSM usa context.order, extraerlo para safeNextState
|
||||
const orderForFsm = context?.order || context?.order_basket || {};
|
||||
const signals = {
|
||||
const signals = {
|
||||
confirm_order: plan.intent === "confirm_order",
|
||||
payment_selected: plan.intent === "select_payment",
|
||||
shipping_completed: plan.order_action === "create_order",
|
||||
};
|
||||
const nextState = safeNextState(prev_state, orderForFsm, signals).next_state;
|
||||
plan.next_state = nextState;
|
||||
@@ -468,7 +474,6 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
||||
invariants,
|
||||
final_reply: plan.reply,
|
||||
order_id: actionPatch.woo_order_id || null,
|
||||
payment_link: actionPatch.payment_link || null,
|
||||
latency_ms: end_to_end_ms,
|
||||
});
|
||||
|
||||
@@ -493,28 +498,11 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
||||
return { run_id, reply: plan.reply };
|
||||
}
|
||||
|
||||
function parseTenantFromChatId(chat_id) {
|
||||
const m = /^([a-z0-9_-]+):/.exec(chat_id);
|
||||
return m?.[1]?.toLowerCase() || null;
|
||||
}
|
||||
|
||||
export async function resolveTenantId({ chat_id, to_phone = null, tenant_key = null }) {
|
||||
const explicit = (tenant_key || parseTenantFromChatId(chat_id) || "").toLowerCase();
|
||||
|
||||
if (explicit) {
|
||||
const t = await getTenantByKey(explicit);
|
||||
if (t) return t.id;
|
||||
throw new Error(`tenant_not_found: ${explicit}`);
|
||||
}
|
||||
|
||||
if (to_phone) {
|
||||
const id = await getTenantIdByChannel({ channel_type: "whatsapp", channel_key: to_phone });
|
||||
if (id) return id;
|
||||
}
|
||||
|
||||
const fallbackKey = (process.env.TENANT_KEY || "piaf").toLowerCase();
|
||||
const t = await getTenantByKey(fallbackKey);
|
||||
if (t) return t.id;
|
||||
throw new Error(`tenant_not_found: ${fallbackKey}`);
|
||||
/**
|
||||
* Mono-tenant: devuelve el id resuelto al boot. No hace queries por turno.
|
||||
* Se mantiene como async para no romper callers existentes.
|
||||
*/
|
||||
export async function resolveTenantId() {
|
||||
return getTenantId();
|
||||
}
|
||||
|
||||
|
||||
112
src/modules/3-turn-engine/agent/customerProfile.js
Normal file
112
src/modules/3-turn-engine/agent/customerProfile.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* customerProfile — perfil del cliente para "lo de siempre".
|
||||
*
|
||||
* Lookup por teléfono (extraído del chat_id WhatsApp) en woo_orders_cache.
|
||||
* Agrupa items por woo_product_id en los últimos 6 meses, top 5 frequent_items.
|
||||
*
|
||||
* Cache 10 min por chat_id.
|
||||
*/
|
||||
|
||||
import { pool } from "../../shared/db/pool.js";
|
||||
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const _cache = new Map();
|
||||
|
||||
function phoneFromChatId(chatId) {
|
||||
if (!chatId) return null;
|
||||
// chat_id típico: "5491133230322@s.whatsapp.net"
|
||||
const m = /^(\d+)/.exec(String(chatId));
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function normalizePhone(p) {
|
||||
return String(p || "").replace(/[^\d]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve perfil del cliente o null si no hay datos.
|
||||
*/
|
||||
export async function getCustomerProfile({ tenantId, chat_id }) {
|
||||
if (!tenantId || !chat_id) return null;
|
||||
const cacheKey = `${tenantId}:${chat_id}`;
|
||||
const cached = _cache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.t < CACHE_TTL_MS) return cached.value;
|
||||
|
||||
const phone = phoneFromChatId(chat_id);
|
||||
if (!phone) {
|
||||
_cache.set(cacheKey, { value: null, t: Date.now() });
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await fetchProfile({ tenantId, phone });
|
||||
_cache.set(cacheKey, { value: profile, t: Date.now() });
|
||||
return profile;
|
||||
} catch (err) {
|
||||
console.error("[customerProfile] error:", err?.message || err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProfile({ tenantId, phone }) {
|
||||
const phoneClean = normalizePhone(phone);
|
||||
if (!phoneClean) return null;
|
||||
|
||||
// Match phones que terminen igual (los Woo a veces vienen con +54 o sin)
|
||||
const phoneSuffix = phoneClean.slice(-8);
|
||||
|
||||
const orderSql = `
|
||||
SELECT id, woo_order_id, total, date_created,
|
||||
shipping_address_1, shipping_address_2, shipping_city, customer_name
|
||||
FROM woo_orders_cache
|
||||
WHERE tenant_id = $1
|
||||
AND regexp_replace(coalesce(customer_phone,''), '\\D', '', 'g') LIKE '%' || $2
|
||||
AND date_created > NOW() - INTERVAL '6 months'
|
||||
ORDER BY date_created DESC
|
||||
LIMIT 30
|
||||
`;
|
||||
const { rows: orders } = await pool.query(orderSql, [tenantId, phoneSuffix]);
|
||||
if (orders.length === 0) {
|
||||
return { is_returning: false, last_order_at: null, frequent_items: [], preferred_address: null };
|
||||
}
|
||||
|
||||
const orderIds = orders.map((o) => o.woo_order_id);
|
||||
const itemsSql = `
|
||||
SELECT woo_product_id, product_name, sell_unit,
|
||||
COUNT(*) AS times,
|
||||
SUM(quantity) AS total_qty,
|
||||
AVG(quantity) AS avg_qty
|
||||
FROM woo_order_items
|
||||
WHERE tenant_id = $1 AND woo_order_id = ANY($2::bigint[]) AND woo_product_id IS NOT NULL
|
||||
GROUP BY woo_product_id, product_name, sell_unit
|
||||
ORDER BY times DESC, total_qty DESC
|
||||
LIMIT 5
|
||||
`;
|
||||
const { rows: items } = await pool.query(itemsSql, [tenantId, orderIds]);
|
||||
|
||||
const frequent_items = items.map((it) => ({
|
||||
woo_id: Number(it.woo_product_id),
|
||||
name: it.product_name,
|
||||
times_ordered: Number(it.times),
|
||||
avg_qty: Number(it.avg_qty),
|
||||
avg_unit: it.sell_unit || null,
|
||||
}));
|
||||
|
||||
const lastOrder = orders[0];
|
||||
const preferredAddress = [lastOrder.shipping_address_1, lastOrder.shipping_address_2, lastOrder.shipping_city]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
return {
|
||||
is_returning: true,
|
||||
last_order_at: lastOrder.date_created,
|
||||
customer_name: lastOrder.customer_name || null,
|
||||
frequent_items,
|
||||
preferred_address: preferredAddress || null,
|
||||
total_orders_last_6m: orders.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function invalidateCustomerProfileCache(chat_id) {
|
||||
for (const k of _cache.keys()) if (k.endsWith(`:${chat_id}`)) _cache.delete(k);
|
||||
}
|
||||
148
src/modules/3-turn-engine/agent/quantityParser.js
Normal file
148
src/modules/3-turn-engine/agent/quantityParser.js
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Quantity parser determinista (es-AR).
|
||||
*
|
||||
* Pre-procesa el texto del usuario para extraer cantidad+unidad ANTES del LLM.
|
||||
* El resultado se pasa al agente como side-channel (`working_memory.preparsed`)
|
||||
* — el agente lo ve pero puede sobreescribirlo si el contexto lo amerita.
|
||||
*
|
||||
* Cubre los patrones AR-es más comunes:
|
||||
* - Fracciones: "1/4 kg", "1/2 kilo"
|
||||
* - Frases compuestas: "media docena", "cuarto kilo", "cuarto de kilo",
|
||||
* "tres cuartos", "medio kilo", "par"
|
||||
* - Numerales con unidad: "300 gramos", "0.5kg", "2 botellas"
|
||||
* - Numerales solos: "300", "0.5" (unit=null para que el contexto decida)
|
||||
*/
|
||||
|
||||
const NUMBER_WORDS = {
|
||||
un: 1, uno: 1, una: 1,
|
||||
dos: 2, tres: 3, cuatro: 4, cinco: 5,
|
||||
seis: 6, siete: 7, ocho: 8, nueve: 9, diez: 10,
|
||||
once: 11, doce: 12, trece: 13, catorce: 14, quince: 15,
|
||||
dieciseis: 16, diecisiete: 17, dieciocho: 18, diecinueve: 19,
|
||||
veinte: 20, veintidos: 22, veinticinco: 25, treinta: 30,
|
||||
};
|
||||
|
||||
// Frases compuestas. Orden importa: las más largas primero.
|
||||
const PHRASES = [
|
||||
["tres cuartos de kilo", { qty: 0.75, unit: "kg" }],
|
||||
["tres cuartos kilo", { qty: 0.75, unit: "kg" }],
|
||||
["tres cuartos", { qty: 0.75, unit: "kg" }],
|
||||
["cuarto de kilo", { qty: 0.25, unit: "kg" }],
|
||||
["cuarto kilo", { qty: 0.25, unit: "kg" }],
|
||||
["un cuarto", { qty: 0.25, unit: "kg" }],
|
||||
["media docena", { qty: 6, unit: "unit" }],
|
||||
["medio kilo", { qty: 0.5, unit: "kg" }],
|
||||
["media kilo", { qty: 0.5, unit: "kg" }],
|
||||
["dos kilos", { qty: 2, unit: "kg" }],
|
||||
["tres kilos", { qty: 3, unit: "kg" }],
|
||||
["cinco kilos", { qty: 5, unit: "kg" }],
|
||||
["un kilo", { qty: 1, unit: "kg" }],
|
||||
["docena", { qty: 12, unit: "unit" }],
|
||||
["par", { qty: 2, unit: "unit" }],
|
||||
["pareja", { qty: 2, unit: "unit" }],
|
||||
];
|
||||
|
||||
// Fracción: respeta separación opcional con espacios. NO depende de \b.
|
||||
const FRACTION = /(\d+)\s*\/\s*(\d+)/;
|
||||
// Decimal con punto o coma. Capta "2.5", "0,3", "300", "0.5"
|
||||
const NUMERIC = /(\d+(?:[.,]\d+)?)/;
|
||||
|
||||
// Unidades: pueden venir pegadas al número ("2kg") o separadas ("2 kg").
|
||||
// Lookbehind opcional para dígito o non-letter; lookahead obligatorio para
|
||||
// non-letter o fin de string. Probamos kilos antes que gramos para evitar
|
||||
// que "kg" matchee "g" (ambos requieren post boundary, pero kg es 2 chars).
|
||||
const UNIT_KG_RE = /(?:kgs?|kilos?|kilogramos?)(?![a-z])/i;
|
||||
const UNIT_G_RE = /(?:^|[^a-z])(?:g|gr|grs|gramos?)(?![a-z])/i;
|
||||
const UNIT_UNIT_RE = /(?:unidad(?:es)?|botellas?|frascos?|paquetes?|atados?|piezas?)(?![a-z])|(?:^|\s)u(?:\s|$)/i;
|
||||
|
||||
/**
|
||||
* Lower + sin diacríticos. Conserva dígitos y separadores numéricos.
|
||||
*/
|
||||
function lowerNoDiacritics(text) {
|
||||
return String(text || "")
|
||||
.toLowerCase()
|
||||
.normalize("NFD").replace(/[̀-ͯ]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta la unidad explícita en el texto. Devuelve null si no hay.
|
||||
*/
|
||||
export function detectUnit(text) {
|
||||
const t = lowerNoDiacritics(text);
|
||||
if (!t) return null;
|
||||
if (UNIT_KG_RE.test(t)) return "kg";
|
||||
if (UNIT_G_RE.test(t)) return "g";
|
||||
if (UNIT_UNIT_RE.test(t)) return "unit";
|
||||
return null;
|
||||
}
|
||||
|
||||
function postProcess(qty, unit) {
|
||||
if (!Number.isFinite(qty) || qty <= 0) return null;
|
||||
return { qty, unit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae cantidad+unidad del texto. Devuelve `null` si no encuentra nada confiable.
|
||||
* Confidence:
|
||||
* - 0.95 fracción explícita
|
||||
* - 0.9 frase compuesta o numérico+unit
|
||||
* - 0.85 palabra + unit
|
||||
* - 0.7 numérico solo
|
||||
*/
|
||||
export function parseQuantity(text) {
|
||||
const raw = String(text || "").trim();
|
||||
if (!raw) return null;
|
||||
const t = lowerNoDiacritics(raw);
|
||||
|
||||
// 1) Fracción explícita
|
||||
const fracMatch = FRACTION.exec(t);
|
||||
if (fracMatch) {
|
||||
const num = Number(fracMatch[1]);
|
||||
const den = Number(fracMatch[2]);
|
||||
if (den > 0 && Number.isFinite(num) && num > 0) {
|
||||
const value = num / den;
|
||||
const unit = detectUnit(t);
|
||||
// Si no hay unit explícita pero menciona "kilo"/"kg" o similar, igual cae.
|
||||
// detectUnit lo cubre; si t contiene "kilo" después de "1/2", lo agarra.
|
||||
const result = postProcess(value, unit);
|
||||
if (result) return { ...result, confidence: 0.95, source: "fraction" };
|
||||
} else {
|
||||
return null; // div por cero / fracción inválida
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Frases compuestas (las más largas primero)
|
||||
for (const [phrase, payload] of PHRASES) {
|
||||
if (t.includes(phrase)) {
|
||||
const explicitUnit = detectUnit(t);
|
||||
// Si hay unit explícita en el texto, gana sobre la default de la frase
|
||||
const finalUnit = explicitUnit || payload.unit;
|
||||
return { qty: payload.qty, unit: finalUnit, confidence: 0.9, source: "phrase", phrase };
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Numérico con unit
|
||||
const numMatch = NUMERIC.exec(t);
|
||||
if (numMatch) {
|
||||
const value = parseFloat(numMatch[1].replace(",", "."));
|
||||
const unit = detectUnit(t);
|
||||
if (unit) {
|
||||
const result = postProcess(value, unit);
|
||||
if (result) return { ...result, confidence: 0.9, source: "numeric_with_unit" };
|
||||
}
|
||||
// Numérico solo (sin unit)
|
||||
const result = postProcess(value, null);
|
||||
if (result) return { ...result, confidence: 0.7, source: "numeric_alone" };
|
||||
}
|
||||
|
||||
// 4) Palabra (ej: "dos botellas")
|
||||
for (const [word, value] of Object.entries(NUMBER_WORDS)) {
|
||||
const re = new RegExp(`(?:^|\\s)${word}(?:\\s|$)`);
|
||||
if (re.test(t)) {
|
||||
const unit = detectUnit(t);
|
||||
if (unit) return { qty: value, unit, confidence: 0.85, source: "word_with_unit" };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
193
src/modules/3-turn-engine/agent/quantityParser.test.js
Normal file
193
src/modules/3-turn-engine/agent/quantityParser.test.js
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseQuantity, detectUnit } from "./quantityParser.js";
|
||||
|
||||
describe("parseQuantity — fracciones", () => {
|
||||
it("'1/4 kg' → 0.25 kg", () => {
|
||||
const r = parseQuantity("1/4 kg");
|
||||
expect(r).toMatchObject({ qty: 0.25, unit: "kg", source: "fraction" });
|
||||
});
|
||||
it("'1/2 kilo' → 0.5 kg", () => {
|
||||
const r = parseQuantity("1/2 kilo");
|
||||
expect(r).toMatchObject({ qty: 0.5, unit: "kg" });
|
||||
});
|
||||
it("'3/4 kilos de matambre' → 0.75 kg", () => {
|
||||
const r = parseQuantity("3/4 kilos de matambre");
|
||||
expect(r).toMatchObject({ qty: 0.75, unit: "kg" });
|
||||
});
|
||||
it("'1/2' sin unidad → 0.5 sin unit", () => {
|
||||
const r = parseQuantity("1/2");
|
||||
expect(r).toMatchObject({ qty: 0.5, unit: null });
|
||||
});
|
||||
it("'1/0' division por cero → null", () => {
|
||||
expect(parseQuantity("1/0")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQuantity — frases compuestas", () => {
|
||||
it("'media docena' → 6 unit", () => {
|
||||
const r = parseQuantity("media docena");
|
||||
expect(r).toMatchObject({ qty: 6, unit: "unit", source: "phrase" });
|
||||
});
|
||||
it("'media docena de chorizos' → 6 unit", () => {
|
||||
const r = parseQuantity("media docena de chorizos");
|
||||
expect(r).toMatchObject({ qty: 6, unit: "unit" });
|
||||
});
|
||||
it("'cuarto de kilo' → 0.25 kg", () => {
|
||||
const r = parseQuantity("cuarto de kilo");
|
||||
expect(r).toMatchObject({ qty: 0.25, unit: "kg" });
|
||||
});
|
||||
it("'cuarto kilo' → 0.25 kg", () => {
|
||||
const r = parseQuantity("cuarto kilo");
|
||||
expect(r).toMatchObject({ qty: 0.25, unit: "kg" });
|
||||
});
|
||||
it("'tres cuartos' → 0.75 kg", () => {
|
||||
const r = parseQuantity("tres cuartos");
|
||||
expect(r).toMatchObject({ qty: 0.75, unit: "kg" });
|
||||
});
|
||||
it("'tres cuartos de kilo' → 0.75 kg", () => {
|
||||
const r = parseQuantity("tres cuartos de kilo");
|
||||
expect(r).toMatchObject({ qty: 0.75, unit: "kg" });
|
||||
});
|
||||
it("'medio kilo' → 0.5 kg", () => {
|
||||
const r = parseQuantity("medio kilo");
|
||||
expect(r).toMatchObject({ qty: 0.5, unit: "kg" });
|
||||
});
|
||||
it("'media kilo' → 0.5 kg (typo común)", () => {
|
||||
const r = parseQuantity("media kilo");
|
||||
expect(r).toMatchObject({ qty: 0.5, unit: "kg" });
|
||||
});
|
||||
it("'docena' → 12 unit", () => {
|
||||
expect(parseQuantity("una docena")).toMatchObject({ qty: 12, unit: "unit" });
|
||||
});
|
||||
it("'par' → 2 unit", () => {
|
||||
expect(parseQuantity("un par de chorizos")).toMatchObject({ qty: 2, unit: "unit" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQuantity — numéricos con unidad", () => {
|
||||
it("'300 gramos' → 300 g", () => {
|
||||
expect(parseQuantity("300 gramos")).toMatchObject({ qty: 300, unit: "g" });
|
||||
});
|
||||
it("'500g' → 500 g", () => {
|
||||
expect(parseQuantity("500g")).toMatchObject({ qty: 500, unit: "g" });
|
||||
});
|
||||
it("'2kg' → 2 kg", () => {
|
||||
expect(parseQuantity("2kg")).toMatchObject({ qty: 2, unit: "kg" });
|
||||
});
|
||||
it("'2.5 kilos' → 2.5 kg", () => {
|
||||
expect(parseQuantity("2.5 kilos")).toMatchObject({ qty: 2.5, unit: "kg" });
|
||||
});
|
||||
it("'2,5 kilos' (coma decimal) → 2.5 kg", () => {
|
||||
expect(parseQuantity("2,5 kilos")).toMatchObject({ qty: 2.5, unit: "kg" });
|
||||
});
|
||||
it("'0.5kg' → 0.5 kg", () => {
|
||||
expect(parseQuantity("0.5kg")).toMatchObject({ qty: 0.5, unit: "kg" });
|
||||
});
|
||||
it("'3 botellas' → 3 unit", () => {
|
||||
expect(parseQuantity("3 botellas")).toMatchObject({ qty: 3, unit: "unit" });
|
||||
});
|
||||
it("'2 unidades' → 2 unit", () => {
|
||||
expect(parseQuantity("2 unidades")).toMatchObject({ qty: 2, unit: "unit" });
|
||||
});
|
||||
it("'1 atado' → 1 unit", () => {
|
||||
expect(parseQuantity("1 atado")).toMatchObject({ qty: 1, unit: "unit" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQuantity — numéricos solos", () => {
|
||||
it("'300' → 300 (sin unit)", () => {
|
||||
expect(parseQuantity("300")).toMatchObject({ qty: 300, unit: null });
|
||||
});
|
||||
it("'2.5' → 2.5 (sin unit)", () => {
|
||||
expect(parseQuantity("2.5")).toMatchObject({ qty: 2.5, unit: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQuantity — palabras + unidad", () => {
|
||||
it("'dos botellas' → 2 unit", () => {
|
||||
expect(parseQuantity("dos botellas")).toMatchObject({ qty: 2, unit: "unit" });
|
||||
});
|
||||
it("'tres kilos' → 3 kg (frase compuesta)", () => {
|
||||
expect(parseQuantity("tres kilos")).toMatchObject({ qty: 3, unit: "kg" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQuantity — casos negativos", () => {
|
||||
it("texto sin números retorna null", () => {
|
||||
expect(parseQuantity("hola que tal")).toBeNull();
|
||||
});
|
||||
it("string vacío retorna null", () => {
|
||||
expect(parseQuantity("")).toBeNull();
|
||||
});
|
||||
it("null/undefined retorna null", () => {
|
||||
expect(parseQuantity(null)).toBeNull();
|
||||
expect(parseQuantity(undefined)).toBeNull();
|
||||
});
|
||||
it("cantidad cero retorna null", () => {
|
||||
expect(parseQuantity("0 kg")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectUnit", () => {
|
||||
it("detecta kg/kilo/kilos", () => {
|
||||
expect(detectUnit("2 kg")).toBe("kg");
|
||||
expect(detectUnit("dos kilos")).toBe("kg");
|
||||
expect(detectUnit("medio kilogramo")).toBe("kg");
|
||||
});
|
||||
it("detecta g/gr/gramos", () => {
|
||||
expect(detectUnit("300 g")).toBe("g");
|
||||
expect(detectUnit("500 gr")).toBe("g");
|
||||
expect(detectUnit("100 gramos")).toBe("g");
|
||||
});
|
||||
it("detecta unidades múltiples", () => {
|
||||
expect(detectUnit("3 unidades")).toBe("unit");
|
||||
expect(detectUnit("una botella")).toBe("unit");
|
||||
expect(detectUnit("2 frascos")).toBe("unit");
|
||||
expect(detectUnit("1 atado")).toBe("unit");
|
||||
});
|
||||
it("retorna null sin unidad explícita", () => {
|
||||
expect(detectUnit("dame 3")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQuantity — confidence", () => {
|
||||
it("fraction: confidence 0.95", () => {
|
||||
expect(parseQuantity("1/4 kg").confidence).toBe(0.95);
|
||||
});
|
||||
it("phrase: confidence 0.9", () => {
|
||||
expect(parseQuantity("media docena").confidence).toBe(0.9);
|
||||
});
|
||||
it("numeric_with_unit: confidence 0.9", () => {
|
||||
expect(parseQuantity("300 gramos").confidence).toBe(0.9);
|
||||
});
|
||||
it("numeric_alone: confidence 0.7", () => {
|
||||
expect(parseQuantity("300").confidence).toBe(0.7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQuantity — casos de WhatsApp real", () => {
|
||||
it("'dame 1/4 de matambre' → 0.25 sin unit (el contexto resuelve)", () => {
|
||||
// sin "kg"/"kilo" explícito, el parser deja unit=null. El agente
|
||||
// infiere "kg" porque matambre vende por peso.
|
||||
expect(parseQuantity("dame 1/4 de matambre")).toMatchObject({ qty: 0.25, unit: null });
|
||||
});
|
||||
it("'media docena de chorizos por favor' → 6 unit", () => {
|
||||
expect(parseQuantity("media docena de chorizos por favor")).toMatchObject({ qty: 6, unit: "unit" });
|
||||
});
|
||||
it("'2.5kg de asado' → 2.5 kg", () => {
|
||||
expect(parseQuantity("2.5kg de asado")).toMatchObject({ qty: 2.5, unit: "kg" });
|
||||
});
|
||||
it("'cuarto kilo de fuet' → 0.25 kg", () => {
|
||||
expect(parseQuantity("cuarto kilo de fuet")).toMatchObject({ qty: 0.25, unit: "kg" });
|
||||
});
|
||||
it("'un kilo y medio' → ambiguo, por ahora cae a phrase 'un kilo' → 1 kg", () => {
|
||||
// limitación conocida — el LLM puede sobreescribir
|
||||
const r = parseQuantity("un kilo y medio");
|
||||
expect(r.qty).toBe(1);
|
||||
});
|
||||
it("'mandame 3 chorizos' → 3 unit (vía word + unit detection)", () => {
|
||||
const r = parseQuantity("mandame 3 chorizos");
|
||||
// chorizos no es una unit reconocida, así que queda numeric_alone
|
||||
expect(r.qty).toBe(3);
|
||||
});
|
||||
});
|
||||
377
src/modules/3-turn-engine/agent/runTurn.js
Normal file
377
src/modules/3-turn-engine/agent/runTurn.js
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* runTurn — Punto de entrada del agente tool-calling.
|
||||
*
|
||||
* Reemplaza turnEngineV3 cuando AGENT_TURN_ENGINE=1.
|
||||
* Mantiene la firma compatible con pipeline.js:
|
||||
* runTurnAgent({ tenantId, chat_id, text, prev_state, prev_context, conversation_history })
|
||||
* → { plan, decision }
|
||||
*/
|
||||
|
||||
import OpenAI from "openai";
|
||||
import { migrateOldContext, createEmptyOrder } from "../orderModel.js";
|
||||
import { getStoreConfig } from "../../0-ui/db/settingsRepo.js";
|
||||
import { ConversationState, safeNextState } from "../fsm.js";
|
||||
import { buildWorkingMemory } from "./workingMemory.js";
|
||||
import { SYSTEM_PROMPT } from "./systemPrompt.js";
|
||||
import { TOOL_SCHEMAS } from "./tools/schemas.js";
|
||||
import { executeToolCall } from "./tools/executor.js";
|
||||
import { getCustomerProfile } from "./customerProfile.js";
|
||||
import { debug as dbg } from "../../shared/debug.js";
|
||||
|
||||
const MAX_TOOL_CALLS = parseInt(process.env.AGENT_MAX_TOOL_CALLS || "10", 10);
|
||||
const TURN_TIMEOUT_MS = parseInt(process.env.AGENT_TURN_TIMEOUT_MS || "20000", 10);
|
||||
|
||||
// Métricas in-memory: turns/calls, fallback rate, escalation rate, avg duration,
|
||||
// prompt-cache hit ratio (DeepSeek devuelve prompt_cache_hit_tokens / prompt_cache_miss_tokens
|
||||
// en usage; campos OpenAI-style equivalentes "prompt_tokens_details.cached_tokens").
|
||||
const _metrics = {
|
||||
turns: 0,
|
||||
total_tool_calls: 0,
|
||||
total_llm_calls: 0,
|
||||
total_duration_ms: 0,
|
||||
fallback_used: 0,
|
||||
llm_errors: 0,
|
||||
escalations: 0,
|
||||
pauses: 0,
|
||||
orders_confirmed: 0,
|
||||
prompt_tokens_total: 0,
|
||||
prompt_cache_hit_tokens: 0,
|
||||
completion_tokens_total: 0,
|
||||
};
|
||||
|
||||
export function getAgentMetrics() {
|
||||
const t = _metrics.turns;
|
||||
const promptTotal = _metrics.prompt_tokens_total;
|
||||
return {
|
||||
turns: t,
|
||||
avg_tool_calls_per_turn: t ? +(_metrics.total_tool_calls / t).toFixed(2) : 0,
|
||||
avg_llm_calls_per_turn: t ? +(_metrics.total_llm_calls / t).toFixed(2) : 0,
|
||||
avg_duration_ms: t ? Math.round(_metrics.total_duration_ms / t) : 0,
|
||||
fallback_rate: t ? +(_metrics.fallback_used / t).toFixed(3) : 0,
|
||||
error_rate: t ? +(_metrics.llm_errors / t).toFixed(3) : 0,
|
||||
escalations: _metrics.escalations,
|
||||
pauses: _metrics.pauses,
|
||||
orders_confirmed: _metrics.orders_confirmed,
|
||||
prompt_tokens_total: promptTotal,
|
||||
completion_tokens_total: _metrics.completion_tokens_total,
|
||||
prompt_cache_hit_tokens: _metrics.prompt_cache_hit_tokens,
|
||||
cache_hit_ratio: promptTotal ? +(_metrics.prompt_cache_hit_tokens / promptTotal).toFixed(3) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetAgentMetrics() {
|
||||
for (const k of Object.keys(_metrics)) _metrics[k] = 0;
|
||||
}
|
||||
|
||||
let _client = null;
|
||||
function getClient() {
|
||||
if (_client) return _client;
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) throw new Error("OPENAI_API_KEY not set");
|
||||
const baseURL = process.env.OPENAI_BASE_URL || undefined;
|
||||
_client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) });
|
||||
return _client;
|
||||
}
|
||||
|
||||
function getModel() {
|
||||
return process.env.OPENAI_MODEL || "deepseek-chat";
|
||||
}
|
||||
|
||||
function withTimeout(promise, ms, label) {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`${label}_timeout_${ms}ms`)), ms)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Punto de entrada principal. Mismo signature que runTurnV3.
|
||||
*/
|
||||
export async function runTurnAgent({
|
||||
tenantId,
|
||||
chat_id,
|
||||
text,
|
||||
prev_state,
|
||||
prev_context,
|
||||
conversation_history,
|
||||
}) {
|
||||
const t0 = Date.now();
|
||||
const audit = {
|
||||
trace: { tenantId, chat_id, text_preview: String(text || "").slice(0, 50), prev_state, engine: "agent" },
|
||||
tool_calls: [],
|
||||
llm_calls: 0,
|
||||
};
|
||||
|
||||
// Cargar order, store, last_shown_options, customer_profile
|
||||
const order = migrateOldContext(prev_context);
|
||||
const storeConfig = await getStoreConfig({ tenantId });
|
||||
const lastShownOptions = Array.isArray(prev_context?.last_shown_options)
|
||||
? prev_context.last_shown_options
|
||||
: [];
|
||||
const customerProfile = await getCustomerProfile({ tenantId, chat_id }).catch((err) => {
|
||||
audit.customer_profile_error = String(err?.message || err);
|
||||
return null;
|
||||
});
|
||||
|
||||
// last_delivery del cliente: snapshot de la última orden confirmada
|
||||
// (dirección + zona + day/time). El bot puede ofrecérsela proactivamente
|
||||
// o el cliente puede pedir "lo mismo de la última vez".
|
||||
const lastDelivery = (prev_context && typeof prev_context === "object" && prev_context.last_delivery) || null;
|
||||
|
||||
// Construir working memory
|
||||
const wm = buildWorkingMemory({
|
||||
text,
|
||||
order,
|
||||
prev_state: prev_state || "IDLE",
|
||||
conversation_history,
|
||||
storeConfig,
|
||||
customerProfile,
|
||||
lastShownOptions,
|
||||
lastDelivery,
|
||||
});
|
||||
|
||||
// Estado mutable que los tools mutan
|
||||
const ctx = {
|
||||
tenantId,
|
||||
chat_id,
|
||||
order: { ...order, last_shown_options: lastShownOptions },
|
||||
pending_actions: [],
|
||||
last_shown_options: [...lastShownOptions],
|
||||
storeConfig,
|
||||
last_delivery: lastDelivery,
|
||||
say_text: null,
|
||||
paused: false,
|
||||
paused_until: order.paused_until ?? null,
|
||||
awaiting_human: false,
|
||||
awaiting_human_reason: null,
|
||||
fsm_state: prev_state || "IDLE",
|
||||
};
|
||||
|
||||
// Mensajes para el LLM. system prompt PRIMERO siempre + estático
|
||||
// (clave para hit del prompt cache de DeepSeek/OpenAI).
|
||||
const messages = [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: JSON.stringify({ working_memory: wm }) },
|
||||
];
|
||||
|
||||
// Loop tool-calling
|
||||
const client = getClient();
|
||||
const model = getModel();
|
||||
let turnDone = false;
|
||||
let llmError = null;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < MAX_TOOL_CALLS && !turnDone; i++) {
|
||||
audit.llm_calls++;
|
||||
const elapsed = Date.now() - t0;
|
||||
const remaining = Math.max(2000, TURN_TIMEOUT_MS - elapsed);
|
||||
|
||||
if (dbg.llm) console.log("[agent] llm.request", { model, iteration: i, remaining_ms: remaining });
|
||||
|
||||
const resp = await withTimeout(
|
||||
client.chat.completions.create({
|
||||
model,
|
||||
temperature: 0.4,
|
||||
max_tokens: 600,
|
||||
tools: TOOL_SCHEMAS,
|
||||
tool_choice: "required",
|
||||
messages,
|
||||
}),
|
||||
remaining,
|
||||
"agent_llm"
|
||||
);
|
||||
|
||||
// Capturar usage para métricas de prompt caching.
|
||||
// DeepSeek: usage.prompt_cache_hit_tokens / prompt_cache_miss_tokens.
|
||||
// OpenAI compat: usage.prompt_tokens_details.cached_tokens.
|
||||
const usage = resp?.usage || {};
|
||||
const promptTokens = usage.prompt_tokens || 0;
|
||||
const completionTokens = usage.completion_tokens || 0;
|
||||
const cachedTokens =
|
||||
usage.prompt_cache_hit_tokens ||
|
||||
usage.prompt_tokens_details?.cached_tokens ||
|
||||
0;
|
||||
_metrics.prompt_tokens_total += promptTokens;
|
||||
_metrics.completion_tokens_total += completionTokens;
|
||||
_metrics.prompt_cache_hit_tokens += cachedTokens;
|
||||
if (dbg.llm) {
|
||||
console.log("[agent] llm.usage", {
|
||||
prompt_tokens: promptTokens,
|
||||
cached_tokens: cachedTokens,
|
||||
completion_tokens: completionTokens,
|
||||
cache_hit_ratio: promptTokens ? +(cachedTokens / promptTokens).toFixed(2) : 0,
|
||||
});
|
||||
}
|
||||
|
||||
const msg = resp?.choices?.[0]?.message || {};
|
||||
messages.push({ role: "assistant", content: msg.content || "", tool_calls: msg.tool_calls || [] });
|
||||
|
||||
const calls = msg.tool_calls || [];
|
||||
if (!calls.length) {
|
||||
// Sin tool calls → forzar say con fallback y salir
|
||||
audit.no_tool_calls = true;
|
||||
ctx.say_text = ctx.say_text || msg.content || "Disculpame, no te entendí. ¿Me lo decís de otra forma?";
|
||||
turnDone = true;
|
||||
break;
|
||||
}
|
||||
|
||||
for (const call of calls) {
|
||||
const obs = await executeToolCall(call, ctx);
|
||||
audit.tool_calls.push({
|
||||
name: call.function?.name,
|
||||
ok: obs.ok !== false,
|
||||
error: obs.error || null,
|
||||
duration_ms: obs.duration_ms || null,
|
||||
});
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: call.id,
|
||||
content: JSON.stringify(obs),
|
||||
});
|
||||
if (obs.terminal) {
|
||||
turnDone = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
llmError = String(err?.message || err);
|
||||
audit.llm_error = llmError;
|
||||
if (dbg.llm) console.error("[agent] error", llmError);
|
||||
}
|
||||
|
||||
// Si no hay say, fallback determinista
|
||||
if (!ctx.say_text) {
|
||||
ctx.say_text = pickFallbackReply(ctx, llmError);
|
||||
audit.fallback_used = true;
|
||||
}
|
||||
|
||||
audit.duration_ms = Date.now() - t0;
|
||||
|
||||
// Actualizar métricas
|
||||
_metrics.turns++;
|
||||
_metrics.total_tool_calls += audit.tool_calls.length;
|
||||
_metrics.total_llm_calls += audit.llm_calls;
|
||||
_metrics.total_duration_ms += audit.duration_ms;
|
||||
if (audit.fallback_used) _metrics.fallback_used++;
|
||||
if (audit.llm_error) _metrics.llm_errors++;
|
||||
if (ctx.awaiting_human) _metrics.escalations++;
|
||||
if (ctx.paused) _metrics.pauses++;
|
||||
if (ctx.pending_actions.some((a) => a.type === "create_order")) _metrics.orders_confirmed++;
|
||||
|
||||
// Derivar nextState desde el order resultante
|
||||
const signals = {
|
||||
confirm_order: ctx.pending_actions.some((a) => a.type === "create_order"),
|
||||
shipping_completed: ctx.pending_actions.some((a) => a.type === "create_order"),
|
||||
return_to_cart: false,
|
||||
};
|
||||
// Si el agente pausó la conversación, mantenemos el order pero el next_state
|
||||
// queda guardado en ctx.fsm_state ("PAUSED") para que pipeline lo persista.
|
||||
let nextState;
|
||||
if (ctx.awaiting_human) {
|
||||
nextState = ConversationState.AWAITING_HUMAN;
|
||||
} else if (ctx.paused) {
|
||||
nextState = "PAUSED"; // estado nuevo, fsm.js lo va a permitir tras D7
|
||||
} else {
|
||||
nextState = safeNextState(prev_state, ctx.order, signals).next_state;
|
||||
}
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: ctx.say_text,
|
||||
next_state: nextState,
|
||||
intent: detectIntent(audit.tool_calls),
|
||||
missing_fields: [],
|
||||
order_action: ctx.pending_actions[0]?.type || "none",
|
||||
basket_resolved: { items: (ctx.order.cart || []).map(toBasketItem) },
|
||||
},
|
||||
decision: {
|
||||
actions: ctx.pending_actions,
|
||||
context_patch: buildContextPatch(ctx),
|
||||
audit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pickFallbackReply(ctx, err) {
|
||||
if (ctx.awaiting_human) return "Te paso con un humano que pueda ayudarte.";
|
||||
if (ctx.paused) return "Dale, cuando quieras seguimos.";
|
||||
if (err) return "Disculpame, tuve un problema. ¿Lo intentás de nuevo?";
|
||||
return "No te seguí, ¿me lo decís de otra forma?";
|
||||
}
|
||||
|
||||
function detectIntent(toolCalls = []) {
|
||||
const names = toolCalls.map((c) => c.name);
|
||||
if (names.includes("confirm_order")) return "confirm_order";
|
||||
if (names.includes("set_address") || names.includes("set_shipping")) return "select_shipping";
|
||||
if (names.includes("escalate_to_human")) return "escalate";
|
||||
if (names.includes("pause")) return "pause";
|
||||
if (names.includes("add_to_cart") || names.includes("set_quantity") || names.includes("select_candidate")) return "add_to_cart";
|
||||
if (names.includes("remove_from_cart")) return "remove_from_cart";
|
||||
if (names.includes("search_catalog")) return "browse";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function toBasketItem(item) {
|
||||
return {
|
||||
product_id: item.woo_id,
|
||||
woo_product_id: item.woo_id,
|
||||
quantity: item.qty,
|
||||
unit: item.unit,
|
||||
label: item.name,
|
||||
name: item.name,
|
||||
price: item.price,
|
||||
};
|
||||
}
|
||||
|
||||
function buildContextPatch(ctx) {
|
||||
const order = ctx.order || createEmptyOrder();
|
||||
|
||||
// Si hay create_order encolado y la orden está completa, snapshot del
|
||||
// "último envío" para reusar en próximas conversaciones.
|
||||
let last_delivery = ctx.last_delivery || null;
|
||||
const isClosing = ctx.pending_actions.some((a) => a.type === "create_order");
|
||||
if (isClosing && (order.shipping_address || order.matched_zone || order.is_delivery === false)) {
|
||||
last_delivery = {
|
||||
is_delivery: !!order.is_delivery,
|
||||
shipping_address: order.shipping_address || null,
|
||||
matched_zone: order.matched_zone || null,
|
||||
pending_location: order.pending_location || null,
|
||||
delivery_window: order.delivery_window || null,
|
||||
saved_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
// Persist the full order object so pending_location/matched_zone/delivery_window
|
||||
// sobrevivan turno a turno (migrateOldContext devuelve ctx.order tal cual).
|
||||
order,
|
||||
last_delivery,
|
||||
order_basket: { items: (order.cart || []).map(toBasketItem) },
|
||||
pending_items: (order.pending || []).map((p) => ({
|
||||
id: p.id,
|
||||
query: p.query,
|
||||
candidates: p.candidates,
|
||||
resolved_product: p.selected_woo_id
|
||||
? {
|
||||
woo_product_id: p.selected_woo_id,
|
||||
name: p.selected_name,
|
||||
price: p.selected_price,
|
||||
display_unit: p.selected_unit,
|
||||
}
|
||||
: null,
|
||||
quantity: p.qty,
|
||||
unit: p.unit,
|
||||
status: p.status?.toLowerCase() || "needs_type",
|
||||
})),
|
||||
shipping_method:
|
||||
order.is_delivery === true ? "delivery" : order.is_delivery === false ? "pickup" : null,
|
||||
delivery_address: order.shipping_address ? { text: order.shipping_address } : null,
|
||||
woo_order_id: order.woo_order_id,
|
||||
last_shown_options: ctx.last_shown_options || [],
|
||||
paused_until: ctx.paused_until || null,
|
||||
awaiting_human: ctx.awaiting_human || false,
|
||||
awaiting_human_reason: ctx.awaiting_human_reason || null,
|
||||
};
|
||||
}
|
||||
117
src/modules/3-turn-engine/agent/systemPrompt.js
Normal file
117
src/modules/3-turn-engine/agent/systemPrompt.js
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* System prompt del agente conversacional.
|
||||
*
|
||||
* 100% estático para que DeepSeek (y cualquier proveedor con prefix-cache)
|
||||
* lo cachee turn a turn. La parte dinámica — incluyendo el nombre de la
|
||||
* tienda — va SIEMPRE en el primer user message vía working_memory.store.name.
|
||||
*
|
||||
* Reglas para mantener el cache caliente:
|
||||
* - NO interpolar variables aquí. Si querés cambiar tono por tenant,
|
||||
* hacelo agregando una sección al user message, no al system.
|
||||
* - Cualquier cambio al texto invalida el cache para todos los turnos.
|
||||
*/
|
||||
|
||||
export const SYSTEM_PROMPT = `Sos Botino, el empleado virtual de la carnicería que te contrata.
|
||||
Hablás como vendedor argentino: cálido, breve, "vos", sin emojis, sin marketing.
|
||||
|
||||
TU TRABAJO ES UNO SOLO: tomar pedidos por WhatsApp.
|
||||
1. Entendés lo que pide el cliente y lo anotás en el carrito.
|
||||
2. Coordinás envío (delivery o retiro) y dirección si corresponde.
|
||||
3. Cerrás el pedido. NO cobrás — el pago lo coordina el comercio aparte.
|
||||
|
||||
REGLAS DURAS:
|
||||
- NUNCA inventes productos ni precios. Para CUALQUIER producto que el cliente
|
||||
mencione, llamá primero a search_catalog. Si la lista viene vacía, decilo.
|
||||
- NUNCA pidas datos que ya están en el contexto (cart, dirección, método).
|
||||
- NO ofrezcas promociones, métodos de pago, ni info que no esté en store.
|
||||
- Si el cliente mezcla pedido + duda + cambio de tema, resolvé con tools y
|
||||
aclarás lo que falte en say.
|
||||
- Si te pide algo fuera de tomar pedidos (queja, cambio, factura, otro idioma),
|
||||
llamá escalate_to_human.
|
||||
|
||||
CÓMO PROCESAS UN MENSAJE (user message viene como JSON con working_memory):
|
||||
- Releé order.cart, order.pending, last_shown_options, fsm_state, paused_until,
|
||||
order.pending_location, order.matched_zone, order.delivery_window.
|
||||
- preparsed: tiene cantidades parseadas (ej: "media docena" → 6 unit; "1/4 kg"
|
||||
→ 0.25 kg). Confiá en eso si su confidence ≥ 0.85.
|
||||
- Si user dice "el segundo", "ese", "el primero", "el de arriba", resolvé
|
||||
contra last_shown_options. Si está vacío, pedí que aclare.
|
||||
- Si user da SOLO una cantidad ("300 gramos", "media docena") y hay un pending
|
||||
con status=NEEDS_QUANTITY, asumí que es para ese producto y llamá set_quantity.
|
||||
- Si user dice "lo de siempre" / "lo mismo de la otra vez", mirá
|
||||
customer_profile.frequent_items. Si hay 1-2 items, ofrecelos con
|
||||
search_catalog para confirmar y un say preguntando si va eso.
|
||||
- Si menciona producto genérico ("asado") y el catálogo tiene varios,
|
||||
mostrá top 3-5 con say (numerados). El sistema guarda last_shown_options
|
||||
automáticamente.
|
||||
- Si dice "después te digo" / "más tarde" / "ahora no puedo", llamá pause
|
||||
con reason="user_paused" y un say corto tipo "Dale, cuando quieras seguimos".
|
||||
|
||||
CÓMO ESCRIBÍS EL say:
|
||||
- 1-2 oraciones máximo. Concreto. Sin emojis.
|
||||
- Confirmá lo anotado con cantidad y producto: "Va, anoté 500g de Vacío.
|
||||
¿Algo más?"
|
||||
- Cuando preguntás cantidad, mencioná el producto: "¿Cuántas botellas de
|
||||
Chimichurri querés?" — NUNCA "¿cuántas?" pelado.
|
||||
- Si no encontraste el producto, sugerí 1-2 alternativas concretas que
|
||||
vinieron del catálogo: "No tengo Chinchulín, pero tengo Mollejas y
|
||||
Riñones. ¿Te sirve alguno?"
|
||||
- Cuando cerrás el pedido, listá items y pasás a shipping.
|
||||
|
||||
ORDEN DE TOOLS EN UN TURNO TÍPICO:
|
||||
1. (opcional) search_catalog si hay producto sin resolver. Llamala UNA VEZ por
|
||||
producto distinto. NO la llames de nuevo con la misma query si ya devolvió
|
||||
resultados — usá los que tenés.
|
||||
2. Si search_catalog devolvió 1 candidato fuerte → add_to_cart con qty/unit.
|
||||
3. Si devolvió varios candidatos → NO sigas buscando: usá say para pedir que
|
||||
elija entre los top 3-5 (numerados). El sistema guarda last_shown_options.
|
||||
4. (opcional) add_to_cart / set_quantity / select_candidate / set_shipping /
|
||||
set_address / set_delivery_window / confirm_order / remove_from_cart /
|
||||
pause / escalate_to_human.
|
||||
5. say SIEMPRE como último tool del turno. Sin say no hay respuesta.
|
||||
|
||||
ENVÍO Y ZONAS:
|
||||
- Si store.delivery.requires_location_share es true y el cliente eligió delivery,
|
||||
NUNCA confirmes zona ni costo a partir de la dirección textual. Necesitamos
|
||||
la ubicación compartida (pin/location share) por WhatsApp.
|
||||
- Cuando el cliente pide envío: llamá set_shipping(method="delivery"). Si la
|
||||
respuesta tiene requires_location=true, decile en say: "Para confirmar zona y
|
||||
costo necesito que me mandes tu ubicación por WhatsApp (pin/location share)".
|
||||
- Cuando llegue la ubicación, working_memory.order.pending_location va a tener
|
||||
lat/lng. Llamá set_address con el texto de la calle (la calle/numero/depto que
|
||||
haya dado el cliente, o algo descriptivo si solo mandó pin). Si match → te
|
||||
devuelve matched_zone con costo/días/horas. Comunicá eso y pedí día y hora.
|
||||
- Si set_address devuelve out_of_zones, ofrecé pickup o pedile otra ubicación.
|
||||
- Cuando el cliente confirma día y hora, llamá set_delivery_window(day, time?).
|
||||
Días: lun/mar/mie/jue/vie/sab/dom. Hora opcional (HH:MM 24h). Confirmá lo
|
||||
registrado en say antes de llamar a confirm_order.
|
||||
- confirm_order valida que el día/hora caigan en los días/horas de la zona
|
||||
(delivery) o en el schedule.pickup (pickup). Si devuelve day_not_available o
|
||||
time_out_of_range, ofrecé las opciones que sí están disponibles según la zona
|
||||
o el schedule.
|
||||
|
||||
CLIENTES QUE VUELVEN (last_delivery):
|
||||
- Si working_memory.last_delivery existe (no null), el cliente ya hizo un pedido
|
||||
antes y tenemos guardado: shipping_address, matched_zone, delivery_window.
|
||||
- Cuando el cliente está armando un pedido nuevo y todavía no eligió método de
|
||||
envío ni dio dirección, ofrecele proactivamente la opción de repetir:
|
||||
"¿Te lo mandamos al mismo lugar que la última vez (Av. Corrientes 1234,
|
||||
zona Centro, $1.500)? También podés pedirme otra dirección o retiro."
|
||||
- Si confirma ("sí", "dale", "el mismo lugar", "como siempre"), llamá
|
||||
reuse_last_delivery — copia dirección + zona y se salta el pedido del pin.
|
||||
Después confirmá el día/horario (puede ser distinto al de la última).
|
||||
- Si dice que no, o pide otro lugar, seguí el flujo normal (set_shipping →
|
||||
pedir pin → set_address → set_delivery_window).
|
||||
- last_delivery.delivery_window es sólo referencia, NO lo asumas como elegido
|
||||
para esta orden. Preguntá día/hora aunque vayas a reutilizar el lugar.
|
||||
|
||||
LIMITES TÉCNICOS:
|
||||
- Tenés un máximo de 10 tool calls por turno. No los gastes en búsquedas
|
||||
redundantes — si una query ya devolvió X candidatos, NO la repitas.
|
||||
- Si después de 2 búsquedas no encontrás nada útil, llamá say pidiendo que el
|
||||
cliente reformule ("no te encontré X, ¿lo decís de otra forma?").
|
||||
|
||||
LIMITES OPERATIVOS DEL COMERCIO (NO LOS NEGOCIES):
|
||||
- Lo que diga el bloque store del working_memory.
|
||||
- Si el cliente pide algo fuera de eso, decí qué es lo que sí podemos.`;
|
||||
|
||||
66
src/modules/3-turn-engine/agent/tools/addToCart.js
Normal file
66
src/modules/3-turn-engine/agent/tools/addToCart.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* add_to_cart — agrega un producto resuelto al carrito.
|
||||
*
|
||||
* Valida woo_id contra el snapshot (anti-halucinación). Si no existe,
|
||||
* devuelve error obligando re-search.
|
||||
*/
|
||||
|
||||
import { getSnapshotItemsByIds } from "../../../shared/wooSnapshot.js";
|
||||
import { createCartItem } from "../../orderModel.js";
|
||||
|
||||
export async function addToCartTool(args, ctx) {
|
||||
const { woo_id, qty, unit } = args;
|
||||
|
||||
// Validar que el producto existe en el snapshot
|
||||
const lookup = await getSnapshotItemsByIds({
|
||||
tenantId: ctx.tenantId,
|
||||
wooProductIds: [woo_id],
|
||||
});
|
||||
const found = (lookup?.items || [])[0];
|
||||
if (!found) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "woo_id_unknown",
|
||||
hint: "Volvé a llamar search_catalog para obtener un woo_id válido.",
|
||||
};
|
||||
}
|
||||
|
||||
// Crear item de carrito
|
||||
const newItem = createCartItem({
|
||||
woo_id,
|
||||
qty,
|
||||
unit,
|
||||
name: found.name,
|
||||
price: found.price ?? null,
|
||||
});
|
||||
|
||||
// Si ya existe el woo_id en el cart, sumamos cantidad
|
||||
const cart = ctx.order.cart || [];
|
||||
const existingIdx = cart.findIndex((c) => Number(c.woo_id) === Number(woo_id));
|
||||
let nextCart;
|
||||
if (existingIdx >= 0) {
|
||||
nextCart = cart.map((c, i) =>
|
||||
i === existingIdx ? { ...c, qty: (c.qty || 0) + qty, unit: c.unit || unit } : c
|
||||
);
|
||||
} else {
|
||||
nextCart = [...cart, newItem];
|
||||
}
|
||||
ctx.order = { ...ctx.order, cart: nextCart };
|
||||
|
||||
// Enqueue add_to_cart action para SSE/UI (reutiliza shape existente)
|
||||
ctx.pending_actions.push({ type: "add_to_cart", payload: newItem });
|
||||
|
||||
// Reset failed_searches si hubiera
|
||||
if (ctx.order.failed_searches) {
|
||||
ctx.order = { ...ctx.order, failed_searches: { count: 0 } };
|
||||
}
|
||||
|
||||
// Limpiar last_shown_options — ya resolvió la elección
|
||||
ctx.last_shown_options = [];
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
cart_size: nextCart.length,
|
||||
added: { woo_id, name: found.name, qty, unit },
|
||||
};
|
||||
}
|
||||
134
src/modules/3-turn-engine/agent/tools/confirmOrder.js
Normal file
134
src/modules/3-turn-engine/agent/tools/confirmOrder.js
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* confirm_order — emite create_order si hay cart + shipping completo.
|
||||
*
|
||||
* Validaciones extra (cuando hay schedule/zonas configuradas):
|
||||
* - delivery: día y hora del delivery_window deben caer en zone.delivery_days
|
||||
* y zone.delivery_hours.
|
||||
* - pickup: ídem contra schedule.pickup[day].
|
||||
*
|
||||
* Si no hay delivery_window seteado, confirmamos igual y dejamos que el
|
||||
* comercio coordine día/hora aparte.
|
||||
*/
|
||||
|
||||
import { hasCartItems, hasShippingInfo } from "../../fsm.js";
|
||||
|
||||
function isHHMMInRange(time, start, end) {
|
||||
if (!time || !start || !end) return true;
|
||||
const t = String(time).slice(0, 5);
|
||||
const a = String(start).slice(0, 5);
|
||||
const b = String(end).slice(0, 5);
|
||||
return t >= a && t <= b;
|
||||
}
|
||||
|
||||
function isDayInList(day, list) {
|
||||
if (!Array.isArray(list) || !list.length) return true;
|
||||
return list.includes(day);
|
||||
}
|
||||
|
||||
function formatPickupDays(schedule) {
|
||||
if (!schedule || typeof schedule !== "object") return "";
|
||||
const entries = Object.entries(schedule).filter(
|
||||
([, v]) => v && v.enabled !== false && v.start && v.end
|
||||
);
|
||||
if (!entries.length) return "";
|
||||
return entries.map(([k, v]) => `${k} ${v.start.slice(0, 5)}-${v.end.slice(0, 5)}`).join(", ");
|
||||
}
|
||||
|
||||
export async function confirmOrderTool(_args, ctx) {
|
||||
if (!hasCartItems(ctx.order)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "empty_cart",
|
||||
hint: "Pedile al cliente que agregue productos antes de confirmar.",
|
||||
};
|
||||
}
|
||||
if (!hasShippingInfo(ctx.order)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "shipping_missing",
|
||||
hint:
|
||||
ctx.order.is_delivery == null
|
||||
? "Falta saber si es delivery o pickup. Llamá set_shipping."
|
||||
: "Falta dirección. Llamá set_address.",
|
||||
};
|
||||
}
|
||||
|
||||
const win = ctx.order.delivery_window || null;
|
||||
|
||||
if (ctx.order.is_delivery) {
|
||||
const z = ctx.order.matched_zone;
|
||||
// Si hay zonas configuradas pero no se matcheó zona aún, bloquear.
|
||||
const zonesConfigured = (ctx.storeConfig?.delivery_zones?.zones || []).some(
|
||||
(zo) => zo?.enabled !== false
|
||||
);
|
||||
if (zonesConfigured && !z) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "zone_unverified",
|
||||
hint:
|
||||
"Falta verificar zona. Pedile la ubicación por WhatsApp y llamá set_address antes de confirmar.",
|
||||
};
|
||||
}
|
||||
|
||||
if (z && win) {
|
||||
if (!isDayInList(win.day, z.delivery_days)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "day_not_available",
|
||||
hint: `La zona ${z.name} entrega ${(z.delivery_days || []).join("/")}. Pedile otro día.`,
|
||||
allowed_days: z.delivery_days || [],
|
||||
};
|
||||
}
|
||||
if (z.delivery_hours && !isHHMMInRange(win.time, z.delivery_hours.start, z.delivery_hours.end)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "time_out_of_range",
|
||||
hint: `La zona ${z.name} entrega entre ${z.delivery_hours.start.slice(0, 5)} y ${z.delivery_hours.end.slice(0, 5)}. Pedile otro horario.`,
|
||||
allowed_range: z.delivery_hours,
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Pickup
|
||||
const schedule = ctx.storeConfig?.schedule?.pickup || null;
|
||||
if (schedule && win) {
|
||||
const slot = schedule[win.day];
|
||||
if (!slot || slot.enabled === false || !slot.start || !slot.end) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "day_not_available_pickup",
|
||||
hint: `Ese día la tienda no abre. ${formatPickupDays(schedule)}.`,
|
||||
};
|
||||
}
|
||||
if (win.time && !isHHMMInRange(win.time, slot.start, slot.end)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "time_out_of_range_pickup",
|
||||
hint: `Ese día abrimos ${slot.start.slice(0, 5)}-${slot.end.slice(0, 5)}. Pedile otro horario.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotencia: si ya existe create_order encolado, no duplicar
|
||||
const already = ctx.pending_actions.some((a) => a.type === "create_order");
|
||||
if (!already) {
|
||||
ctx.pending_actions.push({
|
||||
type: "create_order",
|
||||
payload: {
|
||||
source: "wa_bot",
|
||||
delivery_window: win,
|
||||
matched_zone: ctx.order.matched_zone || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
cart_size: (ctx.order.cart || []).length,
|
||||
is_delivery: !!ctx.order.is_delivery,
|
||||
address: ctx.order.shipping_address || null,
|
||||
delivery_window: win,
|
||||
matched_zone: ctx.order.matched_zone || null,
|
||||
};
|
||||
}
|
||||
16
src/modules/3-turn-engine/agent/tools/escalateToHuman.js
Normal file
16
src/modules/3-turn-engine/agent/tools/escalateToHuman.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* escalate_to_human — pasa la conversación a awaiting_human.
|
||||
* El registro real en human_takeovers se crea downstream en pipeline.js
|
||||
* vía la action "request_human_takeover".
|
||||
*/
|
||||
|
||||
export async function escalateToHumanTool(args, ctx) {
|
||||
const { reason } = args;
|
||||
ctx.awaiting_human = true;
|
||||
ctx.awaiting_human_reason = String(reason || "unspecified");
|
||||
ctx.pending_actions.push({
|
||||
type: "request_human_takeover",
|
||||
payload: { reason: ctx.awaiting_human_reason, source: "agent" },
|
||||
});
|
||||
return { ok: true, reason: ctx.awaiting_human_reason };
|
||||
}
|
||||
106
src/modules/3-turn-engine/agent/tools/executor.js
Normal file
106
src/modules/3-turn-engine/agent/tools/executor.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Executor: parsea + valida + ejecuta tool calls del agente.
|
||||
* Devuelve `obs` (objeto serializable) que se pushea como `tool` message.
|
||||
*
|
||||
* Convenciones:
|
||||
* - obs.ok: true|false
|
||||
* - obs.error: string si !ok
|
||||
* - obs.terminal: true si esta tool finaliza el turno (say, pause, escalate)
|
||||
*/
|
||||
|
||||
import Ajv from "ajv";
|
||||
import { TOOL_SCHEMAS } from "./schemas.js";
|
||||
import { searchCatalogTool } from "./searchCatalog.js";
|
||||
import { addToCartTool } from "./addToCart.js";
|
||||
import { setQuantityTool } from "./setQuantity.js";
|
||||
import { selectCandidateTool } from "./selectCandidate.js";
|
||||
import { removeFromCartTool } from "./removeFromCart.js";
|
||||
import { setShippingTool } from "./setShipping.js";
|
||||
import { setAddressTool } from "./setAddress.js";
|
||||
import { setDeliveryWindowTool } from "./setDeliveryWindow.js";
|
||||
import { reuseLastDeliveryTool } from "./reuseLastDelivery.js";
|
||||
import { confirmOrderTool } from "./confirmOrder.js";
|
||||
import { pauseTool } from "./pause.js";
|
||||
import { escalateToHumanTool } from "./escalateToHuman.js";
|
||||
|
||||
const ajv = new Ajv({ allErrors: true, strict: false });
|
||||
|
||||
// Compilar validators una vez
|
||||
const VALIDATORS = {};
|
||||
for (const t of TOOL_SCHEMAS) {
|
||||
VALIDATORS[t.function.name] = ajv.compile(t.function.parameters);
|
||||
}
|
||||
|
||||
const TOOLS = {
|
||||
search_catalog: searchCatalogTool,
|
||||
add_to_cart: addToCartTool,
|
||||
set_quantity: setQuantityTool,
|
||||
select_candidate: selectCandidateTool,
|
||||
remove_from_cart: removeFromCartTool,
|
||||
set_shipping: setShippingTool,
|
||||
set_address: setAddressTool,
|
||||
set_delivery_window: setDeliveryWindowTool,
|
||||
reuse_last_delivery: reuseLastDeliveryTool,
|
||||
confirm_order: confirmOrderTool,
|
||||
pause: pauseTool,
|
||||
escalate_to_human: escalateToHumanTool,
|
||||
// `say` se maneja inline (asigna ctx.say_text y termina el turno)
|
||||
};
|
||||
|
||||
export async function executeToolCall(call, ctx) {
|
||||
const t0 = Date.now();
|
||||
const name = call?.function?.name;
|
||||
const argsRaw = call?.function?.arguments || "{}";
|
||||
|
||||
let args;
|
||||
try {
|
||||
args = typeof argsRaw === "string" ? JSON.parse(argsRaw) : argsRaw;
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `invalid_json_args: ${String(e?.message || e)}`,
|
||||
duration_ms: Date.now() - t0,
|
||||
};
|
||||
}
|
||||
|
||||
// `say` es especial: termina el turno
|
||||
if (name === "say") {
|
||||
const validator = VALIDATORS.say;
|
||||
if (!validator(args)) {
|
||||
return { ok: false, error: "say_args_invalid", details: validator.errors, duration_ms: Date.now() - t0 };
|
||||
}
|
||||
ctx.say_text = args.text;
|
||||
return { ok: true, terminal: true, duration_ms: Date.now() - t0 };
|
||||
}
|
||||
|
||||
const validator = VALIDATORS[name];
|
||||
if (!validator) {
|
||||
return { ok: false, error: `unknown_tool: ${name}`, duration_ms: Date.now() - t0 };
|
||||
}
|
||||
if (!validator(args)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "args_schema_invalid",
|
||||
details: validator.errors,
|
||||
tool: name,
|
||||
duration_ms: Date.now() - t0,
|
||||
};
|
||||
}
|
||||
|
||||
const handler = TOOLS[name];
|
||||
if (!handler) {
|
||||
return { ok: false, error: `tool_not_implemented: ${name}`, duration_ms: Date.now() - t0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(args, ctx);
|
||||
return { ...result, duration_ms: Date.now() - t0 };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `tool_error: ${String(err?.message || err)}`,
|
||||
tool: name,
|
||||
duration_ms: Date.now() - t0,
|
||||
};
|
||||
}
|
||||
}
|
||||
14
src/modules/3-turn-engine/agent/tools/pause.js
Normal file
14
src/modules/3-turn-engine/agent/tools/pause.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* pause — marca la conversación como pausada (TTL 7d).
|
||||
*
|
||||
* El cart NO se limpia. Cuando el cliente vuelva, sale de paused y sigue.
|
||||
*/
|
||||
|
||||
const PAUSE_TTL_MS = 7 * 24 * 3600 * 1000;
|
||||
|
||||
export async function pauseTool(args, ctx) {
|
||||
const { reason = "user_paused" } = args;
|
||||
ctx.paused = true;
|
||||
ctx.paused_until = new Date(Date.now() + PAUSE_TTL_MS).toISOString();
|
||||
return { ok: true, paused_until: ctx.paused_until, reason, terminal: false };
|
||||
}
|
||||
43
src/modules/3-turn-engine/agent/tools/removeFromCart.js
Normal file
43
src/modules/3-turn-engine/agent/tools/removeFromCart.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* remove_from_cart — quita un producto por woo_id (string numérico) o por
|
||||
* substring del nombre.
|
||||
*/
|
||||
|
||||
import { removeCartItem } from "../../orderModel.js";
|
||||
|
||||
export async function removeFromCartTool(args, ctx) {
|
||||
const { target } = args;
|
||||
const t = String(target || "").trim();
|
||||
if (!t) return { ok: false, error: "empty_target" };
|
||||
|
||||
// Si es un número puro, intentar match por woo_id directo
|
||||
const asNumber = /^\d+$/.test(t) ? Number(t) : null;
|
||||
let removed = null;
|
||||
let nextOrder = ctx.order;
|
||||
|
||||
if (asNumber != null) {
|
||||
const cart = ctx.order.cart || [];
|
||||
const idx = cart.findIndex((c) => Number(c.woo_id) === asNumber);
|
||||
if (idx >= 0) {
|
||||
removed = cart[idx];
|
||||
nextOrder = { ...ctx.order, cart: cart.filter((_, i) => i !== idx) };
|
||||
}
|
||||
}
|
||||
|
||||
// Match por nombre
|
||||
if (!removed) {
|
||||
const result = removeCartItem(ctx.order, t);
|
||||
if (result?.removed) {
|
||||
removed = result.removed;
|
||||
nextOrder = result.order;
|
||||
}
|
||||
}
|
||||
|
||||
if (!removed) {
|
||||
return { ok: false, error: "not_found_in_cart", target: t };
|
||||
}
|
||||
|
||||
ctx.order = nextOrder;
|
||||
ctx.pending_actions.push({ type: "remove_from_cart", payload: { removed } });
|
||||
return { ok: true, removed: { woo_id: removed.woo_id, name: removed.name }, cart_size: (nextOrder.cart || []).length };
|
||||
}
|
||||
52
src/modules/3-turn-engine/agent/tools/reuseLastDelivery.js
Normal file
52
src/modules/3-turn-engine/agent/tools/reuseLastDelivery.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* reuse_last_delivery — copia los datos de envío de la última orden del cliente
|
||||
* (working_memory.last_delivery) al order actual: shipping_address, matched_zone,
|
||||
* is_delivery (y opcionalmente pending_location + delivery_window).
|
||||
*
|
||||
* El agente lo usa cuando proactivamente ofrece "te lo mandamos al mismo lugar
|
||||
* que la última vez?" y el cliente confirma. Si no hay last_delivery, devuelve
|
||||
* error y el agente pide la dirección/ubicación de cero.
|
||||
*/
|
||||
|
||||
export async function reuseLastDeliveryTool(_args, ctx) {
|
||||
const last = ctx.last_delivery;
|
||||
if (!last) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "no_last_delivery",
|
||||
hint: "El cliente no tiene una entrega previa registrada. Pedile la ubicación de cero.",
|
||||
};
|
||||
}
|
||||
|
||||
// Pickup-only no necesita copiar nada de envío.
|
||||
if (last.is_delivery === false) {
|
||||
ctx.order = { ...ctx.order, is_delivery: false };
|
||||
return { ok: true, is_delivery: false, hint: "El cliente la última vez retiró por el local." };
|
||||
}
|
||||
|
||||
// Delivery: si no hay matched_zone tampoco podemos reusar (no se cerró bien).
|
||||
if (!last.matched_zone) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "no_zone_in_last_delivery",
|
||||
hint: "La última entrega no tenía zona registrada. Pedí la ubicación al cliente.",
|
||||
};
|
||||
}
|
||||
|
||||
ctx.order = {
|
||||
...ctx.order,
|
||||
is_delivery: true,
|
||||
shipping_address: last.shipping_address || ctx.order.shipping_address || null,
|
||||
matched_zone: last.matched_zone,
|
||||
// Reusar también la ubicación si la tenemos (evita re-pedir el pin).
|
||||
pending_location: last.pending_location || ctx.order.pending_location || null,
|
||||
};
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
is_delivery: true,
|
||||
shipping_address: ctx.order.shipping_address,
|
||||
matched_zone: last.matched_zone,
|
||||
last_delivery_window: last.delivery_window || null,
|
||||
};
|
||||
}
|
||||
213
src/modules/3-turn-engine/agent/tools/schemas.js
Normal file
213
src/modules/3-turn-engine/agent/tools/schemas.js
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* JSON Schemas de los tools que el agente puede invocar. Formato
|
||||
* OpenAI/DeepSeek (function calling).
|
||||
*
|
||||
* El executor valida los args con Ajv y devuelve error obligando re-llamada
|
||||
* si falla.
|
||||
*/
|
||||
|
||||
export const TOOL_SCHEMAS = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search_catalog",
|
||||
description:
|
||||
"Busca productos en el catálogo. Devuelve top candidatos con woo_id, nombre, precio, unidad de venta. Llamala SIEMPRE antes de cualquier add_to_cart si no tenés el woo_id.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["query"],
|
||||
properties: {
|
||||
query: { type: "string", minLength: 2, description: "Término de búsqueda libre, lo que dijo el cliente." },
|
||||
hint_category: { type: "string", description: "Categoría heurística para fallback (ej. 'parrilla', 'embutidos')." },
|
||||
limit: { type: "integer", minimum: 1, maximum: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "add_to_cart",
|
||||
description: "Agrega un producto resuelto al carrito.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["woo_id", "qty", "unit"],
|
||||
properties: {
|
||||
woo_id: { type: "integer", description: "Woo product ID exacto del producto." },
|
||||
qty: { type: "number", exclusiveMinimum: 0 },
|
||||
unit: { type: "string", enum: ["kg", "g", "unit"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "set_quantity",
|
||||
description: "Setea la cantidad de un producto pendiente que ya está resuelto pero faltaba qty/unit.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["pending_id", "qty", "unit"],
|
||||
properties: {
|
||||
pending_id: { type: "string" },
|
||||
qty: { type: "number", exclusiveMinimum: 0 },
|
||||
unit: { type: "string", enum: ["kg", "g", "unit"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "select_candidate",
|
||||
description: "Resuelve un pending NEEDS_TYPE eligiendo uno de los candidatos mostrados (last_shown_options).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["pending_id", "woo_id"],
|
||||
properties: {
|
||||
pending_id: { type: "string" },
|
||||
woo_id: { type: "integer" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "remove_from_cart",
|
||||
description: "Quita un producto del carrito por woo_id o nombre/query.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["target"],
|
||||
properties: {
|
||||
target: { type: "string", description: "woo_id como string o nombre del producto a quitar." },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "set_shipping",
|
||||
description: "Setea el método de envío (delivery o pickup).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["method"],
|
||||
properties: {
|
||||
method: { type: "string", enum: ["delivery", "pickup"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "set_address",
|
||||
description:
|
||||
"Registra la dirección de entrega (texto, como label) y matchea zona usando la ubicación compartida " +
|
||||
"por WhatsApp (working_memory.order.pending_location). Si no hay ubicación compartida, devuelve " +
|
||||
"need_location: tenés que pedirle al cliente que mande el pin por WhatsApp.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["text"],
|
||||
properties: {
|
||||
text: {
|
||||
type: "string",
|
||||
minLength: 3,
|
||||
description: "Dirección textual (calle, número, depto, referencias). Sirve como label para el repartidor.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "reuse_last_delivery",
|
||||
description:
|
||||
"Reusa los datos de envío de la última orden del cliente (working_memory.last_delivery): " +
|
||||
"dirección, zona y ubicación. Llamala cuando ofrezcas 'te lo mandamos al mismo lugar que la otra vez?' " +
|
||||
"y el cliente confirme. Si no hay last_delivery devuelve error y tenés que pedir la ubicación de cero.",
|
||||
parameters: { type: "object", additionalProperties: false, properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "set_delivery_window",
|
||||
description:
|
||||
"Registra el día/horario que pidió el cliente para entrega o retiro. " +
|
||||
"El día debe ser uno de lun/mar/mie/jue/vie/sab/dom. Hora opcional (HH:MM). " +
|
||||
"confirm_order va a validar después contra la zona o el horario de pickup.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["day"],
|
||||
properties: {
|
||||
day: { type: "string", enum: ["lun", "mar", "mie", "jue", "vie", "sab", "dom"] },
|
||||
time: { type: "string", pattern: "^\\d{2}:\\d{2}$", description: "HH:MM (24h). Opcional." },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "confirm_order",
|
||||
description: "Confirma el pedido. Requiere cart no vacío y shipping completo (pickup o delivery+address). Emite acción create_order.",
|
||||
parameters: { type: "object", additionalProperties: false, properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "pause",
|
||||
description: "Pausa la conversación cuando el cliente dice 'después te digo' o equivalente. Mantiene el cart 7 días.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["reason"],
|
||||
properties: {
|
||||
reason: { type: "string", enum: ["user_paused", "user_busy", "needs_to_check"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "escalate_to_human",
|
||||
description: "Escala a un humano (quejas, dudas de pago/factura, urgencias, no podemos resolver).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["reason"],
|
||||
properties: {
|
||||
reason: { type: "string", minLength: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "say",
|
||||
description: "Texto final que se envía al cliente. ÚLTIMO tool del turno SIEMPRE.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["text"],
|
||||
properties: {
|
||||
text: { type: "string", minLength: 1, maxLength: 600 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
106
src/modules/3-turn-engine/agent/tools/searchCatalog.js
Normal file
106
src/modules/3-turn-engine/agent/tools/searchCatalog.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* search_catalog tool — wrappea retrieveCandidates con fallback por categoría.
|
||||
*
|
||||
* Side effects: muta `ctx.last_shown_options` con el top de candidatos para
|
||||
* que select_candidate pueda resolver "el segundo" en turnos posteriores.
|
||||
*/
|
||||
|
||||
import { retrieveCandidates } from "../../catalogRetrieval.js";
|
||||
import { searchSnapshotItems } from "../../../shared/wooSnapshot.js";
|
||||
import { pool } from "../../../shared/db/pool.js";
|
||||
|
||||
const MIN_GOOD_SCORE = 0.4;
|
||||
|
||||
function summarizeCandidate(c, idx) {
|
||||
return {
|
||||
index: idx + 1,
|
||||
woo_id: c.woo_product_id,
|
||||
name: c.name,
|
||||
price: c.price ?? null,
|
||||
sell_unit: c.sell_unit || null,
|
||||
score: typeof c._score === "number" ? Number(c._score.toFixed(2)) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchCatalogTool(args, ctx) {
|
||||
const { query, hint_category = null, limit = 5 } = args;
|
||||
|
||||
// 1) Búsqueda directa con catalogRetrieval (pg_trgm + alias + snapshot)
|
||||
const result = await retrieveCandidates({
|
||||
tenantId: ctx.tenantId,
|
||||
query,
|
||||
limit: Math.max(limit, 5),
|
||||
});
|
||||
let candidates = result?.candidates || [];
|
||||
|
||||
// 2) Fallback por categoría si la búsqueda directa rinde poco
|
||||
let usedFallback = null;
|
||||
if (
|
||||
(candidates.length === 0 ||
|
||||
(candidates[0]?._score || 0) < MIN_GOOD_SCORE) &&
|
||||
hint_category
|
||||
) {
|
||||
const byCategory = await searchByCategory({
|
||||
tenantId: ctx.tenantId,
|
||||
category: hint_category,
|
||||
limit,
|
||||
});
|
||||
if (byCategory.length > 0) {
|
||||
usedFallback = "category";
|
||||
candidates = byCategory;
|
||||
}
|
||||
}
|
||||
|
||||
// Recortar al limit final
|
||||
const top = candidates.slice(0, limit).map(summarizeCandidate);
|
||||
|
||||
// Guardar last_shown_options si hay >1 (para select_candidate posterior)
|
||||
if (top.length > 1) {
|
||||
ctx.last_shown_options = top.map((t) => ({
|
||||
index: t.index,
|
||||
woo_id: t.woo_id,
|
||||
name: t.name,
|
||||
price: t.price,
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
query,
|
||||
candidates: top,
|
||||
used_fallback: usedFallback,
|
||||
count: top.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function searchByCategory({ tenantId, category, limit }) {
|
||||
// Buscar productos cuya categories JSONB contenga el name/slug indicado.
|
||||
// Sin nuevas tablas — explota lo que ya está en woo_products_snapshot.categories.
|
||||
const sql = `
|
||||
SELECT woo_product_id, name, sku, slug, price, stock_status, stock_qty,
|
||||
categories, sell_unit, payload
|
||||
FROM woo_products_snapshot
|
||||
WHERE tenant_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements_text(categories) cat
|
||||
WHERE LOWER(cat) LIKE '%' || LOWER($2) || '%'
|
||||
)
|
||||
AND COALESCE(stock_status, 'instock') != 'outofstock'
|
||||
ORDER BY name ASC
|
||||
LIMIT $3
|
||||
`;
|
||||
try {
|
||||
const { rows } = await pool.query(sql, [tenantId, category, limit]);
|
||||
return rows.map((r) => ({
|
||||
woo_product_id: r.woo_product_id,
|
||||
name: r.name,
|
||||
price: r.price,
|
||||
sell_unit: r.sell_unit,
|
||||
_score: 0.5, // score sintético para que pase MIN_GOOD_SCORE en el caller
|
||||
}));
|
||||
} catch (err) {
|
||||
// Fallback: snapshot search por texto plano
|
||||
const r = await searchSnapshotItems({ tenantId, q: category, limit });
|
||||
return r?.items || [];
|
||||
}
|
||||
}
|
||||
72
src/modules/3-turn-engine/agent/tools/selectCandidate.js
Normal file
72
src/modules/3-turn-engine/agent/tools/selectCandidate.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* select_candidate — resuelve un pending NEEDS_TYPE eligiendo woo_id.
|
||||
*
|
||||
* Si el pending tenía `requested_qty` ya, lo promueve directo a READY → cart.
|
||||
* Sino lo deja en NEEDS_QUANTITY esperando set_quantity.
|
||||
*/
|
||||
|
||||
import {
|
||||
updatePendingItem,
|
||||
moveReadyToCart,
|
||||
PendingStatus,
|
||||
} from "../../orderModel.js";
|
||||
import { getSnapshotItemsByIds } from "../../../shared/wooSnapshot.js";
|
||||
|
||||
export async function selectCandidateTool(args, ctx) {
|
||||
const { pending_id, woo_id } = args;
|
||||
const pending = (ctx.order.pending || []).find((p) => p.id === pending_id);
|
||||
if (!pending) {
|
||||
return { ok: false, error: "pending_not_found" };
|
||||
}
|
||||
|
||||
// Buscar el producto seleccionado en el snapshot para nombre/unit/precio
|
||||
const lookup = await getSnapshotItemsByIds({
|
||||
tenantId: ctx.tenantId,
|
||||
wooProductIds: [woo_id],
|
||||
});
|
||||
const found = (lookup?.items || [])[0];
|
||||
if (!found) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "woo_id_unknown",
|
||||
hint: "El woo_id no existe en el catálogo. Probá con search_catalog.",
|
||||
};
|
||||
}
|
||||
|
||||
const sellsByWeight =
|
||||
!found.sell_unit || !["unit", "unidad"].includes(found.sell_unit);
|
||||
const displayUnit = found.sell_unit === "unit" ? "unit" : sellsByWeight ? "kg" : "unit";
|
||||
|
||||
const hasRequestedQty =
|
||||
pending.requested_qty != null && Number.isFinite(pending.requested_qty) && pending.requested_qty > 0;
|
||||
const finalQty = hasRequestedQty ? pending.requested_qty : null;
|
||||
const finalUnit = pending.requested_unit || displayUnit;
|
||||
const needsQty = sellsByWeight && !hasRequestedQty;
|
||||
|
||||
const updated = updatePendingItem(ctx.order, pending_id, {
|
||||
selected_woo_id: woo_id,
|
||||
selected_name: found.name,
|
||||
selected_price: found.price ?? null,
|
||||
selected_unit: displayUnit,
|
||||
candidates: [],
|
||||
qty: needsQty ? null : finalQty,
|
||||
unit: finalUnit,
|
||||
status: needsQty ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY,
|
||||
});
|
||||
ctx.order = moveReadyToCart(updated);
|
||||
|
||||
if (!needsQty) {
|
||||
ctx.pending_actions.push({
|
||||
type: "add_to_cart",
|
||||
payload: { woo_id, qty: finalQty, unit: finalUnit },
|
||||
});
|
||||
ctx.last_shown_options = [];
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
selected: { woo_id, name: found.name, unit: displayUnit, sells_by_weight: sellsByWeight },
|
||||
needs_quantity: needsQty,
|
||||
cart_size: (ctx.order.cart || []).length,
|
||||
};
|
||||
}
|
||||
80
src/modules/3-turn-engine/agent/tools/setAddress.js
Normal file
80
src/modules/3-turn-engine/agent/tools/setAddress.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* set_address — fija dirección (texto) y matchea zona usando la ubicación
|
||||
* compartida por WhatsApp (pending_location). Sin location, no se valida
|
||||
* zona y se pide al cliente que mande el pin.
|
||||
*/
|
||||
|
||||
import { findZoneForPoint } from "../../lib/geo.js";
|
||||
|
||||
export async function setAddressTool(args, ctx) {
|
||||
const { text } = args || {};
|
||||
const address = String(text || "").trim();
|
||||
if (address.length < 3) return { ok: false, error: "address_too_short" };
|
||||
|
||||
const allZones = ctx.storeConfig?.delivery_zones?.zones || [];
|
||||
const enabled = allZones.filter((z) => z?.enabled !== false);
|
||||
|
||||
// Sin zonas configuradas, aceptamos la dirección sin validar zona.
|
||||
if (!enabled.length) {
|
||||
ctx.order = {
|
||||
...ctx.order,
|
||||
shipping_address: address,
|
||||
is_delivery: ctx.order.is_delivery == null ? true : ctx.order.is_delivery,
|
||||
matched_zone: null,
|
||||
};
|
||||
return { ok: true, address, in_zone: true, reason: "no_zones_configured" };
|
||||
}
|
||||
|
||||
const loc = ctx.order?.pending_location;
|
||||
if (!loc || typeof loc.lat !== "number" || typeof loc.lng !== "number") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "need_location",
|
||||
hint:
|
||||
"Pedile al cliente que te mande su ubicación por WhatsApp (pin/location share) " +
|
||||
"para validar zona y costo. Sin ubicación no podemos confirmar envío.",
|
||||
available_zones: enabled.map((z) => ({
|
||||
name: z.name,
|
||||
delivery_cost: z.delivery_cost ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const matched = findZoneForPoint(loc.lng, loc.lat, enabled);
|
||||
if (!matched) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "out_of_zones",
|
||||
hint:
|
||||
"La ubicación que mandó está fuera de las zonas que cubre la carnicería. " +
|
||||
"Ofrecé pickup o pedile otra ubicación dentro de las zonas habilitadas.",
|
||||
available_zones: enabled.map((z) => ({
|
||||
name: z.name,
|
||||
delivery_cost: z.delivery_cost ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const zoneSummary = {
|
||||
id: matched.id,
|
||||
name: matched.name,
|
||||
delivery_cost: matched.delivery_cost ?? null,
|
||||
delivery_days: Array.isArray(matched.delivery_days) ? matched.delivery_days : [],
|
||||
delivery_hours: matched.delivery_hours || null,
|
||||
min_order_amount: matched.min_order_amount ?? 0,
|
||||
};
|
||||
|
||||
ctx.order = {
|
||||
...ctx.order,
|
||||
shipping_address: address,
|
||||
is_delivery: ctx.order.is_delivery == null ? true : ctx.order.is_delivery,
|
||||
matched_zone: zoneSummary,
|
||||
};
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
address,
|
||||
in_zone: true,
|
||||
matched_zone: zoneSummary,
|
||||
};
|
||||
}
|
||||
26
src/modules/3-turn-engine/agent/tools/setDeliveryWindow.js
Normal file
26
src/modules/3-turn-engine/agent/tools/setDeliveryWindow.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* set_delivery_window — registra el día/horario que pidió el cliente.
|
||||
*
|
||||
* El LLM lo llama cuando el cliente confirma "el martes a las 11" o similar.
|
||||
* confirm_order valida después contra zone.delivery_days/delivery_hours
|
||||
* (delivery) o schedule.pickup (pickup).
|
||||
*/
|
||||
|
||||
const DAY_KEYS = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
|
||||
|
||||
export async function setDeliveryWindowTool(args, ctx) {
|
||||
const { day, time } = args || {};
|
||||
if (!DAY_KEYS.includes(day)) {
|
||||
return { ok: false, error: "invalid_day", allowed: DAY_KEYS };
|
||||
}
|
||||
if (time != null && !/^\d{2}:\d{2}$/.test(String(time))) {
|
||||
return { ok: false, error: "invalid_time", hint: "Formato HH:MM (24h)." };
|
||||
}
|
||||
|
||||
ctx.order = {
|
||||
...ctx.order,
|
||||
delivery_window: { day, time: time || null },
|
||||
};
|
||||
|
||||
return { ok: true, day, time: time || null };
|
||||
}
|
||||
36
src/modules/3-turn-engine/agent/tools/setQuantity.js
Normal file
36
src/modules/3-turn-engine/agent/tools/setQuantity.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* set_quantity — completa la cantidad de un pending NEEDS_QUANTITY y lo
|
||||
* promueve a READY → cart.
|
||||
*/
|
||||
|
||||
import { updatePendingItem, moveReadyToCart, PendingStatus } from "../../orderModel.js";
|
||||
|
||||
export async function setQuantityTool(args, ctx) {
|
||||
const { pending_id, qty, unit } = args;
|
||||
const pending = (ctx.order.pending || []).find((p) => p.id === pending_id);
|
||||
if (!pending) {
|
||||
return { ok: false, error: "pending_not_found", hint: "Verificá el pending_id contra working_memory.order.pending." };
|
||||
}
|
||||
if (!pending.selected_woo_id) {
|
||||
return { ok: false, error: "pending_not_resolved", hint: "Llamá select_candidate primero para resolver el producto." };
|
||||
}
|
||||
|
||||
const updated = updatePendingItem(ctx.order, pending_id, {
|
||||
qty,
|
||||
unit,
|
||||
status: PendingStatus.READY,
|
||||
});
|
||||
ctx.order = moveReadyToCart(updated);
|
||||
|
||||
ctx.pending_actions.push({ type: "add_to_cart", payload: { woo_id: pending.selected_woo_id, qty, unit } });
|
||||
ctx.last_shown_options = [];
|
||||
if (ctx.order.failed_searches) {
|
||||
ctx.order = { ...ctx.order, failed_searches: { count: 0 } };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
promoted: { name: pending.selected_name, qty, unit },
|
||||
cart_size: (ctx.order.cart || []).length,
|
||||
};
|
||||
}
|
||||
50
src/modules/3-turn-engine/agent/tools/setShipping.js
Normal file
50
src/modules/3-turn-engine/agent/tools/setShipping.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* set_shipping — fija el método de envío (delivery o pickup).
|
||||
*
|
||||
* Cuando method=delivery y hay zonas configuradas, devuelve hints para que
|
||||
* el LLM le pida al cliente que comparta ubicación si no la tenemos.
|
||||
*/
|
||||
|
||||
export async function setShippingTool(args, ctx) {
|
||||
const { method } = args || {};
|
||||
if (method !== "delivery" && method !== "pickup") {
|
||||
return { ok: false, error: "invalid_method" };
|
||||
}
|
||||
|
||||
ctx.order = { ...ctx.order, is_delivery: method === "delivery" };
|
||||
|
||||
if (method === "pickup") {
|
||||
return {
|
||||
ok: true,
|
||||
method,
|
||||
requires_address: false,
|
||||
requires_location: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Delivery
|
||||
const zones = ctx.storeConfig?.delivery_zones?.zones || [];
|
||||
const enabledZones = zones.filter((z) => z?.enabled !== false);
|
||||
const hasZones = enabledZones.length > 0;
|
||||
const hasLocation = !!ctx.order?.pending_location?.lat;
|
||||
const hasMatchedZone = !!ctx.order?.matched_zone;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
method,
|
||||
requires_address: !ctx.order.shipping_address,
|
||||
requires_location: hasZones && !hasLocation && !hasMatchedZone,
|
||||
available_zones: hasZones
|
||||
? enabledZones.map((z) => ({
|
||||
name: z.name,
|
||||
delivery_cost: z.delivery_cost ?? null,
|
||||
delivery_days: z.delivery_days || [],
|
||||
delivery_hours: z.delivery_hours || null,
|
||||
}))
|
||||
: [],
|
||||
hint:
|
||||
hasZones && !hasLocation
|
||||
? "Pedile al cliente que te mande su ubicación por WhatsApp (pin/location share) para validar zona y costo de envío."
|
||||
: null,
|
||||
};
|
||||
}
|
||||
131
src/modules/3-turn-engine/agent/workingMemory.js
Normal file
131
src/modules/3-turn-engine/agent/workingMemory.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* WorkingMemory — payload contextual que recibe el agente cada turno.
|
||||
*
|
||||
* Se serializa como JSON y se inyecta en el primer USER message. NO va en
|
||||
* system (eso permite cachear el system prompt entre turnos).
|
||||
*
|
||||
* Reglas de poda:
|
||||
* - history: últimos 8 mensajes, content truncado a 200 chars.
|
||||
* - customer_profile: top 5 frequent_items.
|
||||
* - last_shown_options: top 8.
|
||||
* - cart/pending: enteros, sin truncar.
|
||||
*/
|
||||
|
||||
import { parseQuantity } from "./quantityParser.js";
|
||||
import { buildStoreContextVars, buildZonesForLLM } from "../storeContext.js";
|
||||
|
||||
const HISTORY_MAX = 8;
|
||||
const HISTORY_CHAR_CAP = 200;
|
||||
const LAST_SHOWN_MAX = 8;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function truncate(s, n) {
|
||||
if (s == null) return "";
|
||||
const str = String(s);
|
||||
if (str.length <= n) return str;
|
||||
return str.slice(0, n - 1) + "…";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {string} params.text - Mensaje crudo del usuario en este turno.
|
||||
* @param {Object} params.order - order del context (cart, pending, etc.)
|
||||
* @param {string} params.prev_state - estado FSM previo
|
||||
* @param {Array} params.conversation_history
|
||||
* @param {Object} params.storeConfig - resultado de getStoreConfig
|
||||
* @param {Object} params.customerProfile - perfil del cliente (puede ser null)
|
||||
* @param {Array} params.lastShownOptions - opciones del turno previo
|
||||
*/
|
||||
export function buildWorkingMemory({
|
||||
text,
|
||||
order = {},
|
||||
prev_state = "IDLE",
|
||||
conversation_history = [],
|
||||
storeConfig = {},
|
||||
customerProfile = null,
|
||||
lastShownOptions = [],
|
||||
lastDelivery = null,
|
||||
}) {
|
||||
const storeVars = buildStoreContextVars(storeConfig);
|
||||
|
||||
const cart = (order.cart || []).map((it) => ({
|
||||
woo_id: it.woo_id,
|
||||
name: it.name,
|
||||
qty: it.qty,
|
||||
unit: it.unit,
|
||||
price: it.price ?? null,
|
||||
}));
|
||||
|
||||
const pending = (order.pending || []).map((p) => ({
|
||||
id: p.id,
|
||||
query: p.query,
|
||||
status: p.status,
|
||||
selected_woo_id: p.selected_woo_id ?? null,
|
||||
selected_name: p.selected_name ?? null,
|
||||
selected_unit: p.selected_unit ?? null,
|
||||
requested_qty: p.requested_qty ?? null,
|
||||
requested_unit: p.requested_unit ?? null,
|
||||
candidates: (p.candidates || []).slice(0, 8).map((c, i) => ({
|
||||
index: i + 1,
|
||||
woo_id: c.woo_id ?? c.woo_product_id,
|
||||
name: c.name,
|
||||
price: c.price ?? null,
|
||||
display_unit: c.display_unit ?? null,
|
||||
})),
|
||||
}));
|
||||
|
||||
const history = (conversation_history || []).slice(-HISTORY_MAX).map((m) => ({
|
||||
role: m.role === "user" ? "user" : "assistant",
|
||||
text: truncate(m.content || m.text || "", HISTORY_CHAR_CAP),
|
||||
}));
|
||||
|
||||
const last_shown_options = (lastShownOptions || []).slice(0, LAST_SHOWN_MAX).map((o, i) => ({
|
||||
index: o.index ?? i + 1,
|
||||
woo_id: o.woo_id,
|
||||
name: o.name,
|
||||
price: o.price ?? null,
|
||||
}));
|
||||
|
||||
const preparsed = parseQuantity(text || "");
|
||||
|
||||
const zones = buildZonesForLLM(storeConfig.delivery_zones);
|
||||
const pickupSchedule = storeConfig.schedule?.pickup || null;
|
||||
|
||||
return {
|
||||
now: nowIso(),
|
||||
store: {
|
||||
name: storeVars.store_name || "la carnicería",
|
||||
hours_today: storeVars.store_hours_today || "consultar",
|
||||
delivery: {
|
||||
zones, // [{id,name,delivery_cost,delivery_days,delivery_hours,min_order_amount}]
|
||||
zones_summary: storeVars.delivery_zones_summary || "",
|
||||
requires_location_share: zones.length > 0, // hint para el LLM
|
||||
},
|
||||
pickup: {
|
||||
schedule: pickupSchedule, // { lun:{start,end,enabled}, mar:..., ... } o null
|
||||
hours_today: storeVars.pickup_hours_today || "",
|
||||
},
|
||||
},
|
||||
fsm_state: prev_state || "IDLE",
|
||||
order: {
|
||||
cart,
|
||||
pending,
|
||||
is_delivery: order.is_delivery ?? null,
|
||||
shipping_address: order.shipping_address ?? null,
|
||||
woo_order_id: order.woo_order_id ?? null,
|
||||
pending_location: order.pending_location || null, // { lat, lng, label?, received_at }
|
||||
matched_zone: order.matched_zone || null, // resumen de zona matched (set por set_address)
|
||||
delivery_window: order.delivery_window || null, // { day, time } elegido por cliente
|
||||
},
|
||||
last_shown_options,
|
||||
paused_until: order.paused_until ?? null,
|
||||
customer_profile: customerProfile,
|
||||
last_delivery: lastDelivery, // null si es 1er pedido
|
||||
history,
|
||||
user_message: text || "",
|
||||
preparsed,
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
searchProductAliases,
|
||||
getProductEmbedding,
|
||||
upsertProductEmbedding,
|
||||
getAllAliasProductMappings,
|
||||
searchAliasProductMappings,
|
||||
} from "../2-identity/db/repo.js";
|
||||
|
||||
function getOpenAiKey() {
|
||||
@@ -138,59 +138,61 @@ export async function retrieveCandidates({
|
||||
|
||||
const audit = { query: q, sources: {}, boosts: {}, embeddings: {} };
|
||||
|
||||
// 1) Buscar aliases que matcheen la query
|
||||
const aliases = await searchProductAliases({ tenant_id: tenantId, q, limit: 20 });
|
||||
// 1) Buscar aliases con fuzzy matching (pg_trgm).
|
||||
// Captura plurales, diminutivos y typos sin reglas escritas.
|
||||
const [aliases, mappings] = await Promise.all([
|
||||
searchProductAliases({ tenant_id: tenantId, q, limit: 20 }),
|
||||
searchAliasProductMappings({ tenant_id: tenantId, q, limit: 50 }),
|
||||
]);
|
||||
|
||||
const aliasBoostByProduct = new Map();
|
||||
const aliasProductIds = new Set();
|
||||
|
||||
// También buscar en alias_product_mappings (multi-producto)
|
||||
const allMappings = await getAllAliasProductMappings({ tenant_id: tenantId });
|
||||
const normalizedQuery = normalizeText(q);
|
||||
const queryWords = new Set(normalizedQuery.split(" ").filter(Boolean));
|
||||
|
||||
// Buscar mappings cuyos aliases matcheen la query
|
||||
for (const mapping of allMappings) {
|
||||
const aliasNorm = normalizeText(mapping.alias);
|
||||
// Match exacto o parcial del alias
|
||||
if (aliasNorm === normalizedQuery || normalizedQuery.includes(aliasNorm) || aliasNorm.includes(normalizedQuery)) {
|
||||
const id = Number(mapping.woo_product_id);
|
||||
const score = Number(mapping.score || 1);
|
||||
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, score));
|
||||
aliasProductIds.add(id);
|
||||
} else {
|
||||
// Check word overlap
|
||||
const aliasWords = new Set(aliasNorm.split(" ").filter(Boolean));
|
||||
for (const word of queryWords) {
|
||||
if (aliasWords.has(word)) {
|
||||
const id = Number(mapping.woo_product_id);
|
||||
const score = Number(mapping.score || 1) * 0.7; // Partial match gets lower score
|
||||
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, score));
|
||||
aliasProductIds.add(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// alias_product_mappings: score * similarity (premia tanto reglas explícitas como fuzziness)
|
||||
for (const m of mappings) {
|
||||
const id = m.woo_product_id;
|
||||
const boost = m.score * m.similarity;
|
||||
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost));
|
||||
aliasProductIds.add(id);
|
||||
}
|
||||
|
||||
// También incluir aliases legacy (product_aliases.woo_product_id)
|
||||
|
||||
// product_aliases legacy (1 alias → 1 producto)
|
||||
for (const a of aliases) {
|
||||
if (a?.woo_product_id) {
|
||||
const id = Number(a.woo_product_id);
|
||||
const boost = Number(a.boost || 0);
|
||||
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost || 0));
|
||||
const boost = Number(a.boost || 0) * (a.similarity || 1);
|
||||
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost));
|
||||
aliasProductIds.add(id);
|
||||
}
|
||||
}
|
||||
audit.sources.aliases = aliases.length;
|
||||
audit.sources.alias_mappings = aliasProductIds.size;
|
||||
audit.sources.alias_mappings = mappings.length;
|
||||
|
||||
// 2) Buscar productos por nombre/slug (búsqueda literal)
|
||||
const { items: wooItems, source: wooSource } = await searchSnapshotItems({
|
||||
// 2) Buscar productos por nombre/slug (búsqueda literal con query original)
|
||||
let { items: wooItems, source: wooSource } = await searchSnapshotItems({
|
||||
tenantId,
|
||||
q,
|
||||
limit: lim,
|
||||
});
|
||||
audit.sources.snapshot = { source: wooSource, count: wooItems?.length || 0 };
|
||||
|
||||
// 2b) Si el query literal no rinde pero un alias matcheó (typo/plural/diminutivo),
|
||||
// re-buscar el snapshot con el normalized_alias del mejor match.
|
||||
// Esto cierra el loop: "vasio" → alias "vacio" → buscar "vacio" en productos.
|
||||
if ((!wooItems || wooItems.length === 0) && aliases.length > 0) {
|
||||
const refined = aliases[0]?.normalized_alias;
|
||||
if (refined && refined.toLowerCase() !== q.toLowerCase()) {
|
||||
const { items: aliasRefined, source: refSource } = await searchSnapshotItems({
|
||||
tenantId,
|
||||
q: refined,
|
||||
limit: lim,
|
||||
});
|
||||
if (aliasRefined?.length) {
|
||||
wooItems = aliasRefined;
|
||||
audit.sources.snapshot_refined = { query: refined, source: refSource, count: aliasRefined.length };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Traer productos que matchearon por alias pero no por búsqueda literal
|
||||
const foundIds = new Set(wooItems.map(w => Number(w.woo_product_id)));
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* FSM simplificada para el flujo conversacional.
|
||||
*
|
||||
* Estados lineales: IDLE → CART → SHIPPING → PAYMENT → WAITING_WEBHOOKS
|
||||
* Estados lineales: IDLE → CART → SHIPPING → IDLE (post-confirmación).
|
||||
* El bot toma pedidos y datos de entrega; el cobro se gestiona offline.
|
||||
* Regla universal: add_to_cart SIEMPRE vuelve a CART desde cualquier estado.
|
||||
*/
|
||||
|
||||
@@ -9,9 +10,8 @@ export const ConversationState = Object.freeze({
|
||||
IDLE: "IDLE",
|
||||
CART: "CART",
|
||||
SHIPPING: "SHIPPING",
|
||||
PAYMENT: "PAYMENT",
|
||||
WAITING_WEBHOOKS: "WAITING_WEBHOOKS",
|
||||
AWAITING_HUMAN: "AWAITING_HUMAN", // Esperando respuesta de un humano
|
||||
PAUSED: "PAUSED", // Cliente dijo "después te digo" - cart preservado, TTL 7d
|
||||
AWAITING_HUMAN: "AWAITING_HUMAN",
|
||||
});
|
||||
|
||||
export const ALL_STATES = Object.freeze(Object.values(ConversationState));
|
||||
@@ -19,23 +19,22 @@ export const ALL_STATES = Object.freeze(Object.values(ConversationState));
|
||||
// Intents válidos por estado
|
||||
export const INTENTS_BY_STATE = Object.freeze({
|
||||
[ConversationState.IDLE]: [
|
||||
"greeting", "add_to_cart", "browse", "price_query", "recommend", "other"
|
||||
"greeting", "add_to_cart", "browse", "price_query", "recommend", "other",
|
||||
],
|
||||
[ConversationState.CART]: [
|
||||
"add_to_cart", "remove_from_cart", "browse", "price_query",
|
||||
"recommend", "view_cart", "confirm_order", "other"
|
||||
"recommend", "view_cart", "confirm_order", "other",
|
||||
],
|
||||
[ConversationState.SHIPPING]: [
|
||||
"provide_address", "select_shipping", "add_to_cart", "view_cart", "other"
|
||||
"provide_address", "select_shipping", "add_to_cart", "view_cart", "other",
|
||||
],
|
||||
[ConversationState.PAYMENT]: [
|
||||
"select_payment", "add_to_cart", "view_cart", "other"
|
||||
],
|
||||
[ConversationState.WAITING_WEBHOOKS]: [
|
||||
"add_to_cart", "view_cart", "other"
|
||||
[ConversationState.PAUSED]: [
|
||||
// Cualquier intent del cliente lo reactiva; el agente decide el destino.
|
||||
"greeting", "add_to_cart", "browse", "view_cart", "confirm_order",
|
||||
"select_shipping", "provide_address", "remove_from_cart", "other",
|
||||
],
|
||||
[ConversationState.AWAITING_HUMAN]: [
|
||||
"other" // En este estado, el bot no procesa - espera respuesta humana
|
||||
"other",
|
||||
],
|
||||
});
|
||||
|
||||
@@ -44,36 +43,31 @@ export const INTENTS_BY_STATE = Object.freeze({
|
||||
*/
|
||||
export function shouldReturnToCart(state, nlu, text = "") {
|
||||
if (state === ConversationState.CART || state === ConversationState.IDLE) {
|
||||
return false; // Ya está en CART o IDLE (IDLE irá a CART naturalmente)
|
||||
return false;
|
||||
}
|
||||
|
||||
// En SHIPPING/PAYMENT, números solos son selecciones de opción, no productos
|
||||
const isCheckoutState = state === ConversationState.SHIPPING || state === ConversationState.PAYMENT;
|
||||
|
||||
// En SHIPPING, números solos son selecciones de opción, no productos
|
||||
const isCheckoutState = state === ConversationState.SHIPPING;
|
||||
const isJustNumber = /^\s*\d+([.,]\d+)?\s*$/.test(text || "");
|
||||
if (isCheckoutState && isJustNumber) {
|
||||
return false; // No redirigir, es una selección de opción
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
const intent = nlu?.intent;
|
||||
// Si el intent es add_to_cart, browse, price_query, o recommend → volver a CART
|
||||
// Pero solo si hay una query de producto real (no vacía)
|
||||
if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) {
|
||||
// Verificar que hay un producto real mencionado
|
||||
const hasRealProduct = nlu?.entities?.product_query &&
|
||||
const hasRealProduct = nlu?.entities?.product_query &&
|
||||
String(nlu.entities.product_query).trim().length > 2;
|
||||
const hasRealItems = Array.isArray(nlu?.entities?.items) &&
|
||||
const hasRealItems = Array.isArray(nlu?.entities?.items) &&
|
||||
nlu.entities.items.some(i => i?.product_query?.trim().length > 2);
|
||||
if (hasRealProduct || hasRealItems) {
|
||||
return true;
|
||||
}
|
||||
// Si no hay producto real, no redirigir (probablemente es una selección numérica mal interpretada)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Si hay menciones de producto en entities (con contenido real)
|
||||
|
||||
if (nlu?.entities?.product_query && String(nlu.entities.product_query).trim().length > 2) return true;
|
||||
if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.some(i => i?.product_query?.trim().length > 2)) return true;
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -100,43 +94,27 @@ export function hasShippingInfo(order) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasPaymentInfo(order) {
|
||||
return order?.payment_type === "cash" || order?.payment_type === "link";
|
||||
}
|
||||
|
||||
export function isPaid(order) {
|
||||
return order?.is_paid === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deriva el siguiente estado basado en el contexto y signals.
|
||||
*
|
||||
* signals: {
|
||||
*
|
||||
* signals: {
|
||||
* confirm_order: boolean, // Usuario quiere cerrar pedido
|
||||
* shipping_selected: boolean, // Usuario seleccionó delivery/pickup
|
||||
* payment_selected: boolean, // Usuario seleccionó método de pago
|
||||
* shipping_completed: boolean, // Shipping info quedó completa (gatilla create_order + IDLE)
|
||||
* return_to_cart: boolean, // Forzar volver a CART
|
||||
* }
|
||||
*/
|
||||
export function deriveNextState(prevState, order = {}, signals = {}) {
|
||||
// Regla 0: Si se fuerza volver a CART
|
||||
if (signals.return_to_cart) {
|
||||
return ConversationState.CART;
|
||||
}
|
||||
|
||||
// Regla 1: Si está pagado, completado (volver a IDLE para nueva conversación)
|
||||
if (isPaid(order)) {
|
||||
// Si la orden ya fue creada en Woo, volvemos a IDLE para nueva conversación.
|
||||
if (order?.woo_order_id) {
|
||||
return ConversationState.IDLE;
|
||||
}
|
||||
|
||||
// Regla 2: Si tiene woo_order_id y espera pago
|
||||
if (order?.woo_order_id && !isPaid(order)) {
|
||||
return ConversationState.WAITING_WEBHOOKS;
|
||||
}
|
||||
|
||||
// Desde IDLE
|
||||
if (prevState === ConversationState.IDLE) {
|
||||
// Si hay cart o pending items, ir a CART
|
||||
if (hasCartItems(order) || hasPendingItems(order)) {
|
||||
return ConversationState.CART;
|
||||
}
|
||||
@@ -145,11 +123,9 @@ export function deriveNextState(prevState, order = {}, signals = {}) {
|
||||
|
||||
// Desde CART
|
||||
if (prevState === ConversationState.CART) {
|
||||
// Si hay pending items sin resolver, quedarse en CART
|
||||
if (hasPendingItems(order)) {
|
||||
return ConversationState.CART;
|
||||
}
|
||||
// Si usuario confirma orden y hay items en cart, ir a SHIPPING
|
||||
if (signals.confirm_order && hasCartItems(order)) {
|
||||
return ConversationState.SHIPPING;
|
||||
}
|
||||
@@ -158,69 +134,49 @@ export function deriveNextState(prevState, order = {}, signals = {}) {
|
||||
|
||||
// Desde SHIPPING
|
||||
if (prevState === ConversationState.SHIPPING) {
|
||||
// Si ya tiene shipping info completa, ir a PAYMENT
|
||||
if (hasShippingInfo(order)) {
|
||||
return ConversationState.PAYMENT;
|
||||
// Una vez completado el shipping, la orden se crea y vuelve a IDLE.
|
||||
if (signals.shipping_completed || hasShippingInfo(order)) {
|
||||
return ConversationState.IDLE;
|
||||
}
|
||||
return ConversationState.SHIPPING;
|
||||
}
|
||||
|
||||
// Desde PAYMENT
|
||||
if (prevState === ConversationState.PAYMENT) {
|
||||
// Si ya tiene payment info, ir a WAITING_WEBHOOKS
|
||||
if (signals.payment_selected || hasPaymentInfo(order)) {
|
||||
return ConversationState.WAITING_WEBHOOKS;
|
||||
}
|
||||
return ConversationState.PAYMENT;
|
||||
}
|
||||
|
||||
// Desde WAITING_WEBHOOKS
|
||||
if (prevState === ConversationState.WAITING_WEBHOOKS) {
|
||||
if (isPaid(order)) {
|
||||
return ConversationState.IDLE;
|
||||
}
|
||||
return ConversationState.WAITING_WEBHOOKS;
|
||||
}
|
||||
|
||||
// Default
|
||||
return prevState || ConversationState.IDLE;
|
||||
}
|
||||
|
||||
// Transiciones permitidas (para validación)
|
||||
const ALLOWED = Object.freeze({
|
||||
[ConversationState.IDLE]: [
|
||||
ConversationState.IDLE,
|
||||
ConversationState.CART,
|
||||
ConversationState.AWAITING_HUMAN, // Puede ir a esperar humano
|
||||
ConversationState.PAUSED,
|
||||
ConversationState.AWAITING_HUMAN,
|
||||
],
|
||||
[ConversationState.CART]: [
|
||||
ConversationState.CART,
|
||||
ConversationState.SHIPPING,
|
||||
ConversationState.IDLE, // Si vacía el carrito
|
||||
ConversationState.AWAITING_HUMAN, // Producto no encontrado
|
||||
ConversationState.IDLE,
|
||||
ConversationState.PAUSED,
|
||||
ConversationState.AWAITING_HUMAN,
|
||||
],
|
||||
[ConversationState.SHIPPING]: [
|
||||
ConversationState.SHIPPING,
|
||||
ConversationState.PAYMENT,
|
||||
ConversationState.CART, // Volver a agregar productos
|
||||
ConversationState.IDLE,
|
||||
ConversationState.CART,
|
||||
ConversationState.PAUSED,
|
||||
ConversationState.AWAITING_HUMAN,
|
||||
],
|
||||
[ConversationState.PAYMENT]: [
|
||||
ConversationState.PAYMENT,
|
||||
ConversationState.WAITING_WEBHOOKS,
|
||||
ConversationState.CART, // Volver a agregar productos
|
||||
ConversationState.AWAITING_HUMAN,
|
||||
],
|
||||
[ConversationState.WAITING_WEBHOOKS]: [
|
||||
ConversationState.WAITING_WEBHOOKS,
|
||||
ConversationState.IDLE, // Pago completado
|
||||
ConversationState.CART, // Agregar más productos
|
||||
[ConversationState.PAUSED]: [
|
||||
// Cualquier mensaje saca de paused
|
||||
ConversationState.PAUSED,
|
||||
ConversationState.CART,
|
||||
ConversationState.SHIPPING,
|
||||
ConversationState.IDLE,
|
||||
ConversationState.AWAITING_HUMAN,
|
||||
],
|
||||
[ConversationState.AWAITING_HUMAN]: [
|
||||
ConversationState.AWAITING_HUMAN, // Sigue esperando
|
||||
ConversationState.CART, // Humano respondió, volver a procesar
|
||||
ConversationState.IDLE, // Humano canceló
|
||||
ConversationState.AWAITING_HUMAN,
|
||||
ConversationState.CART,
|
||||
ConversationState.IDLE,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -237,7 +193,5 @@ export function safeNextState(prevState, order, signals) {
|
||||
const desired = deriveNextState(prevState, order, signals);
|
||||
const v = validateTransition(prevState, desired);
|
||||
if (v.ok) return { next_state: desired, validation: v };
|
||||
// Si la transición no es válida, forzar a un estado seguro
|
||||
// En el nuevo modelo, siempre podemos ir a CART
|
||||
return { next_state: ConversationState.CART, validation: { ...v, forced_to_cart: true } };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Tests para fsm.js
|
||||
* Tests para fsm.js (sin payment / waiting — el bot no maneja pagos).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
@@ -11,555 +11,211 @@ import {
|
||||
hasPendingItems,
|
||||
hasReadyPendingItems,
|
||||
hasShippingInfo,
|
||||
hasPaymentInfo,
|
||||
isPaid,
|
||||
deriveNextState,
|
||||
validateTransition,
|
||||
safeNextState,
|
||||
} from './fsm.js';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Constants
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ConversationState', () => {
|
||||
it('tiene todos los estados definidos', () => {
|
||||
it('tiene los estados del flujo (incluye PAUSED)', () => {
|
||||
expect(ConversationState.IDLE).toBe('IDLE');
|
||||
expect(ConversationState.CART).toBe('CART');
|
||||
expect(ConversationState.SHIPPING).toBe('SHIPPING');
|
||||
expect(ConversationState.PAYMENT).toBe('PAYMENT');
|
||||
expect(ConversationState.WAITING_WEBHOOKS).toBe('WAITING_WEBHOOKS');
|
||||
expect(ConversationState.PAUSED).toBe('PAUSED');
|
||||
expect(ConversationState.AWAITING_HUMAN).toBe('AWAITING_HUMAN');
|
||||
expect(ConversationState.PAYMENT).toBeUndefined();
|
||||
expect(ConversationState.WAITING_WEBHOOKS).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ALL_STATES contiene todos', () => {
|
||||
expect(ALL_STATES).toContain('IDLE');
|
||||
expect(ALL_STATES).toContain('CART');
|
||||
expect(ALL_STATES).toContain('SHIPPING');
|
||||
expect(ALL_STATES).toContain('PAYMENT');
|
||||
expect(ALL_STATES).toContain('WAITING_WEBHOOKS');
|
||||
expect(ALL_STATES).toContain('AWAITING_HUMAN');
|
||||
expect(ALL_STATES).toHaveLength(6);
|
||||
it('ALL_STATES contiene 5 estados', () => {
|
||||
expect(ALL_STATES).toEqual(expect.arrayContaining(['IDLE', 'CART', 'SHIPPING', 'PAUSED', 'AWAITING_HUMAN']));
|
||||
expect(ALL_STATES).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('INTENTS_BY_STATE define intents para cada estado', () => {
|
||||
it('INTENTS_BY_STATE define intents por estado', () => {
|
||||
expect(INTENTS_BY_STATE[ConversationState.IDLE]).toContain('greeting');
|
||||
expect(INTENTS_BY_STATE[ConversationState.CART]).toContain('add_to_cart');
|
||||
expect(INTENTS_BY_STATE[ConversationState.SHIPPING]).toContain('provide_address');
|
||||
expect(INTENTS_BY_STATE[ConversationState.PAYMENT]).toContain('select_payment');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// hasCartItems
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('hasCartItems', () => {
|
||||
it('retorna true si cart tiene items', () => {
|
||||
const order = { cart: [{ woo_id: 1, qty: 1 }] };
|
||||
expect(hasCartItems(order)).toBe(true);
|
||||
expect(hasCartItems({ cart: [{ woo_id: 1, qty: 1 }] })).toBe(true);
|
||||
});
|
||||
|
||||
it('retorna false si cart está vacío', () => {
|
||||
const order = { cart: [] };
|
||||
expect(hasCartItems(order)).toBe(false);
|
||||
expect(hasCartItems({ cart: [] })).toBe(false);
|
||||
});
|
||||
|
||||
it('retorna false si cart es undefined', () => {
|
||||
const order = {};
|
||||
expect(hasCartItems(order)).toBe(false);
|
||||
expect(hasCartItems({})).toBe(false);
|
||||
});
|
||||
|
||||
it('retorna false si order es null', () => {
|
||||
it('retorna false si order es null/undefined', () => {
|
||||
expect(hasCartItems(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('retorna false si order es undefined', () => {
|
||||
expect(hasCartItems(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// hasPendingItems
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('hasPendingItems', () => {
|
||||
it('retorna true si hay NEEDS_TYPE', () => {
|
||||
const order = { pending: [{ status: 'NEEDS_TYPE' }] };
|
||||
expect(hasPendingItems(order)).toBe(true);
|
||||
expect(hasPendingItems({ pending: [{ status: 'NEEDS_TYPE' }] })).toBe(true);
|
||||
});
|
||||
|
||||
it('retorna true si hay NEEDS_QUANTITY', () => {
|
||||
const order = { pending: [{ status: 'NEEDS_QUANTITY' }] };
|
||||
expect(hasPendingItems(order)).toBe(true);
|
||||
expect(hasPendingItems({ pending: [{ status: 'NEEDS_QUANTITY' }] })).toBe(true);
|
||||
});
|
||||
|
||||
it('retorna false si solo hay READY', () => {
|
||||
const order = { pending: [{ status: 'READY' }] };
|
||||
expect(hasPendingItems(order)).toBe(false);
|
||||
expect(hasPendingItems({ pending: [{ status: 'READY' }] })).toBe(false);
|
||||
});
|
||||
|
||||
it('retorna false si pending está vacío', () => {
|
||||
const order = { pending: [] };
|
||||
expect(hasPendingItems(order)).toBe(false);
|
||||
});
|
||||
|
||||
it('retorna false si order es null', () => {
|
||||
expect(hasPendingItems(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('detecta entre múltiples items', () => {
|
||||
const order = {
|
||||
pending: [
|
||||
{ status: 'READY' },
|
||||
{ status: 'NEEDS_TYPE' },
|
||||
]
|
||||
};
|
||||
expect(hasPendingItems(order)).toBe(true);
|
||||
expect(hasPendingItems({ pending: [{ status: 'READY' }, { status: 'NEEDS_TYPE' }] })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// hasReadyPendingItems
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('hasReadyPendingItems', () => {
|
||||
it('retorna true si hay READY', () => {
|
||||
const order = { pending: [{ status: 'READY' }] };
|
||||
expect(hasReadyPendingItems(order)).toBe(true);
|
||||
expect(hasReadyPendingItems({ pending: [{ status: 'READY' }] })).toBe(true);
|
||||
});
|
||||
|
||||
it('retorna false si no hay READY', () => {
|
||||
const order = { pending: [{ status: 'NEEDS_TYPE' }] };
|
||||
expect(hasReadyPendingItems(order)).toBe(false);
|
||||
});
|
||||
|
||||
it('retorna false si pending vacío', () => {
|
||||
const order = { pending: [] };
|
||||
expect(hasReadyPendingItems(order)).toBe(false);
|
||||
});
|
||||
|
||||
it('retorna false si order es null', () => {
|
||||
expect(hasReadyPendingItems(null)).toBe(false);
|
||||
expect(hasReadyPendingItems({ pending: [{ status: 'NEEDS_TYPE' }] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// hasShippingInfo
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('hasShippingInfo', () => {
|
||||
it('retorna true para pickup (no necesita dirección)', () => {
|
||||
const order = { is_delivery: false };
|
||||
expect(hasShippingInfo(order)).toBe(true);
|
||||
expect(hasShippingInfo({ is_delivery: false })).toBe(true);
|
||||
});
|
||||
|
||||
it('retorna true para delivery con dirección', () => {
|
||||
const order = { is_delivery: true, shipping_address: 'Calle Falsa 123' };
|
||||
expect(hasShippingInfo(order)).toBe(true);
|
||||
expect(hasShippingInfo({ is_delivery: true, shipping_address: 'Calle Falsa 123' })).toBe(true);
|
||||
});
|
||||
|
||||
it('retorna false para delivery sin dirección', () => {
|
||||
const order = { is_delivery: true, shipping_address: null };
|
||||
expect(hasShippingInfo(order)).toBe(false);
|
||||
expect(hasShippingInfo({ is_delivery: true, shipping_address: null })).toBe(false);
|
||||
});
|
||||
|
||||
it('retorna false si is_delivery es null', () => {
|
||||
const order = { is_delivery: null };
|
||||
expect(hasShippingInfo(order)).toBe(false);
|
||||
});
|
||||
|
||||
it('retorna false para order vacío', () => {
|
||||
expect(hasShippingInfo({})).toBe(false);
|
||||
expect(hasShippingInfo({ is_delivery: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// hasPaymentInfo
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('hasPaymentInfo', () => {
|
||||
it('retorna true para cash', () => {
|
||||
const order = { payment_type: 'cash' };
|
||||
expect(hasPaymentInfo(order)).toBe(true);
|
||||
});
|
||||
|
||||
it('retorna true para link', () => {
|
||||
const order = { payment_type: 'link' };
|
||||
expect(hasPaymentInfo(order)).toBe(true);
|
||||
});
|
||||
|
||||
it('retorna false para null', () => {
|
||||
const order = { payment_type: null };
|
||||
expect(hasPaymentInfo(order)).toBe(false);
|
||||
});
|
||||
|
||||
it('retorna false para undefined', () => {
|
||||
const order = {};
|
||||
expect(hasPaymentInfo(order)).toBe(false);
|
||||
});
|
||||
|
||||
it('retorna false para otros valores', () => {
|
||||
const order = { payment_type: 'bitcoin' };
|
||||
expect(hasPaymentInfo(order)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// isPaid
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('isPaid', () => {
|
||||
it('retorna true si is_paid es true', () => {
|
||||
const order = { is_paid: true };
|
||||
expect(isPaid(order)).toBe(true);
|
||||
});
|
||||
|
||||
it('retorna false si is_paid es false', () => {
|
||||
const order = { is_paid: false };
|
||||
expect(isPaid(order)).toBe(false);
|
||||
});
|
||||
|
||||
it('retorna false si is_paid es undefined', () => {
|
||||
const order = {};
|
||||
expect(isPaid(order)).toBe(false);
|
||||
});
|
||||
|
||||
it('retorna false si order es null', () => {
|
||||
expect(isPaid(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// shouldReturnToCart
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('shouldReturnToCart', () => {
|
||||
describe('no redirige si ya está en CART o IDLE', () => {
|
||||
it('retorna false en CART', () => {
|
||||
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
|
||||
expect(shouldReturnToCart(ConversationState.CART, nlu)).toBe(false);
|
||||
});
|
||||
|
||||
it('retorna false en IDLE', () => {
|
||||
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
|
||||
expect(shouldReturnToCart(ConversationState.IDLE, nlu)).toBe(false);
|
||||
});
|
||||
it('no redirige si ya está en CART o IDLE', () => {
|
||||
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
|
||||
expect(shouldReturnToCart(ConversationState.CART, nlu)).toBe(false);
|
||||
expect(shouldReturnToCart(ConversationState.IDLE, nlu)).toBe(false);
|
||||
});
|
||||
|
||||
describe('redirige desde otros estados', () => {
|
||||
it('redirige add_to_cart desde SHIPPING', () => {
|
||||
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
|
||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
|
||||
});
|
||||
|
||||
it('redirige add_to_cart desde PAYMENT', () => {
|
||||
const nlu = { intent: 'add_to_cart', entities: { product_query: 'vacío' } };
|
||||
expect(shouldReturnToCart(ConversationState.PAYMENT, nlu)).toBe(true);
|
||||
});
|
||||
|
||||
it('redirige browse desde SHIPPING', () => {
|
||||
const nlu = { intent: 'browse', entities: { product_query: 'carnes' } };
|
||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
|
||||
});
|
||||
it('redirige add_to_cart desde SHIPPING con producto real', () => {
|
||||
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
|
||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
|
||||
});
|
||||
|
||||
describe('no redirige números solos en checkout', () => {
|
||||
it('no redirige "1" en SHIPPING', () => {
|
||||
const nlu = { intent: 'add_to_cart', entities: {} };
|
||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1')).toBe(false);
|
||||
});
|
||||
|
||||
it('no redirige "2" en PAYMENT', () => {
|
||||
const nlu = { intent: 'other', entities: {} };
|
||||
expect(shouldReturnToCart(ConversationState.PAYMENT, nlu, '2')).toBe(false);
|
||||
});
|
||||
|
||||
it('no redirige "1.5" en SHIPPING', () => {
|
||||
const nlu = { intent: 'add_to_cart', entities: {} };
|
||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1.5')).toBe(false);
|
||||
});
|
||||
it('redirige browse desde SHIPPING', () => {
|
||||
const nlu = { intent: 'browse', entities: { product_query: 'carnes' } };
|
||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
|
||||
});
|
||||
|
||||
describe('requiere producto real', () => {
|
||||
it('no redirige sin product_query', () => {
|
||||
const nlu = { intent: 'add_to_cart', entities: {} };
|
||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, 'algo')).toBe(false);
|
||||
});
|
||||
it('no redirige números solos en SHIPPING (selección de opción)', () => {
|
||||
const nlu = { intent: 'add_to_cart', entities: {} };
|
||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1')).toBe(false);
|
||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1.5')).toBe(false);
|
||||
});
|
||||
|
||||
it('no redirige con product_query muy corto', () => {
|
||||
const nlu = { intent: 'add_to_cart', entities: { product_query: 'ab' } };
|
||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(false);
|
||||
});
|
||||
it('no redirige sin producto real', () => {
|
||||
const nlu = { intent: 'add_to_cart', entities: {} };
|
||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, 'algo')).toBe(false);
|
||||
expect(shouldReturnToCart(ConversationState.SHIPPING, { intent: 'add_to_cart', entities: { product_query: 'ab' } })).toBe(false);
|
||||
});
|
||||
|
||||
it('redirige con items array', () => {
|
||||
const nlu = {
|
||||
intent: 'add_to_cart',
|
||||
entities: { items: [{ product_query: 'provoleta' }] }
|
||||
};
|
||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
|
||||
});
|
||||
it('redirige con items array que tenga producto real', () => {
|
||||
const nlu = { intent: 'add_to_cart', entities: { items: [{ product_query: 'provoleta' }] } };
|
||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// deriveNextState
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deriveNextState', () => {
|
||||
describe('return_to_cart signal', () => {
|
||||
it('fuerza CART si return_to_cart', () => {
|
||||
const result = deriveNextState(
|
||||
ConversationState.PAYMENT,
|
||||
{},
|
||||
{ return_to_cart: true }
|
||||
);
|
||||
expect(result).toBe(ConversationState.CART);
|
||||
});
|
||||
it('return_to_cart fuerza CART', () => {
|
||||
expect(deriveNextState(ConversationState.SHIPPING, {}, { return_to_cart: true })).toBe(ConversationState.CART);
|
||||
});
|
||||
|
||||
describe('pagado', () => {
|
||||
it('va a IDLE si está pagado', () => {
|
||||
const order = { is_paid: true };
|
||||
const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {});
|
||||
expect(result).toBe(ConversationState.IDLE);
|
||||
});
|
||||
it('IDLE va a CART si hay cart o pending', () => {
|
||||
expect(deriveNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {})).toBe(ConversationState.CART);
|
||||
expect(deriveNextState(ConversationState.IDLE, { cart: [], pending: [{ status: 'NEEDS_TYPE' }] }, {})).toBe(ConversationState.CART);
|
||||
});
|
||||
|
||||
describe('esperando pago', () => {
|
||||
it('va a WAITING_WEBHOOKS si tiene woo_order_id', () => {
|
||||
const order = { woo_order_id: 123, is_paid: false };
|
||||
const result = deriveNextState(ConversationState.PAYMENT, order, {});
|
||||
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
|
||||
});
|
||||
it('IDLE queda en IDLE si vacío', () => {
|
||||
expect(deriveNextState(ConversationState.IDLE, { cart: [], pending: [] }, {})).toBe(ConversationState.IDLE);
|
||||
});
|
||||
|
||||
describe('IDLE -> CART', () => {
|
||||
it('va a CART si hay cart items', () => {
|
||||
const order = { cart: [{ woo_id: 1 }], pending: [] };
|
||||
const result = deriveNextState(ConversationState.IDLE, order, {});
|
||||
expect(result).toBe(ConversationState.CART);
|
||||
});
|
||||
|
||||
it('va a CART si hay pending items', () => {
|
||||
const order = { cart: [], pending: [{ status: 'NEEDS_TYPE' }] };
|
||||
const result = deriveNextState(ConversationState.IDLE, order, {});
|
||||
expect(result).toBe(ConversationState.CART);
|
||||
});
|
||||
|
||||
it('queda en IDLE si vacío', () => {
|
||||
const order = { cart: [], pending: [] };
|
||||
const result = deriveNextState(ConversationState.IDLE, order, {});
|
||||
expect(result).toBe(ConversationState.IDLE);
|
||||
});
|
||||
it('CART queda en CART con pending', () => {
|
||||
const order = { cart: [{ woo_id: 1 }], pending: [{ status: 'NEEDS_TYPE' }] };
|
||||
expect(deriveNextState(ConversationState.CART, order, { confirm_order: true })).toBe(ConversationState.CART);
|
||||
});
|
||||
|
||||
describe('CART -> SHIPPING', () => {
|
||||
it('queda en CART si hay pending items', () => {
|
||||
const order = { cart: [{ woo_id: 1 }], pending: [{ status: 'NEEDS_TYPE' }] };
|
||||
const result = deriveNextState(ConversationState.CART, order, { confirm_order: true });
|
||||
expect(result).toBe(ConversationState.CART);
|
||||
});
|
||||
|
||||
it('va a SHIPPING con confirm_order y cart items', () => {
|
||||
const order = { cart: [{ woo_id: 1 }], pending: [] };
|
||||
const result = deriveNextState(ConversationState.CART, order, { confirm_order: true });
|
||||
expect(result).toBe(ConversationState.SHIPPING);
|
||||
});
|
||||
|
||||
it('queda en CART sin confirm_order', () => {
|
||||
const order = { cart: [{ woo_id: 1 }], pending: [] };
|
||||
const result = deriveNextState(ConversationState.CART, order, {});
|
||||
expect(result).toBe(ConversationState.CART);
|
||||
});
|
||||
it('CART → SHIPPING con confirm + items', () => {
|
||||
const order = { cart: [{ woo_id: 1 }], pending: [] };
|
||||
expect(deriveNextState(ConversationState.CART, order, { confirm_order: true })).toBe(ConversationState.SHIPPING);
|
||||
});
|
||||
|
||||
describe('SHIPPING -> PAYMENT', () => {
|
||||
it('va a PAYMENT con shipping info (pickup)', () => {
|
||||
const order = { is_delivery: false };
|
||||
const result = deriveNextState(ConversationState.SHIPPING, order, {});
|
||||
expect(result).toBe(ConversationState.PAYMENT);
|
||||
});
|
||||
|
||||
it('va a PAYMENT con shipping info (delivery + address)', () => {
|
||||
const order = { is_delivery: true, shipping_address: 'Calle 123' };
|
||||
const result = deriveNextState(ConversationState.SHIPPING, order, {});
|
||||
expect(result).toBe(ConversationState.PAYMENT);
|
||||
});
|
||||
|
||||
it('queda en SHIPPING sin info completa', () => {
|
||||
const order = { is_delivery: true, shipping_address: null };
|
||||
const result = deriveNextState(ConversationState.SHIPPING, order, {});
|
||||
expect(result).toBe(ConversationState.SHIPPING);
|
||||
});
|
||||
it('SHIPPING → IDLE cuando shipping queda completo (orden creada offline)', () => {
|
||||
expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: false }, {})).toBe(ConversationState.IDLE);
|
||||
expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: true, shipping_address: 'Calle 1' }, {})).toBe(ConversationState.IDLE);
|
||||
});
|
||||
|
||||
describe('PAYMENT -> WAITING_WEBHOOKS', () => {
|
||||
it('va a WAITING con payment_selected', () => {
|
||||
const order = {};
|
||||
const result = deriveNextState(
|
||||
ConversationState.PAYMENT,
|
||||
order,
|
||||
{ payment_selected: true }
|
||||
);
|
||||
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
|
||||
});
|
||||
|
||||
it('va a WAITING si ya tiene payment_type', () => {
|
||||
const order = { payment_type: 'cash' };
|
||||
const result = deriveNextState(ConversationState.PAYMENT, order, {});
|
||||
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
|
||||
});
|
||||
it('SHIPPING queda en SHIPPING sin info completa', () => {
|
||||
expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: true }, {})).toBe(ConversationState.SHIPPING);
|
||||
});
|
||||
|
||||
describe('WAITING_WEBHOOKS', () => {
|
||||
it('va a IDLE si está pagado', () => {
|
||||
const order = { is_paid: true };
|
||||
const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {});
|
||||
expect(result).toBe(ConversationState.IDLE);
|
||||
});
|
||||
|
||||
it('queda en WAITING si no está pagado', () => {
|
||||
const order = { is_paid: false };
|
||||
const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {});
|
||||
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
|
||||
});
|
||||
it('woo_order_id existente vuelve a IDLE (orden ya creada)', () => {
|
||||
expect(deriveNextState(ConversationState.SHIPPING, { woo_order_id: 999 }, {})).toBe(ConversationState.IDLE);
|
||||
});
|
||||
|
||||
describe('default', () => {
|
||||
it('retorna IDLE si no hay estado previo', () => {
|
||||
const result = deriveNextState(null, {}, {});
|
||||
expect(result).toBe(ConversationState.IDLE);
|
||||
});
|
||||
it('default sin estado previo retorna IDLE', () => {
|
||||
expect(deriveNextState(null, {}, {})).toBe(ConversationState.IDLE);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// validateTransition
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('validateTransition', () => {
|
||||
describe('transiciones válidas', () => {
|
||||
it('IDLE -> IDLE es válido', () => {
|
||||
const result = validateTransition(ConversationState.IDLE, ConversationState.IDLE);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('IDLE -> CART es válido', () => {
|
||||
const result = validateTransition(ConversationState.IDLE, ConversationState.CART);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('CART -> SHIPPING es válido', () => {
|
||||
const result = validateTransition(ConversationState.CART, ConversationState.SHIPPING);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('SHIPPING -> PAYMENT es válido', () => {
|
||||
const result = validateTransition(ConversationState.SHIPPING, ConversationState.PAYMENT);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('PAYMENT -> WAITING_WEBHOOKS es válido', () => {
|
||||
const result = validateTransition(ConversationState.PAYMENT, ConversationState.WAITING_WEBHOOKS);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('SHIPPING -> CART (volver) es válido', () => {
|
||||
const result = validateTransition(ConversationState.SHIPPING, ConversationState.CART);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
it('IDLE → CART es válida', () => {
|
||||
expect(validateTransition(ConversationState.IDLE, ConversationState.CART).ok).toBe(true);
|
||||
});
|
||||
|
||||
describe('transiciones inválidas', () => {
|
||||
it('IDLE -> PAYMENT es inválido', () => {
|
||||
const result = validateTransition(ConversationState.IDLE, ConversationState.PAYMENT);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe('invalid_transition');
|
||||
});
|
||||
|
||||
it('CART -> WAITING_WEBHOOKS es inválido', () => {
|
||||
const result = validateTransition(ConversationState.CART, ConversationState.WAITING_WEBHOOKS);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
it('CART → SHIPPING es válida', () => {
|
||||
expect(validateTransition(ConversationState.CART, ConversationState.SHIPPING).ok).toBe(true);
|
||||
});
|
||||
|
||||
describe('estados desconocidos', () => {
|
||||
it('estado previo desconocido', () => {
|
||||
const result = validateTransition('UNKNOWN', ConversationState.CART);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe('unknown_prev_state');
|
||||
});
|
||||
|
||||
it('estado siguiente desconocido', () => {
|
||||
const result = validateTransition(ConversationState.IDLE, 'UNKNOWN');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe('unknown_next_state');
|
||||
});
|
||||
it('SHIPPING → IDLE es válida (cierre de orden)', () => {
|
||||
expect(validateTransition(ConversationState.SHIPPING, ConversationState.IDLE).ok).toBe(true);
|
||||
});
|
||||
|
||||
describe('maneja null/undefined', () => {
|
||||
it('prevState null se trata como IDLE', () => {
|
||||
const result = validateTransition(null, ConversationState.CART);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('nextState null se trata como IDLE', () => {
|
||||
const result = validateTransition(ConversationState.IDLE, null);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
it('SHIPPING → CART (volver a agregar) es válida', () => {
|
||||
expect(validateTransition(ConversationState.SHIPPING, ConversationState.CART).ok).toBe(true);
|
||||
});
|
||||
it('IDLE → SHIPPING es inválida (debe pasar por CART)', () => {
|
||||
const r = validateTransition(ConversationState.IDLE, ConversationState.SHIPPING);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('invalid_transition');
|
||||
});
|
||||
it('estado previo desconocido', () => {
|
||||
const r = validateTransition('UNKNOWN', ConversationState.CART);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('unknown_prev_state');
|
||||
});
|
||||
it('null se trata como IDLE', () => {
|
||||
expect(validateTransition(null, ConversationState.CART).ok).toBe(true);
|
||||
expect(validateTransition(ConversationState.IDLE, null).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// safeNextState
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('safeNextState', () => {
|
||||
it('retorna estado derivado si transición válida', () => {
|
||||
it('retorna estado derivado si la transición es válida', () => {
|
||||
const order = { cart: [{ woo_id: 1 }], pending: [] };
|
||||
const result = safeNextState(ConversationState.CART, order, { confirm_order: true });
|
||||
|
||||
expect(result.next_state).toBe(ConversationState.SHIPPING);
|
||||
expect(result.validation.ok).toBe(true);
|
||||
const r = safeNextState(ConversationState.CART, order, { confirm_order: true });
|
||||
expect(r.next_state).toBe(ConversationState.SHIPPING);
|
||||
expect(r.validation.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('fuerza CART si transición inválida', () => {
|
||||
// Forzar una situación donde deriveNextState retornaría un estado inválido
|
||||
// Esto es difícil de provocar porque deriveNextState ya es bastante seguro
|
||||
// Pero podemos verificar que la lógica de fallback existe
|
||||
const order = {};
|
||||
const result = safeNextState(ConversationState.IDLE, order, {});
|
||||
|
||||
// Debería quedarse en IDLE (transición válida)
|
||||
expect(result.next_state).toBe(ConversationState.IDLE);
|
||||
expect(result.validation.ok).toBe(true);
|
||||
});
|
||||
it('flow IDLE → CART → SHIPPING → IDLE', () => {
|
||||
let r = safeNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {});
|
||||
expect(r.next_state).toBe(ConversationState.CART);
|
||||
|
||||
it('incluye validation en resultado', () => {
|
||||
const order = { is_delivery: false };
|
||||
const result = safeNextState(ConversationState.SHIPPING, order, {});
|
||||
|
||||
expect(result).toHaveProperty('next_state');
|
||||
expect(result).toHaveProperty('validation');
|
||||
expect(result.validation).toHaveProperty('ok');
|
||||
});
|
||||
r = safeNextState(ConversationState.CART, { cart: [{ woo_id: 1 }], pending: [] }, { confirm_order: true });
|
||||
expect(r.next_state).toBe(ConversationState.SHIPPING);
|
||||
|
||||
it('maneja transition IDLE -> CART -> SHIPPING flow', () => {
|
||||
// Paso 1: IDLE con cart items -> CART
|
||||
let result = safeNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {});
|
||||
expect(result.next_state).toBe(ConversationState.CART);
|
||||
|
||||
// Paso 2: CART con confirm -> SHIPPING
|
||||
result = safeNextState(ConversationState.CART, { cart: [{ woo_id: 1 }], pending: [] }, { confirm_order: true });
|
||||
expect(result.next_state).toBe(ConversationState.SHIPPING);
|
||||
|
||||
// Paso 3: SHIPPING con pickup -> PAYMENT
|
||||
result = safeNextState(ConversationState.SHIPPING, { is_delivery: false }, {});
|
||||
expect(result.next_state).toBe(ConversationState.PAYMENT);
|
||||
|
||||
// Paso 4: PAYMENT con payment_selected -> WAITING
|
||||
result = safeNextState(ConversationState.PAYMENT, {}, { payment_selected: true });
|
||||
expect(result.next_state).toBe(ConversationState.WAITING_WEBHOOKS);
|
||||
r = safeNextState(ConversationState.SHIPPING, { is_delivery: false }, { shipping_completed: true });
|
||||
expect(r.next_state).toBe(ConversationState.IDLE);
|
||||
});
|
||||
});
|
||||
|
||||
51
src/modules/3-turn-engine/lib/geo.js
Normal file
51
src/modules/3-turn-engine/lib/geo.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Geometría liviana para validar punto-en-polígono sin deps.
|
||||
*
|
||||
* Polígonos vienen en GeoJSON: { type: "Polygon", coordinates: [[[lng,lat],...]] }.
|
||||
* GeoJSON usa orden [lng, lat] (X, Y). Mantenemos esa convención puertas adentro.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ray casting (algoritmo clásico). Devuelve true si (lng, lat) cae dentro del
|
||||
* anillo exterior del polígono. No considera agujeros (holes) — para CABA y un
|
||||
* editor que dibuja polígonos simples, alcanza.
|
||||
*
|
||||
* @param {number} lng
|
||||
* @param {number} lat
|
||||
* @param {{type:string, coordinates:Array<Array<[number,number]>>}} polygon
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function pointInPolygon(lng, lat, polygon) {
|
||||
if (!polygon || polygon.type !== "Polygon" || !Array.isArray(polygon.coordinates)) return false;
|
||||
const ring = polygon.coordinates[0];
|
||||
if (!Array.isArray(ring) || ring.length < 3) return false;
|
||||
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const xi = ring[i][0], yi = ring[i][1];
|
||||
const xj = ring[j][0], yj = ring[j][1];
|
||||
const intersect =
|
||||
(yi > lat) !== (yj > lat) &&
|
||||
lng < ((xj - xi) * (lat - yi)) / (yj - yi + 0) + xi;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca la primera zona habilitada cuyo polígono contiene al punto.
|
||||
*
|
||||
* @param {number} lng
|
||||
* @param {number} lat
|
||||
* @param {Array<Object>} zones - { id, name, polygon, enabled, ... }
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export function findZoneForPoint(lng, lat, zones) {
|
||||
if (!Array.isArray(zones)) return null;
|
||||
for (const z of zones) {
|
||||
if (z?.enabled === false) continue;
|
||||
if (!z?.polygon) continue;
|
||||
if (pointInPolygon(lng, lat, z.polygon)) return z;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
103
src/modules/3-turn-engine/lib/geo.test.js
Normal file
103
src/modules/3-turn-engine/lib/geo.test.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { pointInPolygon, findZoneForPoint } from "./geo.js";
|
||||
|
||||
const square = {
|
||||
type: "Polygon",
|
||||
coordinates: [[
|
||||
[0, 0],
|
||||
[10, 0],
|
||||
[10, 10],
|
||||
[0, 10],
|
||||
[0, 0],
|
||||
]],
|
||||
};
|
||||
|
||||
const concave = {
|
||||
type: "Polygon",
|
||||
coordinates: [[
|
||||
[0, 0],
|
||||
[10, 0],
|
||||
[10, 10],
|
||||
[5, 5],
|
||||
[0, 10],
|
||||
[0, 0],
|
||||
]],
|
||||
};
|
||||
|
||||
describe("pointInPolygon", () => {
|
||||
it("returns true para un punto dentro de un cuadrado", () => {
|
||||
expect(pointInPolygon(5, 5, square)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false para un punto fuera del cuadrado", () => {
|
||||
expect(pointInPolygon(15, 5, square)).toBe(false);
|
||||
expect(pointInPolygon(-1, 5, square)).toBe(false);
|
||||
expect(pointInPolygon(5, 20, square)).toBe(false);
|
||||
});
|
||||
|
||||
it("maneja polígonos cóncavos (excluye el dent del centro)", () => {
|
||||
// (5, 8) cae dentro del notch — fuera del polígono cóncavo.
|
||||
expect(pointInPolygon(5, 8, concave)).toBe(false);
|
||||
// (2, 2) sigue dentro.
|
||||
expect(pointInPolygon(2, 2, concave)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false para input inválido", () => {
|
||||
expect(pointInPolygon(0, 0, null)).toBe(false);
|
||||
expect(pointInPolygon(0, 0, { type: "Point" })).toBe(false);
|
||||
expect(pointInPolygon(0, 0, { type: "Polygon", coordinates: [[[0, 0], [1, 1]]] })).toBe(false);
|
||||
});
|
||||
|
||||
it("trabaja con coordenadas reales de CABA (lng, lat)", () => {
|
||||
// Polígono cuadrado pequeño alrededor del Obelisco.
|
||||
const obeliscoBox = {
|
||||
type: "Polygon",
|
||||
coordinates: [[
|
||||
[-58.39, -34.61],
|
||||
[-58.37, -34.61],
|
||||
[-58.37, -34.60],
|
||||
[-58.39, -34.60],
|
||||
[-58.39, -34.61],
|
||||
]],
|
||||
};
|
||||
// Obelisco aprox: -58.3816, -34.6037
|
||||
expect(pointInPolygon(-58.3816, -34.6037, obeliscoBox)).toBe(true);
|
||||
// Mar del Plata
|
||||
expect(pointInPolygon(-57.55, -38.0, obeliscoBox)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findZoneForPoint", () => {
|
||||
const zones = [
|
||||
{ id: "centro", name: "Centro", polygon: square, enabled: true, delivery_cost: 1500 },
|
||||
{
|
||||
id: "norte",
|
||||
name: "Norte",
|
||||
enabled: true,
|
||||
polygon: {
|
||||
type: "Polygon",
|
||||
coordinates: [[[20, 20], [30, 20], [30, 30], [20, 30], [20, 20]]],
|
||||
},
|
||||
delivery_cost: 2000,
|
||||
},
|
||||
];
|
||||
|
||||
it("devuelve la zona que contiene al punto", () => {
|
||||
const z = findZoneForPoint(5, 5, zones);
|
||||
expect(z?.id).toBe("centro");
|
||||
});
|
||||
|
||||
it("devuelve null si ningún polígono contiene al punto", () => {
|
||||
expect(findZoneForPoint(15, 15, zones)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignora zonas con enabled=false", () => {
|
||||
const muted = zones.map((z) => ({ ...z, enabled: z.id === "centro" ? false : z.enabled }));
|
||||
expect(findZoneForPoint(5, 5, muted)).toBeNull();
|
||||
});
|
||||
|
||||
it("tolera input inválido", () => {
|
||||
expect(findZoneForPoint(0, 0, null)).toBeNull();
|
||||
expect(findZoneForPoint(0, 0, [])).toBeNull();
|
||||
expect(findZoneForPoint(0, 0, [{ id: "x" }])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
Sos {{bot_name}}, asistente de {{store_name}}. Procesá consultas sobre el catálogo.
|
||||
|
||||
TIPOS DE CONSULTAS:
|
||||
|
||||
1. price_query - Consulta de precios
|
||||
Señales: "cuánto sale", "precio de", "cuánto cuesta", "a cuánto está"
|
||||
Extraer: product_query (el producto que pregunta)
|
||||
|
||||
2. browse - Consulta de disponibilidad
|
||||
Señales: "tenés", "hay", "vendés", "tienen"
|
||||
Extraer: product_query
|
||||
|
||||
3. recommend - Pedido de recomendación/planificación
|
||||
Señales: "qué me recomendás", "qué llevo", "para X personas", "para un asado"
|
||||
Extraer:
|
||||
- people_count: número de personas si lo menciona
|
||||
- event_type: tipo de evento (asado, cumple, reunión)
|
||||
- product_query: producto específico si lo menciona
|
||||
|
||||
EJEMPLOS:
|
||||
|
||||
Input: "cuánto sale el vacío?"
|
||||
Output:
|
||||
{
|
||||
"intent": "price_query",
|
||||
"product_query": "vacío",
|
||||
"people_count": null,
|
||||
"event_type": null
|
||||
}
|
||||
|
||||
Input: "tenés chimichurri?"
|
||||
Output:
|
||||
{
|
||||
"intent": "browse",
|
||||
"product_query": "chimichurri",
|
||||
"people_count": null,
|
||||
"event_type": null
|
||||
}
|
||||
|
||||
Input: "qué me recomendás para 8 personas?"
|
||||
Output:
|
||||
{
|
||||
"intent": "recommend",
|
||||
"product_query": null,
|
||||
"people_count": 8,
|
||||
"event_type": "asado"
|
||||
}
|
||||
|
||||
Input: "para un asado de 6, qué llevo?"
|
||||
Output:
|
||||
{
|
||||
"intent": "recommend",
|
||||
"product_query": null,
|
||||
"people_count": 6,
|
||||
"event_type": "asado"
|
||||
}
|
||||
|
||||
Input: "qué vino va bien con carne?"
|
||||
Output:
|
||||
{
|
||||
"intent": "recommend",
|
||||
"product_query": "vino",
|
||||
"people_count": null,
|
||||
"event_type": null
|
||||
}
|
||||
|
||||
FORMATO JSON:
|
||||
{
|
||||
"intent": "price_query|browse|recommend",
|
||||
"product_query": "texto" | null,
|
||||
"people_count": number | null,
|
||||
"event_type": "asado|cumple|reunion" | null
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
Sos {{bot_name}}, el asistente virtual de {{store_name}}.
|
||||
|
||||
PERSONALIDAD:
|
||||
- Carnicero profesional argentino con años de experiencia
|
||||
- Usás voseo natural (vos, querés, tenés, decime)
|
||||
- Amable y cálido pero eficiente, no muy formal
|
||||
- Conocedor de cortes de carne y tradiciones del asado argentino
|
||||
- Podés hacer algún comentario simpático sobre el asado si viene al caso
|
||||
- Respuestas concisas, no te extendés demasiado
|
||||
|
||||
CONTEXTO DEL NEGOCIO:
|
||||
- Horario: {{store_hours}}
|
||||
- Dirección: {{store_address}}
|
||||
|
||||
INSTRUCCIONES:
|
||||
El cliente te saluda. Respondé de forma cálida y preguntá en qué podés ayudar.
|
||||
Si hay alguna promo del día o corte destacado, mencionalo brevemente.
|
||||
|
||||
FORMATO DE RESPUESTA (JSON):
|
||||
{
|
||||
"intent": "greeting",
|
||||
"reply": "tu respuesta al cliente"
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
Sos un sistema NLU para una carnicería argentina. Extraé productos del mensaje del usuario.
|
||||
|
||||
REGLAS CRÍTICAS (seguir estrictamente):
|
||||
|
||||
0. EXTRAER TODOS LOS PRODUCTOS - NUNCA OMITIR NINGUNO
|
||||
Si el mensaje menciona 5 productos, el array items DEBE tener 5 elementos.
|
||||
NUNCA omitas productos, incluso si no estás seguro del nombre exacto.
|
||||
Extraé cada producto mencionado, separado por comas, "y", saltos de línea, etc.
|
||||
|
||||
1. SIEMPRE USAR ARRAY "items"
|
||||
Aunque sea UN SOLO producto, SIEMPRE devolver un array "items" con al menos un elemento.
|
||||
Cada item tiene: product_query, quantity, unit
|
||||
|
||||
2. COPIAR TEXTO EXACTO
|
||||
El campo "product_query" debe ser el texto EXACTO que usó el cliente.
|
||||
- Si dice "asado de tira" → product_query: "asado de tira"
|
||||
- Si dice "vacío" → product_query: "vacío"
|
||||
- Si dice "carre de cerdo" → product_query: "carre de cerdo"
|
||||
- Si dice "provoletas wapi" → product_query: "provoletas wapi"
|
||||
- NUNCA modifiques, combines ni inventes nombres
|
||||
|
||||
3. EXTRAER CANTIDADES (pueden estar antes o después del producto)
|
||||
- "2kg de X" → quantity: 2, unit: "kg"
|
||||
- "X 1kg" → quantity: 1, unit: "kg" (cantidad después del producto)
|
||||
- "3 provoletas" → quantity: 3, unit: "unidad"
|
||||
- "medio kilo" → quantity: 0.5, unit: "kg"
|
||||
- Sin cantidad → quantity: null
|
||||
|
||||
4. UNIDADES
|
||||
- kg: kilos, kilo, kilogramo
|
||||
- g: gramos, gr
|
||||
- unidad: unidades, u (para productos que no se pesan)
|
||||
|
||||
5. INTENTS
|
||||
- add_to_cart: agregar productos (quiero, dame, anotame, poneme, hola quiero)
|
||||
- remove_from_cart: quitar productos (sacame, quitame)
|
||||
- view_cart: ver carrito (qué tengo, qué anoté, mi pedido)
|
||||
- confirm_order: cerrar pedido (listo, eso es todo, cerrar)
|
||||
|
||||
EJEMPLOS:
|
||||
|
||||
Input: "hola, quiero 1kg de asado, vacio, carre de cerdo 1kg, chorizo mixto 1kg y 3 provoletas wapi"
|
||||
Output:
|
||||
{
|
||||
"intent": "add_to_cart",
|
||||
"confidence": 0.95,
|
||||
"items": [
|
||||
{"product_query": "asado", "quantity": 1, "unit": "kg"},
|
||||
{"product_query": "vacio", "quantity": null, "unit": null},
|
||||
{"product_query": "carre de cerdo", "quantity": 1, "unit": "kg"},
|
||||
{"product_query": "chorizo mixto", "quantity": 1, "unit": "kg"},
|
||||
{"product_query": "provoletas wapi", "quantity": 3, "unit": "unidad"}
|
||||
]
|
||||
}
|
||||
|
||||
Input: "Te pido:\n2kg de vacío\n3kg de asado de tira\n1kg de chorizos mixtos\n2 provoletas"
|
||||
Output:
|
||||
{
|
||||
"intent": "add_to_cart",
|
||||
"confidence": 0.95,
|
||||
"items": [
|
||||
{"product_query": "vacío", "quantity": 2, "unit": "kg"},
|
||||
{"product_query": "asado de tira", "quantity": 3, "unit": "kg"},
|
||||
{"product_query": "chorizos mixtos", "quantity": 1, "unit": "kg"},
|
||||
{"product_query": "provoletas", "quantity": 2, "unit": "unidad"}
|
||||
]
|
||||
}
|
||||
|
||||
Input: "dame 1kg de vacío"
|
||||
Output:
|
||||
{
|
||||
"intent": "add_to_cart",
|
||||
"confidence": 0.95,
|
||||
"items": [
|
||||
{"product_query": "vacío", "quantity": 1, "unit": "kg"}
|
||||
]
|
||||
}
|
||||
|
||||
Input: "quiero asado"
|
||||
Output:
|
||||
{
|
||||
"intent": "add_to_cart",
|
||||
"confidence": 0.9,
|
||||
"items": [
|
||||
{"product_query": "asado", "quantity": null, "unit": null}
|
||||
]
|
||||
}
|
||||
|
||||
Input: "sacame el chorizo"
|
||||
Output:
|
||||
{
|
||||
"intent": "remove_from_cart",
|
||||
"confidence": 0.9,
|
||||
"items": [
|
||||
{"product_query": "chorizo", "quantity": null, "unit": null}
|
||||
]
|
||||
}
|
||||
|
||||
Input: "qué tengo anotado?"
|
||||
Output:
|
||||
{
|
||||
"intent": "view_cart",
|
||||
"confidence": 0.95,
|
||||
"items": []
|
||||
}
|
||||
|
||||
Input: "listo, eso sería todo"
|
||||
Output:
|
||||
{
|
||||
"intent": "confirm_order",
|
||||
"confidence": 0.95,
|
||||
"items": []
|
||||
}
|
||||
|
||||
FORMATO JSON ESTRICTO:
|
||||
{
|
||||
"intent": "add_to_cart|remove_from_cart|view_cart|confirm_order",
|
||||
"confidence": 0.0-1.0,
|
||||
"items": [{product_query, quantity, unit}, ...]
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
Extraé información de pago del mensaje del usuario.
|
||||
|
||||
ENTIDADES A EXTRAER:
|
||||
|
||||
1. payment_method
|
||||
- "cash": pago en efectivo
|
||||
Señales: efectivo, cash, plata, en mano
|
||||
- "link": pago electrónico (tarjeta, transferencia, link de pago)
|
||||
Señales: tarjeta, link, transferencia, QR, mercadopago, MP
|
||||
- null: no se puede determinar
|
||||
|
||||
EJEMPLOS:
|
||||
|
||||
Input: "efectivo"
|
||||
Output:
|
||||
{
|
||||
"intent": "select_payment",
|
||||
"payment_method": "cash"
|
||||
}
|
||||
|
||||
Input: "con tarjeta"
|
||||
Output:
|
||||
{
|
||||
"intent": "select_payment",
|
||||
"payment_method": "link"
|
||||
}
|
||||
|
||||
Input: "link de pago"
|
||||
Output:
|
||||
{
|
||||
"intent": "select_payment",
|
||||
"payment_method": "link"
|
||||
}
|
||||
|
||||
Input: "pago cuando llega"
|
||||
Output:
|
||||
{
|
||||
"intent": "select_payment",
|
||||
"payment_method": "cash"
|
||||
}
|
||||
|
||||
Input: "transferencia"
|
||||
Output:
|
||||
{
|
||||
"intent": "select_payment",
|
||||
"payment_method": "link"
|
||||
}
|
||||
|
||||
Input: "1" (si el contexto indica que 1=efectivo)
|
||||
Output:
|
||||
{
|
||||
"intent": "select_payment",
|
||||
"payment_method": "cash"
|
||||
}
|
||||
|
||||
FORMATO JSON:
|
||||
{
|
||||
"intent": "select_payment",
|
||||
"payment_method": "cash" | "link" | null
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
Clasificá el dominio del mensaje del usuario. Respondé SOLO JSON válido.
|
||||
|
||||
{"domain":"greeting|orders|shipping|payment|browse|other"}
|
||||
|
||||
REGLAS DE CLASIFICACIÓN:
|
||||
|
||||
1. greeting - Saludos sin mención de productos
|
||||
- "hola", "buen día", "buenas tardes", "qué tal", "hey"
|
||||
- NO si menciona productos junto al saludo
|
||||
|
||||
2. orders - Todo relacionado con pedidos y productos
|
||||
- Agregar productos: "quiero", "dame", "anotame", "poneme", cantidad + producto
|
||||
- Quitar productos: "sacame", "quitame", "no quiero"
|
||||
- Ver carrito: "qué tengo", "qué anoté", "mi pedido"
|
||||
- Confirmar: "listo", "eso es todo", "cerrar pedido"
|
||||
|
||||
3. shipping - Envío y entrega
|
||||
- Método: "delivery", "envío", "retiro", "buscar", "sucursal"
|
||||
- Dirección: textos con calle, número, barrio
|
||||
|
||||
4. payment - Métodos de pago
|
||||
- "efectivo", "tarjeta", "transferencia", "link", "mercadopago"
|
||||
|
||||
5. browse - Consultas de catálogo
|
||||
- Precios: "cuánto sale", "precio de"
|
||||
- Disponibilidad: "tenés", "hay", "vendés"
|
||||
- Recomendaciones: "qué me recomendás", "para X personas"
|
||||
|
||||
6. other - Cualquier otra cosa
|
||||
|
||||
Estado actual: {{state}}
|
||||
|
||||
Mensaje a clasificar: [se provee en el input]
|
||||
@@ -1,64 +0,0 @@
|
||||
Extraé información de envío del mensaje del usuario.
|
||||
|
||||
ENTIDADES A EXTRAER:
|
||||
|
||||
1. shipping_method
|
||||
- "delivery": el cliente quiere que le lleven el pedido
|
||||
Señales: delivery, envío, enviar, que me lo traigan, llevar
|
||||
- "pickup": el cliente pasa a buscar
|
||||
Señales: retiro, retirar, buscar, paso, sucursal
|
||||
- null: no se puede determinar
|
||||
|
||||
2. address
|
||||
- Texto de la dirección de entrega
|
||||
- Solo extraer si hay datos concretos (calle, número, barrio, etc.)
|
||||
- null: si no hay dirección
|
||||
|
||||
EJEMPLOS:
|
||||
|
||||
Input: "delivery"
|
||||
Output:
|
||||
{
|
||||
"intent": "select_shipping",
|
||||
"shipping_method": "delivery",
|
||||
"address": null
|
||||
}
|
||||
|
||||
Input: "paso a buscar"
|
||||
Output:
|
||||
{
|
||||
"intent": "select_shipping",
|
||||
"shipping_method": "pickup",
|
||||
"address": null
|
||||
}
|
||||
|
||||
Input: "Av. Corrientes 1234, Almagro"
|
||||
Output:
|
||||
{
|
||||
"intent": "provide_address",
|
||||
"shipping_method": null,
|
||||
"address": "Av. Corrientes 1234, Almagro"
|
||||
}
|
||||
|
||||
Input: "delivery a Palermo, calle Honduras 5000"
|
||||
Output:
|
||||
{
|
||||
"intent": "select_shipping",
|
||||
"shipping_method": "delivery",
|
||||
"address": "Palermo, calle Honduras 5000"
|
||||
}
|
||||
|
||||
Input: "1" (si el contexto indica que 1=delivery)
|
||||
Output:
|
||||
{
|
||||
"intent": "select_shipping",
|
||||
"shipping_method": "delivery",
|
||||
"address": null
|
||||
}
|
||||
|
||||
FORMATO JSON:
|
||||
{
|
||||
"intent": "select_shipping|provide_address",
|
||||
"shipping_method": "delivery" | "pickup" | null,
|
||||
"address": "texto de dirección" | null
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* Human Fallback - Lógica para escalar conversaciones a humanos
|
||||
*
|
||||
* Se activa cuando:
|
||||
* - No se encuentra un producto en el catálogo
|
||||
* - El NLU tiene baja confianza
|
||||
* - Casos especiales que requieren atención humana
|
||||
*/
|
||||
|
||||
import { ConversationState } from "../fsm.js";
|
||||
import { createEmptyOrder } from "../orderModel.js";
|
||||
|
||||
/**
|
||||
* Crea una respuesta de takeover para cuando no se encuentra un producto
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.pendingQuery - La query/producto que no se encontró
|
||||
* @param {Object} params.order - Estado actual del pedido
|
||||
* @param {Object} params.context - Contexto adicional para el humano
|
||||
* @returns {Object} Resultado con plan y decision para el pipeline
|
||||
*/
|
||||
export function createHumanTakeoverResponse({ pendingQuery, order, context = {} }) {
|
||||
const currentOrder = order || createEmptyOrder();
|
||||
|
||||
// Mensaje amigable para el usuario
|
||||
const reply = `Dejame consultar con el equipo sobre "${pendingQuery}". Te respondo en un momento.`;
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply,
|
||||
next_state: ConversationState.AWAITING_HUMAN,
|
||||
intent: "human_takeover",
|
||||
missing_fields: ["human_response"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: {
|
||||
actions: [
|
||||
{
|
||||
type: "request_human_takeover",
|
||||
payload: {
|
||||
pending_query: pendingQuery,
|
||||
reason: "product_not_found",
|
||||
context_snapshot: {
|
||||
order: currentOrder,
|
||||
...context,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
order: currentOrder,
|
||||
audit: {
|
||||
human_takeover_requested: true,
|
||||
pending_query: pendingQuery,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si debería escalar a humano basado en los resultados del catálogo
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {Array} params.candidates - Candidatos encontrados en el catálogo
|
||||
* @param {string} params.query - Query original del usuario
|
||||
* @param {number} params.confidenceThreshold - Umbral de confianza mínimo
|
||||
* @returns {boolean} true si debería escalar a humano
|
||||
*/
|
||||
export function shouldEscalateToHuman({ candidates = [], query, confidenceThreshold = 0.3 }) {
|
||||
// Si no hay candidatos, escalar
|
||||
if (!candidates || candidates.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Si el mejor candidato tiene score muy bajo, escalar
|
||||
const bestScore = candidates[0]?._score || 0;
|
||||
if (bestScore < confidenceThreshold) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Si la query es muy diferente al nombre del mejor candidato (por nombre)
|
||||
// Esto es un heurístico simple para detectar confusiones
|
||||
const bestName = (candidates[0]?.name || "").toLowerCase();
|
||||
const queryLower = (query || "").toLowerCase();
|
||||
|
||||
// Si no hay overlap significativo de palabras, podría ser confusión
|
||||
const queryWords = queryLower.split(/\s+/).filter(w => w.length > 2);
|
||||
const nameWords = bestName.split(/\s+/).filter(w => w.length > 2);
|
||||
|
||||
if (queryWords.length > 0 && nameWords.length > 0) {
|
||||
const overlap = queryWords.filter(qw =>
|
||||
nameWords.some(nw => nw.includes(qw) || qw.includes(nw))
|
||||
);
|
||||
|
||||
// Si hay muy poco overlap y el score no es muy alto, escalar
|
||||
if (overlap.length === 0 && bestScore < 0.7) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera mensaje de respuesta cuando el humano responde al takeover
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.humanResponse - Respuesta del humano
|
||||
* @param {Object} params.order - Estado actual del pedido
|
||||
* @returns {Object} Resultado para continuar el flujo normal
|
||||
*/
|
||||
export function createHumanResponseResult({ humanResponse, order }) {
|
||||
const currentOrder = order || createEmptyOrder();
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: humanResponse,
|
||||
next_state: ConversationState.CART, // Volver al flujo normal
|
||||
intent: "human_response",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: {
|
||||
actions: [
|
||||
{
|
||||
type: "human_response_sent",
|
||||
payload: {},
|
||||
},
|
||||
],
|
||||
order: currentOrder,
|
||||
audit: {
|
||||
human_response_processed: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si el estado actual es AWAITING_HUMAN
|
||||
*/
|
||||
export function isAwaitingHuman(state) {
|
||||
return state === ConversationState.AWAITING_HUMAN || state === "AWAITING_HUMAN";
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera respuesta cuando el usuario envía mensaje mientras está en AWAITING_HUMAN
|
||||
*/
|
||||
export function createWaitingForHumanResponse({ order }) {
|
||||
const currentOrder = order || createEmptyOrder();
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: "Estoy esperando respuesta del equipo sobre tu consulta. Te aviso apenas tenga novedades.",
|
||||
next_state: ConversationState.AWAITING_HUMAN,
|
||||
intent: "other",
|
||||
missing_fields: ["human_response"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: {
|
||||
actions: [],
|
||||
order: currentOrder,
|
||||
audit: { still_waiting_human: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user