From a8cf63e2facd4d11ac4ff2320b9e6e1673e35601 Mon Sep 17 00:00:00 2001 From: Apple Date: Sat, 14 Feb 2026 21:41:30 -0300 Subject: [PATCH] Initial commit: PymesBot project specs and sample inventory --- 01_PYMESBOT_PROJECT_SPEC.md | 1262 ++++++++++++++++++++++++++++ 02_PYMESBOT_INSTALLER_SPEC.md | 1476 +++++++++++++++++++++++++++++++++ inventario_ejemplo.sql | 99 +++ 3 files changed, 2837 insertions(+) create mode 100644 01_PYMESBOT_PROJECT_SPEC.md create mode 100644 02_PYMESBOT_INSTALLER_SPEC.md create mode 100644 inventario_ejemplo.sql diff --git a/01_PYMESBOT_PROJECT_SPEC.md b/01_PYMESBOT_PROJECT_SPEC.md new file mode 100644 index 0000000..0fb35ac --- /dev/null +++ b/01_PYMESBOT_PROJECT_SPEC.md @@ -0,0 +1,1262 @@ +# PymesBot — Especificación Técnica Completa del Proyecto + +> **Documento de referencia para desarrollo con IA** +> Versión 1.0 · Febrero 2026 · RVConsultas +> Este documento contiene TODO lo necesario para construir PymesBot desde cero. +> No asumas nada que no esté escrito acá. Si algo no está en este documento, preguntá antes de inventar. + +--- + +## ÍNDICE + +1. [Qué es PymesBot](#1-qué-es-pymesbot) +2. [Problema que resuelve](#2-problema-que-resuelve) +3. [Cómo funciona — visión general](#3-cómo-funciona--visión-general) +4. [Stack tecnológico completo](#4-stack-tecnológico-completo) +5. [Arquitectura multi-tenant](#5-arquitectura-multi-tenant) +6. [Estructura de carpetas en el servidor](#6-estructura-de-carpetas-en-el-servidor) +7. [Base de datos — esquema completo](#7-base-de-datos--esquema-completo) +8. [Backend FastAPI — todos los endpoints](#8-backend-fastapi--todos-los-endpoints) +9. [El agente IA — PicoClaw + Claude](#9-el-agente-ia--picoclaw--claude) +10. [Dashboard web — especificación UI](#10-dashboard-web--especificación-ui) +11. [Docker Compose por cliente](#11-docker-compose-por-cliente) +12. [Templates de configuración](#12-templates-de-configuración) +13. [Flujo completo de una venta](#13-flujo-completo-de-una-venta) +14. [Modelo de negocio y precios](#14-modelo-de-negocio-y-precios) +15. [Roadmap de implementación](#15-roadmap-de-implementación) +16. [Reglas de desarrollo obligatorias](#16-reglas-de-desarrollo-obligatorias) + +--- + +## 1. Qué es PymesBot + +PymesBot es una **plataforma SaaS multi-tenant** que da a negocios minoristas (librerías, kioscos, papelerías, bazares) un asistente de ventas con inteligencia artificial accesible desde el navegador web. + +El producto final es una **web app** donde el vendedor escribe en lenguaje natural y el bot le responde con información exacta del stock, precios, alternativas y combos. Al confirmar una venta, el stock se descuenta automáticamente. + +**Quién lo usa:** +- **Vendedor** (en el mostrador): chat con el bot para atender clientes rápido +- **Dueño del negocio** (en la misma web): dashboard con estadísticas, gestión de stock, promociones + +**Quién lo opera:** +- **RVConsultas** (Guillermo): da de alta clientes nuevos, cobra mensualidad, administra el servidor + +--- + +## 2. Problema que resuelve + +La mayoría de negocios PyME argentinos manejan el inventario en papel o Excel sin conexión. Esto genera: + +1. **Precios aproximados** ("creo que está como $800...") → desconfianza del cliente +2. **Tiempo perdido** buscando productos en carpetas o cuadernos → el cliente se va +3. **Cero datos de ventas** → el dueño no sabe qué se vende, cuándo ni cuánto + +PymesBot resuelve los tres problemas sin pedirle al negocio que aprenda un sistema nuevo. El dueño sigue usando su Excel; el sistema lo importa. + +--- + +## 3. Cómo funciona — visión general + +``` +[Vendedor escribe en el chat] + ↓ +[Web app (browser)] ←→ WebSocket ←→ [Backend FastAPI] + ↓ + [PicoClaw agent] + ↓ + [Claude API (Anthropic)] + ↓ + [Herramientas: buscar stock, registrar venta, armar combo] + ↓ + [SQLite del cliente] +``` + +**Regla de oro: el vendedor completa una consulta en menos de 2 minutos.** + +Flujo de ejemplo: +1. Vendedor escribe: `"tenés biromes bic azul?"` +2. El bot busca en el stock → encuentra: precio $850, stock 23 unidades, variantes azul/rojo/negro +3. Bot responde: `"Sí, Bic azul a $850 (quedan 23). También hay roja y negra al mismo precio."` +4. Bot pregunta: `"¿Se concretó la venta? ¿Cuántas unidades?"` +5. Vendedor: `"2"` +6. Stock se descuenta de 23 a 21, venta se registra con precio y timestamp + +--- + +## 4. Stack tecnológico completo + +### Lenguajes y frameworks + +| Capa | Tecnología | Versión mínima | Por qué | +|------|-----------|----------------|---------| +| Backend API | Python + FastAPI | Python 3.11, FastAPI 0.110 | Asíncrono, rápido, soporte nativo WebSocket | +| Servidor ASGI | Uvicorn | 0.29 | Standard para FastAPI en producción | +| Agente IA | PicoClaw (Go) | v0.0.1 | <10MB RAM, arranca en <1s, gateway nativo | +| Base de datos | SQLite | 3.x (incluido en Python) | Zero config, un archivo, backup trivial | +| Templates HTML | Jinja2 | 3.x | Incluido en FastAPI | +| Frontend | HTML + CSS + JS vanilla | ES2020 | Sin build step, carga instantánea | +| Reverse proxy | Nginx | 1.24+ | Wildcard SSL, reverse proxy, performance | +| SSL | Certbot + Let's Encrypt | Último | Gratis, auto-renovación | +| Contenedores | Docker + Docker Compose | Docker 24+, Compose v2 | Aislamiento por cliente | + +### Servicios externos + +| Servicio | Uso | Quién paga | +|----------|-----|-----------| +| Anthropic API (Claude) | El cerebro del bot | RVConsultas (costo absorbido en precio mensual) | +| Modelo a usar | `claude-sonnet-4-5-20250929` | — | +| VPS | Servidor de producción | RVConsultas | + +### VPS recomendado + +- **RAM:** 16 GB +- **CPU:** 6 núcleos +- **Disco:** 100 GB SSD +- **OS:** Ubuntu 24.04 LTS +- **Capacidad:** ~150 clientes simultáneos cómodos + +--- + +## 5. Arquitectura multi-tenant + +### Concepto fundamental + +Cada cliente (negocio) tiene su propio **subdominio** bajo `rvconsultas.com`: + +``` +castillo.rvconsultas.com → Librería Castillo +reyes.rvconsultas.com → Librería Reyes +nuevocliente.rvconsultas.com → cualquier negocio nuevo +``` + +Cada subdominio es un **entorno completamente aislado**: +- Su propio contenedor Docker (backend FastAPI) +- Su propio agente PicoClaw +- Su propia base de datos SQLite +- Su propio inventario y configuración + +**Un cliente NUNCA puede ver datos de otro cliente. Jamás. Este es un requisito no negociable.** + +### Diagrama de arquitectura + +``` +Internet + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ Nginx (host) │ +│ *.rvconsultas.com → Let's Encrypt wildcard SSL │ +│ Reverse proxy → puerto interno por cliente │ +└────────┬────────────────────┬───────────────────┘ + │ │ + ▼ ▼ +castillo.rvconsultas.com reyes.rvconsultas.com +Puerto 8201 Puerto 8202 + │ │ +┌────────┴──────┐ ┌────────┴──────┐ +│ Docker │ │ Docker │ +│ Container A │ │ Container B │ +│ │ │ │ +│ FastAPI │ │ FastAPI │ +│ PicoClaw │ │ PicoClaw │ +│ SQLite DB │ │ SQLite DB │ +└───────────────┘ └───────────────┘ + │ │ + └────────┬───────────┘ + ▼ + Anthropic API + (claude-sonnet-4-5-20250929) + Una sola API key de RVConsultas +``` + +### Nginx — configuración de subdominio por cliente + +Cada cliente tiene su archivo `/etc/nginx/conf.d/{slug}.conf`: + +```nginx +server { + listen 80; + server_name castillo.rvconsultas.com; + # Certbot agrega el bloque HTTPS automáticamente + location / { + proxy_pass http://127.0.0.1:8201; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_cache_bypass $http_upgrade; + } +} +``` + +--- + +## 6. Estructura de carpetas en el servidor + +``` +/opt/pymesbot/ ← raíz de toda la plataforma +│ +├── .env.global ← API key de Anthropic (una sola, compartida) +│ # Contenido: +│ # ANTHROPIC_API_KEY=sk-ant-... +│ +├── nginx/ +│ └── conf.d/ +│ ├── castillo.conf ← bloque Nginx generado por el instalador +│ └── reyes.conf +│ +├── scripts/ +│ ├── nuevo_cliente.sh ← script de onboarding +│ └── backup.sh ← backup diario de todas las DBs +│ +├── castillo/ ← carpeta completa de UN cliente +│ ├── docker-compose.yml ← levanta los contenedores de este cliente +│ ├── .env ← variables de entorno (generadas por instalador) +│ │ +│ ├── picoclaw/ +│ │ └── config.json ← config de PicoClaw: API key + system prompt +│ │ +│ ├── backend/ +│ │ ├── Dockerfile ← imagen del backend FastAPI +│ │ ├── requirements.txt +│ │ ├── main.py ← FastAPI app principal +│ │ ├── db.py ← helpers de SQLite +│ │ ├── tools.py ← endpoints que llama PicoClaw como herramientas +│ │ ├── dashboard.py ← rutas del dashboard web +│ │ ├── auth.py ← autenticación simple (PIN vendedor / admin) +│ │ ├── models.py ← Pydantic models +│ │ └── templates/ +│ │ ├── base.html ← layout base (navbar, CSS global) +│ │ ├── chat.html ← vista del vendedor (mostrador) +│ │ └── admin.html ← vista del dueño (dashboard) +│ │ +│ └── data/ +│ ├── stock.db ← base de datos SQLite del cliente +│ └── uploads/ ← Excels subidos por el dueño +│ +└── reyes/ ← ídem para otro cliente + └── ... +``` + +--- + +## 7. Base de datos — esquema completo + +Cada cliente tiene su propio archivo `stock.db`. El esquema se crea con este SQL al inicializar: + +```sql +-- ═══════════════════════════════════════════════════════ +-- TABLA: productos +-- ═══════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS productos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nombre TEXT NOT NULL, -- "Birome Bic" + marca TEXT, -- "Bic" + categoria TEXT NOT NULL, -- "escritura" + precio REAL NOT NULL, -- 850.00 + stock INTEGER NOT NULL DEFAULT 0, -- 23 + variantes TEXT, -- JSON: {"color":["azul","rojo"],"talle":["M","L"]} + codigo TEXT, -- código interno o EAN (opcional) + activo INTEGER NOT NULL DEFAULT 1, -- 1=activo, 0=deshabilitado (no aparece en búsquedas) + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Índices para búsqueda rápida +CREATE INDEX IF NOT EXISTS idx_productos_nombre ON productos(nombre); +CREATE INDEX IF NOT EXISTS idx_productos_categoria ON productos(categoria); +CREATE INDEX IF NOT EXISTS idx_productos_activo ON productos(activo); + +-- ═══════════════════════════════════════════════════════ +-- TABLA: ventas +-- ═══════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS ventas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + producto_id INTEGER NOT NULL REFERENCES productos(id), + cantidad INTEGER NOT NULL, + precio_vendido REAL NOT NULL, -- precio al momento de la venta (puede diferir del actual) + vendedor TEXT, -- nombre del vendedor (optativo) + notas TEXT, -- notas adicionales (optativo) + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_ventas_producto_id ON ventas(producto_id); +CREATE INDEX IF NOT EXISTS idx_ventas_timestamp ON ventas(timestamp); + +-- ═══════════════════════════════════════════════════════ +-- TABLA: promociones +-- ═══════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS promociones ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nombre TEXT NOT NULL, -- "Vuelta al cole 20% off" + tipo TEXT NOT NULL, -- "descuento_pct" | "descuento_fijo" | "precio_especial" + valor REAL NOT NULL, -- 20 (para pct) | 500 (para fijo) | 1500 (para precio_especial) + productos TEXT, -- JSON array de product IDs: [1,2,3] o NULL = todos + categorias TEXT, -- JSON array de categorías: ["escritura"] o NULL = todas + activa INTEGER NOT NULL DEFAULT 1, + fecha_inicio TEXT, -- NULL = siempre activa + fecha_fin TEXT, -- NULL = sin vencimiento + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ═══════════════════════════════════════════════════════ +-- TABLA: config +-- Configuración del negocio como key-value +-- ═══════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS config ( + clave TEXT PRIMARY KEY, + valor TEXT NOT NULL +); + +-- Valores por defecto que se insertan al crear el negocio +INSERT OR IGNORE INTO config (clave, valor) VALUES + ('nombre_negocio', 'Mi Negocio'), -- se sobreescribe con el nombre real + ('moneda', 'ARS'), + ('moneda_simbolo', '$'), + ('combo_categorias', '["escritura","cuadernos","geometria","colores"]'), + ('alerta_stock_minimo', '5'), -- notificar cuando stock < este número + ('vendedor_pin', '1234'), -- PIN para acceso rápido al chat + ('admin_password', ''), -- se genera aleatoriamente al instalar + ('rubro', 'libreria'); -- libreria | kiosco | bazar | otro +``` + +### Notas importantes sobre la DB + +- **SQLite es suficiente para este caso de uso.** Un negocio PyME rara vez tiene más de 5.000 productos y algunas docenas de ventas por hora. SQLite maneja esto sin problema. +- **Nunca usar ORM pesados como SQLAlchemy para esto.** Usar `sqlite3` de la stdlib de Python directamente o `aiosqlite` para las queries async. Simple y sin magia. +- **El campo `variantes`** es un JSON string. Ejemplo: `'{"color": ["azul", "rojo", "negro"], "talle": ["M", "L", "XL"]}'`. El backend lo parsea con `json.loads()`. +- **El campo `precio_vendido` en ventas** es importante: el precio puede cambiar con el tiempo. Hay que guardar el precio al momento de la venta, no una referencia al precio actual del producto. + +--- + +## 8. Backend FastAPI — todos los endpoints + +### Archivo: `main.py` + +```python +# Estructura básica de main.py +from fastapi import FastAPI, WebSocket, UploadFile, Depends +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +app = FastAPI(title="PymesBot Backend") + +# Montar rutas +app.include_router(stock_router, prefix="/stock", tags=["stock"]) +app.include_router(ventas_router, prefix="/venta", tags=["ventas"]) +app.include_router(combo_router, prefix="/combo", tags=["combos"]) +app.include_router(promo_router, prefix="/promo", tags=["promociones"]) +app.include_router(stats_router, prefix="/stats", tags=["estadísticas"]) +app.include_router(dashboard_router, prefix="", tags=["dashboard"]) + +@app.get("/health") +async def health(): + return {"status": "ok", "negocio": get_config("nombre_negocio")} +``` + +--- + +### Endpoints de Stock (`/stock`) + +#### `GET /stock/search` + +Búsqueda de productos en lenguaje natural. Este es el endpoint más importante, lo llama PicoClaw. + +**Parámetros query:** +- `q` (string, requerido): query de búsqueda libre. Ej: `"birome bic azul"`, `"cuaderno rayado chico"` +- `limit` (int, opcional, default: 5): máximo de resultados + +**Lógica de búsqueda (implementar en este orden de prioridad):** +1. Búsqueda exacta por nombre (LIKE `%q%`) +2. Búsqueda por palabras clave individuales (split por espacios, AND de todos los términos) +3. Si hay menos de 3 resultados: búsqueda OR (al menos una palabra coincide) +4. Solo devolver productos con `activo = 1` + +**Respuesta exitosa (200):** +```json +{ + "resultados": [ + { + "id": 42, + "nombre": "Birome Bic Cristal", + "marca": "Bic", + "categoria": "escritura", + "precio": 850.00, + "precio_formateado": "$850", + "stock": 23, + "variantes": {"color": ["azul", "rojo", "negro"]}, + "hay_stock": true, + "promo_activa": null + } + ], + "total": 1, + "query": "birome bic azul" +} +``` + +**Si no hay resultados:** +```json +{ + "resultados": [], + "total": 0, + "query": "birome bic azul", + "sugerencias": ["Birome Bic Cristal", "Birome Faber Castell"] +} +``` +> Las sugerencias son productos de la misma categoría estimada con stock disponible. + +--- + +#### `GET /stock/producto/{id}` + +Detalle completo de un producto por ID. + +**Respuesta (200):** +```json +{ + "id": 42, + "nombre": "Birome Bic Cristal", + "marca": "Bic", + "categoria": "escritura", + "precio": 850.00, + "stock": 23, + "variantes": {"color": ["azul", "rojo", "negro"]}, + "codigo": "7501031311309", + "activo": true, + "created_at": "2026-01-15T10:30:00", + "updated_at": "2026-02-10T14:22:00" +} +``` + +**Error 404:** +```json +{"detail": "Producto con id 42 no encontrado"} +``` + +--- + +#### `PUT /stock/producto/{id}` + +Actualiza precio o stock de un producto existente. Solo el dueño puede llamar esto (auth requerida). + +**Body (todos opcionales, solo se actualiza lo que se envía):** +```json +{ + "precio": 950.00, + "stock": 30, + "activo": true +} +``` + +**Respuesta (200):** el producto actualizado completo. + +--- + +#### `POST /stock/importar` + +Importa o reemplaza el inventario completo desde un archivo Excel o CSV. Solo el dueño puede llamar esto. + +**Form data:** +- `file`: archivo `.xlsx` o `.csv` +- `modo`: `"reemplazar"` (borra todo y reimporta) | `"actualizar"` (solo actualiza precios/stock de los que ya existen, agrega los nuevos) + +**Columnas esperadas en el Excel/CSV (detección automática, case-insensitive):** +- `nombre` o `producto` o `descripcion` → campo `nombre` (REQUERIDO) +- `precio` o `precio_venta` o `price` → campo `precio` (REQUERIDO) +- `stock` o `cantidad` o `qty` → campo `stock` (default: 0 si no está) +- `categoria` o `rubro` o `category` → campo `categoria` (default: "general") +- `marca` o `brand` → campo `marca` (opcional) +- `codigo` o `ean` o `barcode` → campo `codigo` (opcional) + +**Respuesta (200):** +```json +{ + "importados": 245, + "actualizados": 12, + "errores": 0, + "errores_detalle": [] +} +``` + +**Respuesta con errores (200, pero con detalle de errores):** +```json +{ + "importados": 240, + "actualizados": 0, + "errores": 5, + "errores_detalle": [ + {"fila": 12, "razon": "precio inválido: 'consultar'"}, + {"fila": 34, "razon": "nombre vacío"} + ] +} +``` + +--- + +#### `GET /stock/alertas` + +Devuelve productos con stock por debajo del mínimo configurado. + +**Respuesta (200):** +```json +{ + "alertas": [ + {"id": 12, "nombre": "Goma Staedtler", "stock": 3, "minimo": 5}, + {"id": 44, "nombre": "Regla 30cm", "stock": 1, "minimo": 5} + ], + "total": 2 +} +``` + +--- + +### Endpoints de Ventas (`/venta`) + +#### `POST /venta/confirmar` + +Registra una venta y descuenta el stock. Lo llama PicoClaw después de confirmar con el vendedor. + +**Body:** +```json +{ + "producto_id": 42, + "cantidad": 2, + "precio_vendido": 850.00, + "vendedor": "María" +} +``` + +**Validaciones antes de ejecutar:** +1. El producto existe y está activo +2. `cantidad > 0` +3. `stock actual >= cantidad` (si el stock quedaría negativo, devolver error) +4. Si hay promo activa aplicable, usar ese precio (pero respetar el precio_vendido enviado si difiere) + +**Respuesta (200):** +```json +{ + "venta_id": 891, + "producto": "Birome Bic Cristal", + "cantidad": 2, + "precio_vendido": 850.00, + "total": 1700.00, + "stock_anterior": 23, + "stock_nuevo": 21, + "timestamp": "2026-02-14T15:42:00" +} +``` + +**Error 400 (stock insuficiente):** +```json +{ + "detail": "Stock insuficiente. Stock actual: 1, solicitado: 2." +} +``` + +--- + +#### `GET /venta/historial` + +Lista de ventas con filtros opcionales. + +**Parámetros query (todos opcionales):** +- `fecha_desde`: ISO date string. Default: hoy a las 00:00 +- `fecha_hasta`: ISO date string. Default: ahora +- `producto_id`: filtrar por producto +- `vendedor`: filtrar por vendedor +- `limit`: default 100, max 1000 +- `offset`: para paginación + +**Respuesta (200):** +```json +{ + "ventas": [ + { + "id": 891, + "producto_id": 42, + "producto_nombre": "Birome Bic Cristal", + "cantidad": 2, + "precio_vendido": 850.00, + "total": 1700.00, + "vendedor": "María", + "timestamp": "2026-02-14T15:42:00" + } + ], + "total": 1, + "suma_total": 1700.00 +} +``` + +--- + +### Endpoints de Estadísticas (`/stats`) + +#### `GET /stats/ventas` + +Resumen de ventas para el dashboard del dueño. + +**Parámetros query:** +- `periodo`: `"hoy"` | `"semana"` | `"mes"` (default: `"hoy"`) + +**Respuesta (200):** +```json +{ + "periodo": "hoy", + "total_ventas": 47, + "total_pesos": 38500.00, + "ticket_promedio": 819.15, + "productos_mas_vendidos": [ + {"nombre": "Birome Bic Cristal", "cantidad": 12, "total": 10200.00}, + {"nombre": "Cuaderno Rivadavia", "cantidad": 8, "total": 12000.00} + ], + "comparacion_periodo_anterior": { + "total_pesos_anterior": 31200.00, + "variacion_pct": 23.4 + } +} +``` + +--- + +### Endpoints de Combos (`/combo`) + +#### `POST /combo/armar` + +Genera una lista de productos sugeridos para un presupuesto dado. Lo llama PicoClaw. + +**Body:** +```json +{ + "presupuesto": 300000, + "edad": 7, + "genero": "nena", + "categorias": ["escritura", "cuadernos", "geometria", "colores"], + "incluir_solo_con_stock": true +} +``` + +**Notas sobre `genero`:** +- `"nena"`: priorizar colores llamativos (rosa, lila, rojo), evitar nombres genéricamente masculinos en nombres de productos si hubiera +- `"neno"`: priorizar azul, verde, colores neutros +- `null` o ausente: sin preferencia de género + +**Notas sobre `edad`:** +- `<= 6 años`: primaria inicial → lápices de colores grandes, plastilina, sin tijeras de punta +- `7-10 años`: primaria general → juego de geometría, cuadernos doble raya, colores largos +- `11-13 años`: primaria superior → cuadernos cuadriculados, compás, colores profesionales +- `>= 14 años`: secundaria → calculadora, carpeta, birome, cuaderno universitario + +**Lógica del armado de combo:** +1. Filtrar productos por categorías solicitadas y con stock > 0 +2. Ordenar por "esencialidad" (el backend tiene una lista hardcodeada de productos esenciales por edad) +3. Ir sumando productos desde los más esenciales hasta llegar al 95% del presupuesto +4. Si el presupuesto no alcanza para un producto esencial, marcarlo como "no incluido" pero mostrarlo igual + +**Respuesta (200):** +```json +{ + "presupuesto": 300000, + "total_combo": 287500, + "vuelto": 12500, + "productos": [ + { + "id": 42, + "nombre": "Birome Bic Cristal (azul)", + "precio": 850, + "cantidad": 2, + "subtotal": 1700, + "incluido": true, + "esencial": true + }, + { + "id": 78, + "nombre": "Cuaderno Rivadavia A4", + "precio": 2500, + "cantidad": 3, + "subtotal": 7500, + "incluido": true, + "esencial": true + } + ], + "no_incluidos": [] +} +``` + +--- + +### Endpoints de Promociones (`/promo`) + +#### `GET /promo/activas` + +Lista de promociones vigentes hoy. + +**Respuesta (200):** +```json +{ + "promociones": [ + { + "id": 3, + "nombre": "Vuelta al cole 20% off escritura", + "tipo": "descuento_pct", + "valor": 20, + "categorias": ["escritura"], + "productos": null, + "fecha_fin": "2026-03-15" + } + ] +} +``` + +#### `POST /promo/crear` + +Crea una nueva promoción. Solo el dueño puede llamar esto. + +**Body:** +```json +{ + "nombre": "Vuelta al cole 20% off escritura", + "tipo": "descuento_pct", + "valor": 20, + "categorias": ["escritura"], + "productos": null, + "fecha_inicio": null, + "fecha_fin": "2026-03-15" +} +``` + +--- + +### WebSocket de Chat (`/chat`) + +#### `WebSocket /chat/ws/{session_id}` + +El canal de comunicación entre el frontend del vendedor y el agente PicoClaw. + +**Flujo del WebSocket:** + +``` +Frontend Backend (FastAPI) PicoClaw + | | | + |-- connect ws/session_abc ---> | | + | | | + |-- { "msg": "tenés bic?" } --> | | + | |-- llamar PicoClaw -------> | + | | |-- GET /stock/search?q=bic + | |<-- respuesta parcial ----- | + |<-- { "typing": true } ------- | | + | |<-- respuesta final ------- | + |<-- { "msg": "Sí, Bic...", "typing": false } ------------- | +``` + +**Formato de mensajes del frontend al backend:** +```json +{ "msg": "texto del vendedor", "session_id": "abc123" } +``` + +**Formato de mensajes del backend al frontend:** +```json +{ "msg": "texto de respuesta del bot", "typing": false } +{ "typing": true } +{ "error": "descripción del error" } +``` + +**Historial de conversación:** +- El backend mantiene el historial de la sesión en memoria (dict con `session_id` como clave) +- El historial se descarta después de 30 minutos de inactividad +- Máximo 20 turnos de historial por sesión (para no explotar el context window) + +--- + +## 9. El agente IA — PicoClaw + Claude + +### Qué es PicoClaw + +PicoClaw (github.com/sipeed/picoclaw) es un agente IA escrito en Go. Es extremadamente liviano: +- **RAM:** < 10 MB por instancia +- **Startup:** < 1 segundo +- **Arquitectura:** binario Go auto-contenido, sin dependencias externas +- Se conecta a la API de Claude (Anthropic) o cualquier API compatible con OpenAI + +En nuestra arquitectura, PicoClaw corre como un proceso dentro del contenedor del cliente. El backend FastAPI lo llama internamente cuando llega un mensaje de chat. + +### Config de PicoClaw (`picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 1000, + "temperature": 0.3, + "max_tool_iterations": 5, + "system": "SYSTEM PROMPT AQUÍ (ver abajo)" + } + }, + "providers": { + "anthropic": { + "api_key": "${ANTHROPIC_API_KEY}" + } + } +} +``` + +### System Prompt completo + +El system prompt se personaliza por negocio. Este es el template: + +``` +Sos el asistente de ventas de {NOMBRE_NEGOCIO}, una {RUBRO} ubicada en Argentina. +Tu trabajo es ayudar al vendedor a atender clientes de forma rápida y con información exacta. + +## HERRAMIENTAS DISPONIBLES +Tenés acceso a estas herramientas. SIEMPRE usarlas antes de responder sobre stock o precios: +- `buscar_stock`: busca productos en el inventario por nombre o descripción +- `confirmar_venta`: registra una venta y descuenta el stock +- `armar_combo`: genera una lista de productos para un presupuesto dado +- `ver_promociones`: muestra las promociones activas de hoy + +## REGLAS OBLIGATORIAS (nunca violarlas) + +1. **NUNCA des precios aproximados.** Siempre usá `buscar_stock` para obtener el precio exacto. + Si el tool no devuelve precio, decí que no tenés ese dato, no inventes. + +2. **Si no hay stock de un producto**, ofrecé SIEMPRE la alternativa más cercana que sí haya stock. + Ej: "No tenemos Bic azul, pero sí tenemos Faber roja a $750." + +3. **Al armar combos**, SIEMPRE preguntá antes: + - ¿Cuánto es el presupuesto? (número exacto) + - ¿Para qué edad es? + - ¿Es para nene o nena? (optativo, decile que puede no responder) + +4. **Al final de cada consulta exitosa**, preguntá: "¿Se concretó la venta?" + Si dicen sí, preguntá cuántas unidades y llamá a `confirmar_venta`. + +5. **Respondé SIEMPRE en español argentino coloquial pero profesional.** + Usá "vos", "tenés", "querés". No uses "usted" ni español neutro. + +6. **Sé conciso.** Una respuesta de chat, no un ensayo. Máximo 3-4 líneas por respuesta. + +7. **Nunca menciones información de otros negocios, competidores ni otras sucursales.** + +## EJEMPLOS DE RESPUESTAS CORRECTAS + +Vendedor: "tenés birome bic azul?" +Bot: [llama buscar_stock con "birome bic azul"] +Bot: "Sí, tenemos Bic Cristal azul a $850. Quedan 23. ¿Se vendió?" + +Vendedor: "no hay regla 30cm" +Bot: [llama buscar_stock con "regla 30cm"] +Bot: "No tenemos regla de 30cm por ahora 😕 Pero sí hay de 20cm (Maped, $650, stock 8). ¿Te sirve esa?" + +Vendedor: "una nena me pregunta para útiles, tiene $300000" +Bot: "¡Perfecto! ¿Cuántos años tiene? Y si querés podés decirme si tiene algún color preferido." +``` + +### Herramientas que PicoClaw puede llamar + +PicoClaw llama los endpoints del backend FastAPI como "tools". La configuración de tools se pasa en el context: + +```json +{ + "tools": [ + { + "name": "buscar_stock", + "description": "Busca productos en el inventario. Úsala siempre antes de dar precios o hablar de stock.", + "input_schema": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Nombre o descripción del producto" } + }, + "required": ["query"] + } + }, + { + "name": "confirmar_venta", + "description": "Registra una venta y descuenta el stock. Solo llamar cuando el vendedor confirme.", + "input_schema": { + "type": "object", + "properties": { + "producto_id": { "type": "integer" }, + "cantidad": { "type": "integer" }, + "precio_vendido": { "type": "number" } + }, + "required": ["producto_id", "cantidad", "precio_vendido"] + } + }, + { + "name": "armar_combo", + "description": "Genera una lista de productos para un presupuesto dado.", + "input_schema": { + "type": "object", + "properties": { + "presupuesto": { "type": "number" }, + "edad": { "type": "integer" }, + "genero": { "type": "string", "enum": ["nena", "neno", null] } + }, + "required": ["presupuesto"] + } + }, + { + "name": "ver_promociones", + "description": "Muestra las promociones activas de hoy.", + "input_schema": { "type": "object", "properties": {} } + } + ] +} +``` + +--- + +## 10. Dashboard web — especificación UI + +### Vista Vendedor (chat del mostrador) + +**URL:** `https://castillo.rvconsultas.com/` +**Acceso:** PIN de 4 dígitos (configurable por el dueño) + +**Layout:** +``` +┌──────────────────────────────────────────┐ +│ 🛒 Librería Castillo [vendedor: María]│ ← header +├─────────────────────┬────────────────────┤ +│ │ 📊 Hoy │ +│ CHAT │ Ventas: 47 │ +│ │ Total: $38.500 │ +│ Bot: Hola! ¿Qué │ │ +│ necesitás buscar? │ ⚠️ Stock bajo (2) │ +│ │ - Goma Staedtler │ +│ Vend: tenés bic? │ - Regla 20cm │ +│ │ │ +│ Bot: Sí, Bic azul │ │ +│ $850. Quedan 23. │ │ +│ ¿Se vendió? │ │ +│ │ │ +│ [_______________] │ │ +│ [ Enviar ] │ │ +└─────────────────────┴────────────────────┘ +``` + +**Características del chat:** +- El historial de la sesión actual se muestra en pantalla +- Los mensajes del bot tienen fondo diferente a los del vendedor +- Indicador de "escribiendo..." mientras el bot procesa +- Botones de acción rápida cuando corresponde: `[✓ Confirmar venta]` `[Ver alternativas]` `[Nuevo combo]` +- En móvil: el panel de resumen del día se colapsa. Solo el chat visible. +- **Responsive:** debe funcionar bien en tablet 10" y en PC de escritorio + +**Autenticación:** +- Al entrar, pide el PIN de 4 dígitos +- El PIN correcto abre el chat +- No hay "logout", el PIN simplemente da acceso + +--- + +### Vista Dueño / Admin + +**URL:** `https://castillo.rvconsultas.com/admin` +**Acceso:** usuario `admin` + contraseña generada al instalar + +**Secciones del admin:** + +#### 📊 Dashboard principal +- Total vendido hoy / esta semana / este mes (switcher de período) +- Gráfico de barras de ventas por día (últimos 7 días) +- Top 5 productos más vendidos (tabla) +- Comparación con período anterior (% de variación) + +#### 📦 Gestión de stock +- Tabla con todos los productos: nombre, categoría, precio, stock, activo +- Edición inline: hacer click en precio o stock para editar +- Filtros: por categoría, por nombre (búsqueda), por "stock bajo" +- Botón "Importar Excel": abre modal para subir archivo + +#### 🎯 Promociones +- Lista de promociones activas/inactivas +- Formulario para crear nueva promo: + - Nombre + - Tipo: `% descuento` / `monto fijo` / `precio especial` + - Valor + - Aplicable a: todas las categorías / categorías específicas / productos específicos + - Fecha de vencimiento (opcional) +- Toggle para activar/desactivar + +#### 📋 Historial de ventas +- Tabla filtrable por fecha y producto +- Filtro de rango de fechas (date picker) +- Exportar a CSV (botón) + +#### ⚙️ Configuración +- Nombre del negocio +- Moneda +- Umbral de alerta de stock mínimo +- Cambiar PIN del vendedor +- Cambiar contraseña del admin + +--- + +## 11. Docker Compose por cliente + +Cada cliente tiene su propio `docker-compose.yml`. Se genera automáticamente con el instalador. + +```yaml +version: '3.8' + +services: + + backend_castillo: + build: + context: ./backend + dockerfile: Dockerfile + container_name: pymesbot_castillo + restart: always + ports: + - "${PUERTO}:8000" # PUERTO viene del .env, ej: 8201 + volumes: + - ./data:/app/data # la DB SQLite vive acá + - ./backend:/app # para desarrollo (en prod, solo copiar) + env_file: + - .env + networks: + - pymesbot_net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + + picoclaw_castillo: + image: ghcr.io/sipeed/picoclaw:latest + # Alternativa si la imagen no está disponible: build local del binario Go + container_name: picoclaw_castillo + restart: always + volumes: + - ./picoclaw:/root/.picoclaw # config.json de PicoClaw + - ./data:/data:ro # acceso READ-ONLY a los datos (solo el backend escribe) + depends_on: + backend_castillo: + condition: service_healthy + networks: + - pymesbot_net + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + +networks: + pymesbot_net: + external: true # creada una vez por el instalador, compartida entre todos los clientes +``` + +### Dockerfile del backend (`backend/Dockerfile`) + +```dockerfile +FROM python:3.11-slim + +WORKDIR /app + +# Instalar dependencias del sistema +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copiar e instalar dependencias Python +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copiar código +COPY . . + +# Crear directorio de datos +RUN mkdir -p /app/data/uploads + +# Exponer puerto +EXPOSE 8000 + +# Comando de inicio +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] +``` + +### requirements.txt + +``` +fastapi==0.110.0 +uvicorn[standard]==0.29.0 +jinja2==3.1.3 +python-multipart==0.0.9 +aiosqlite==0.20.0 +openpyxl==3.1.2 +pandas==2.2.0 +anthropic==0.25.0 +python-dotenv==1.0.1 +aiofiles==23.2.1 +httpx==0.27.0 +``` + +--- + +## 12. Templates de configuración + +### `.env` por cliente + +```bash +# Generado automáticamente por el instalador +ANTHROPIC_API_KEY=sk-ant-... # copiada del .env.global +CLIENTE_NOMBRE=Librería Castillo +CLIENTE_ID=castillo +PUERTO=8201 +ADMIN_PASSWORD=Xk7mQ2p # generada aleatoriamente +VENDOR_PIN=4821 # generada aleatoriamente +DOMINIO_BASE=rvconsultas.com +``` + +### Nginx conf por cliente (`/etc/nginx/conf.d/castillo.conf`) + +```nginx +server { + listen 80; + server_name castillo.rvconsultas.com; + + # Certbot agrega automáticamente el bloque SSL al correr certbot --nginx + + location / { + proxy_pass http://127.0.0.1:8201; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; # importante para WebSocket + proxy_send_timeout 300s; + } +} +``` + +--- + +## 13. Flujo completo de una venta + +Este es el flujo técnico detallado de lo que pasa cuando el vendedor escribe en el chat: + +``` +1. Vendedor escribe "tenés birome bic azul?" en el chat +2. JS del frontend: WebSocket.send({ msg: "tenés birome bic azul?", session_id: "abc" }) +3. Backend FastAPI recibe el mensaje en el WebSocket handler +4. Backend envía { typing: true } al frontend → aparece indicador "escribiendo..." +5. Backend pasa el mensaje a PicoClaw con: + - El historial de la conversación (hasta 20 turnos) + - El system prompt del negocio + - Las herramientas disponibles +6. PicoClaw llama a Claude API con todo lo anterior +7. Claude decide llamar la herramienta buscar_stock con { query: "birome bic azul" } +8. PicoClaw hace GET http://localhost:8000/stock/search?q=birome+bic+azul +9. FastAPI busca en SQLite: encuentra "Birome Bic Cristal", precio $850, stock 23 +10. FastAPI devuelve el JSON de resultados a PicoClaw +11. PicoClaw pasa el resultado a Claude +12. Claude genera la respuesta final: "Sí, tenemos Bic Cristal azul a $850. Quedan 23. ¿Se vendió?" +13. Backend envía la respuesta al frontend via WebSocket: { msg: "Sí, tenemos...", typing: false } +14. Frontend muestra el mensaje del bot en el chat + +--- (segunda vuelta) --- + +15. Vendedor escribe "sí, 2" +16. PicoClaw (con el historial que incluye el producto encontrado) llama confirmar_venta + con { producto_id: 42, cantidad: 2, precio_vendido: 850 } +17. FastAPI: + - Verifica: producto 42 existe, activo, stock 23 >= 2 ✓ + - INSERT INTO ventas (producto_id, cantidad, precio_vendido, ...) VALUES (42, 2, 850, ...) + - UPDATE productos SET stock = stock - 2, updated_at = now() WHERE id = 42 + - Devuelve: { venta_id: 891, stock_nuevo: 21, total: 1700 } +18. Claude genera confirmación: "¡Listo! 2 Bic Cristal azul a $850 c/u = $1.700. Stock actualizado: quedan 21." +19. Frontend muestra la confirmación +``` + +--- + +## 14. Modelo de negocio y precios + +### Planes + +| Plan | Precio | Límites | +|------|--------|---------| +| Starter | $15 USD/mes | 1 usuario vendedor, hasta 500 productos | +| Pro | $30 USD/mes | 3 usuarios, productos ilimitados, estadísticas | +| Red | $45 USD/mes | Todo Pro + tendencias zonales, red de PyMEs | + +### Costos operativos estimados (por cliente activo) + +| Item | Costo mensual | +|------|--------------| +| Anthropic API (Claude) | ~$1-2 USD (estimado 500 consultas/mes) | +| VPS (prorrateado entre clientes) | ~$0.15 USD | +| **Total costo** | **~$2 USD/cliente** | + +**Margen bruto estimado (plan Starter):** $15 - $2 = $13 USD por cliente + +--- + +## 15. Roadmap de implementación + +### Fase 1 — MVP funcional (semanas 1-4) +**Objetivo:** un vendedor real puede atender un cliente en menos de 2 minutos. + +- [ ] Estructura de carpetas y Docker Compose template +- [ ] Backend FastAPI con endpoints: `/stock/search`, `/venta/confirmar`, `/health` +- [ ] WebSocket de chat (`/chat/ws/{session_id}`) +- [ ] Integración PicoClaw + Claude API con herramientas reales +- [ ] Parser de Excel para importar stock inicial +- [ ] Dashboard mínimo: vista chat (vendedor) + upload Excel (dueño) +- [ ] Nginx config + SSL en subdominio de prueba +- [ ] Primer cliente piloto real usando el sistema + +**Criterio de éxito:** el vendedor completa una consulta completa (buscar → confirmar → stock descontado) en menos de 2 minutos sin entrenamiento previo. + +### Fase 2 — Dashboard completo (mes 2) +- [ ] Estadísticas de ventas con gráficos +- [ ] Sistema de promociones completo +- [ ] Armado de combos con filtro por edad y género +- [ ] Alertas de stock mínimo +- [ ] Vista admin completa +- [ ] Interfaz responsive para tablet y móvil + +### Fase 3 — Red de PyMEs (mes 3+) +- [ ] Módulo opt-in de datos compartidos +- [ ] Panel de tendencias zonales +- [ ] API para distribuidores + +--- + +## 16. Reglas de desarrollo obligatorias + +Estas reglas son **no negociables**. Si algo entra en conflicto con estas reglas, aplicar la regla. + +### Seguridad +1. **La API key de Anthropic NUNCA se hardcodea.** Siempre desde variable de entorno. +2. **Nunca logear** la contraseña sudo, la API key ni las contraseñas de admin. +3. **Cada cliente tiene su propio contenedor y su propia DB.** Nunca compartir conexión de DB entre clientes. +4. **Todas las rutas de admin** deben requerir autenticación. Nunca dejar una ruta admin sin protección. + +### Datos +5. **El campo `precio_vendido` en ventas** es siempre el precio real de la venta, no una FK al precio actual del producto. +6. **Nunca hacer DELETE en la tabla productos.** Usar `activo = 0` para desactivar. +7. **Todos los timestamps** en formato ISO 8601 UTC: `datetime('now')` en SQLite. + +### Performance +8. **El endpoint `/stock/search`** debe responder en menos de 200ms. Si tarda más, optimizar los índices. +9. **El WebSocket** no debe bloquear. Usar `asyncio` correctamente. Nunca llamadas síncronas dentro de handlers async. + +### Código +10. **Usar tipado estricto de Python** con Pydantic para todos los bodies de request y response. +11. **Nunca usar `*` en imports** (no `from fastapi import *`). +12. **Cada endpoint** debe tener docstring con descripción, parámetros y qué devuelve. +13. **Los errores de validación** devuelven siempre JSON con el campo `detail` descriptivo en español. +14. **Tests:** al menos un test de integración por endpoint crítico (`/stock/search`, `/venta/confirmar`). + +### UX +15. **Los mensajes de error** que ve el vendedor deben estar en español coloquial, nunca mostrar stack traces. +16. **El chat** debe mostrar el indicador de "escribiendo..." mientras el bot procesa. Nunca dejar al vendedor mirando una pantalla en blanco. + +--- + +*Fin del documento. Versión 1.0 — PymesBot Project Spec* diff --git a/02_PYMESBOT_INSTALLER_SPEC.md b/02_PYMESBOT_INSTALLER_SPEC.md new file mode 100644 index 0000000..b416ff1 --- /dev/null +++ b/02_PYMESBOT_INSTALLER_SPEC.md @@ -0,0 +1,1476 @@ +# PymesBot Installer — Especificación Técnica Completa + +> **Documento para construir el sistema de instalación web** +> Versión 1.0 · Febrero 2026 · RVConsultas +> +> Este documento describe **exclusivamente** el sistema de instalación (`pymesbot-installer`). +> Es un proyecto separado del backend principal de PymesBot. +> +> **INSTRUCCIÓN PARA LA IA:** +> Leé este documento completo antes de escribir una sola línea de código. +> Cada sección es importante. No omitas nada. No asumas nada que no esté escrito acá. +> Si algo es ambiguo, implementá la opción más simple y segura. + +--- + +## ÍNDICE + +1. [Qué es el instalador](#1-qué-es-el-instalador) +2. [Cómo se usa](#2-cómo-se-usa) +3. [Estructura de archivos completa](#3-estructura-de-archivos-completa) +4. [Stack tecnológico](#4-stack-tecnológico) +5. [El archivo que descarga el operador](#5-el-archivo-que-descarga-el-operador) +6. [Módulo detect.py — detección del entorno](#6-módulo-detectpy--detección-del-entorno) +7. [Módulo installer.py — instalación base](#7-módulo-installerpy--instalación-base) +8. [Módulo client_setup.py — alta de cliente](#8-módulo-client_setuppy--alta-de-cliente) +9. [Módulo templates_engine.py — generador de configs](#9-módulo-templates_enginepy--generador-de-configs) +10. [Backend FastAPI — rutas y WebSocket](#10-backend-fastapi--rutas-y-websocket) +11. [El wizard HTML — especificación completa](#11-el-wizard-html--especificación-completa) +12. [Templates Jinja2 que genera el instalador](#12-templates-jinja2-que-genera-el-instalador) +13. [Seguridad](#13-seguridad) +14. [Testing y validación](#14-testing-y-validación) +15. [Orden de construcción recomendado](#15-orden-de-construcción-recomendado) + +--- + +## 1. Qué es el instalador + +El instalador es un **contenedor Docker temporal** que: + +1. Se levanta en el servidor con `docker compose up -d` +2. Expone una interfaz web en `http://IP-DEL-SERVIDOR/install` +3. El operador (Guillermo) abre esa URL en su navegador, responde preguntas +4. El instalador detecta el estado del servidor y ejecuta las acciones necesarias +5. Al terminar, muestra las credenciales del cliente nuevo +6. Se apaga manualmente con `docker compose down` + +**Principio central:** el instalador detecta automáticamente si el servidor está limpio (primera vez) o si ya tiene infraestructura. El operador nunca tiene que saber en qué estado está el servidor. + +### Qué puede hacer el instalador + +**Modo A — VPS limpia (primera instalación):** +- Instalar Nginx, Certbot +- Crear la estructura de carpetas `/opt/pymesbot/` +- Crear la red Docker `pymesbot_net` +- Dar de alta el primer cliente + +**Modo B — Infraestructura existente, nuevo cliente:** +- Crear la carpeta del cliente con toda su estructura +- Generar todos los archivos de configuración +- Agregar el bloque de Nginx para el nuevo subdominio +- Emitir el certificado SSL +- Levantar los contenedores Docker del cliente +- Generar credenciales de acceso + +**Modo C — Cliente ya existente (conflicto):** +- Detectar el conflicto y avisarle al operador +- Ofrecer actualizar la configuración sin tocar la DB +- O cambiar el slug a uno disponible + +--- + +## 2. Cómo se usa + +### Paso 1: Descargar en el servidor + +```bash +# En el servidor (via SSH) +mkdir -p ~/pymesbot-install +cd ~/pymesbot-install +curl -O https://rvconsultas.com/release/pymesbot-installer/docker-compose.yml +``` + +### Paso 2: Levantar el instalador + +```bash +docker compose up -d +``` + +### Paso 3: Abrir el wizard en el navegador + +``` +http://IP-DEL-SERVIDOR/install?token=TOKEN_QUE_SE_IMPRIME_EN_LA_TERMINAL +``` + +> El token se imprime en la terminal al levantar el contenedor. Sin este token, `/install` devuelve 404. + +### Paso 4: Seguir el wizard + +6 pasos en el navegador. Ver sección 11 para el detalle de cada paso. + +### Paso 5: Apagar el instalador + +```bash +docker compose down +``` + +--- + +## 3. Estructura de archivos completa + +Este es el proyecto completo que hay que construir. Cada archivo mencionado acá tiene que existir. + +``` +pymesbot-installer/ +│ +├── docker-compose.yml ← EL ARCHIVO QUE DESCARGA EL OPERADOR +├── Dockerfile ← imagen del instalador +├── .dockerignore +│ +├── app/ ← código Python del backend del instalador +│ ├── main.py ← FastAPI app + todas las rutas +│ ├── detect.py ← detección del estado del servidor +│ ├── installer.py ← lógica de instalación de infraestructura base (Modo A) +│ ├── client_setup.py ← lógica de alta de cliente (Modo B y Modo A post-infra) +│ ├── templates_engine.py ← generador de archivos de config con Jinja2 +│ ├── security.py ← token de sesión, validación sudo +│ ├── state.py ← estado de la instalación en memoria +│ └── config_templates/ ← templates Jinja2 para generar configs +│ ├── nginx_site.conf.j2 +│ ├── docker-compose.client.yml.j2 +│ ├── picoclaw_config.json.j2 +│ ├── env_client.j2 +│ └── schema.sql ← schema SQL de la DB del cliente (NO es template, es static) +│ +├── static/ ← frontend del wizard +│ ├── index.html ← el wizard completo (todo en UN archivo HTML) +│ ├── style.css ← estilos del wizard +│ └── wizard.js ← lógica del wizard +│ +└── requirements.txt +``` + +**IMPORTANTE:** El `static/index.html` es el wizard completo. Todo el frontend en un solo archivo. No crear carpetas separadas por componentes. Debe funcionar sin CDN externo si es necesario (aunque puede usar Google Fonts). + +--- + +## 4. Stack tecnológico + +| Componente | Tecnología | Versión | Notas | +|-----------|-----------|---------|-------| +| Backend del instalador | Python + FastAPI | Python 3.11, FastAPI 0.110 | — | +| ASGI server | Uvicorn | 0.29 | — | +| Templates de config | Jinja2 | 3.x | Para generar nginx.conf, docker-compose.yml, etc. | +| Ejecución de comandos shell | `asyncio.create_subprocess_shell` | stdlib | Nunca usar `subprocess.run` síncrono dentro de async | +| Streaming de output al frontend | WebSocket (FastAPI nativo) | — | `ws://{IP}/install/ws/progress` | +| Frontend del wizard | HTML + CSS + JS vanilla | ES2020 | Sin frameworks, sin npm, sin build step | +| Fuentes web | Google Fonts (IBM Plex Mono + Space Grotesk) | — | Si no hay internet, fallback a monospace y sans-serif | +| Contenedor | Docker | 24+ | — | + +### Python packages (`requirements.txt`) + +``` +fastapi==0.110.0 +uvicorn[standard]==0.29.0 +jinja2==3.1.3 +python-multipart==0.0.9 +python-dotenv==1.0.1 +httpx==0.27.0 +aiofiles==23.2.1 +secrets # stdlib, no instalar +``` + +--- + +## 5. El archivo que descarga el operador + +Este es el `docker-compose.yml` del instalador. Es el único archivo que el operador necesita descargar. + +```yaml +version: '3.8' + +services: + pymesbot_installer: + image: rvconsultas/pymesbot-installer:latest + # Alternativa para desarrollo local: + # build: + # context: . + # dockerfile: Dockerfile + container_name: pymesbot_installer + restart: "no" # NO reiniciar solo, el operador lo baja manualmente + ports: + - "80:8000" # wizard accesible en puerto 80 + volumes: + # Estos 4 volúmenes son CRÍTICOS. Sin ellos el instalador no puede actuar sobre el host. + - /var/run/docker.sock:/var/run/docker.sock # para ejecutar docker compose en el host + - /usr/bin/docker:/usr/bin/docker:ro # binario docker disponible en el contenedor + - /opt/pymesbot:/opt/pymesbot # carpeta de clientes + - /etc/nginx:/etc/nginx # configs de Nginx + - /usr/sbin/nginx:/usr/sbin/nginx:ro # para ejecutar nginx -s reload + environment: + - INSTALLER_MODE=production + # El token de seguridad se genera al arrancar y se imprime en los logs + # Ver el token con: docker logs pymesbot_installer +``` + +### Dockerfile del instalador + +```dockerfile +FROM python:3.11-slim + +LABEL maintainer="RVConsultas" +LABEL description="PymesBot Installer — wizard de instalación web" + +WORKDIR /installer + +# Instalar herramientas del sistema necesarias +# curl: para health checks +# sudo: para ejecutar comandos privilegiados con la contraseña del operador +RUN apt-get update && apt-get install -y \ + curl \ + sudo \ + procps \ + && rm -rf /var/lib/apt/lists/* + +# Copiar e instalar dependencias Python +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copiar el código del instalador +COPY app/ ./app/ +COPY static/ ./static/ + +# Exponer puerto +EXPOSE 8000 + +# Al arrancar: generar token, imprimirlo, y levantar uvicorn +CMD ["sh", "-c", "python app/main.py"] +``` + +### .dockerignore + +``` +__pycache__/ +*.pyc +*.pyo +.env +.git/ +*.md +tests/ +``` + +--- + +## 6. Módulo detect.py — detección del entorno + +Este módulo examina el servidor y devuelve un objeto con el estado de cada componente. + +### Función principal: `async def detectar_entorno() -> DetectionResult` + +```python +# app/detect.py + +import asyncio +import os +import json +from dataclasses import dataclass, field +from typing import List, Optional + +@dataclass +class ComponentStatus: + nombre: str + ok: bool + version: Optional[str] = None + detalle: Optional[str] = None + +@dataclass +class ClienteExistente: + slug: str + nombre: str + puerto: int + activo: bool + +@dataclass +class DetectionResult: + docker_ok: bool + docker_version: Optional[str] + nginx_ok: bool + nginx_version: Optional[str] + certbot_ok: bool + certbot_version: Optional[str] + pymesbot_dir_ok: bool + red_docker_ok: bool + clientes_existentes: List[ClienteExistente] + modo: str # "A" | "B" | informational + mensaje_modo: str + siguiente_puerto: int + componentes: List[ComponentStatus] +``` + +### Lógica de detección — implementar en este orden exacto + +```python +async def detectar_entorno() -> DetectionResult: + """ + Detecta el estado del servidor y determina el modo de instalación. + Nunca lanza excepciones. Si algo falla, marca ese componente como no-ok. + """ + + # 1. Detectar Docker + # Ejecutar: docker --version + # Parsear la versión del output + # Si falla: docker_ok = False + + # 2. Detectar Nginx + # Ejecutar: nginx -v 2>&1 + # (nginx imprime la versión en stderr, por eso el 2>&1) + # Si falla: nginx_ok = False + + # 3. Detectar Certbot + # Ejecutar: certbot --version + # Si falla: certbot_ok = False + + # 4. Detectar /opt/pymesbot + # os.path.exists("/opt/pymesbot") y os.path.isdir("/opt/pymesbot") + + # 5. Detectar red Docker pymesbot_net + # Ejecutar: docker network ls --filter name=pymesbot_net --format "{{.Name}}" + # Si el output contiene "pymesbot_net": ok + + # 6. Listar clientes existentes + # Listar directorios en /opt/pymesbot/ + # Excluir: "nginx", "scripts", archivos (no dirs), dirs que empiecen con "." + # Para cada cliente, leer su .env para obtener nombre y puerto + + # 7. Determinar modo + # Modo A: cualquiera de docker_ok, nginx_ok, pymesbot_dir_ok es False + # Modo B: todo ok, y el slug del nuevo cliente no existe + # Modo C: el slug ya existe (esto se detecta después cuando el operador ingresa el slug) + + # 8. Determinar siguiente puerto disponible + # Puertos usados = [c.puerto for c in clientes_existentes] + # Siguiente = próximo disponible empezando desde 8200 + # Verificar que el puerto no esté en uso: ss -tuln | grep :PUERTO +``` + +### Helper: ejecutar comandos de forma segura + +```python +async def run_cmd(cmd: str, timeout: int = 30) -> tuple[int, str, str]: + """ + Ejecuta un comando shell de forma asíncrona. + Returns: (return_code, stdout, stderr) + Nunca lanza excepciones. Si algo falla, devuelve (1, "", str(error)) + """ + try: + proc = await asyncio.create_subprocess_shell( + cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await asyncio.wait_for( + proc.communicate(), + timeout=timeout + ) + return proc.returncode, stdout.decode(), stderr.decode() + except asyncio.TimeoutError: + return 1, "", f"Timeout después de {timeout}s" + except Exception as e: + return 1, "", str(e) +``` + +--- + +## 7. Módulo installer.py — instalación base + +Este módulo se usa solo en **Modo A** (VPS limpia). Instala la infraestructura base. + +### Función principal: `async def instalar_infraestructura(config, progress_callback)` + +El parámetro `progress_callback` es una función async que se llama para enviar eventos al frontend via WebSocket. + +```python +# Formato del evento de progreso +# progress_callback(evento: dict) → None + +# Eventos posibles: +{ + "type": "step_start", + "step_id": "nginx_install", + "label": "Instalando Nginx y Certbot..." +} + +{ + "type": "output", + "step_id": "nginx_install", + "line": "Reading package lists... Done" +} + +{ + "type": "step_done", + "step_id": "nginx_install", + "duration_ms": 4320 +} + +{ + "type": "step_error", + "step_id": "nginx_install", + "error": "E: Package 'nginx' has no installation candidate", + "hint": "Verificá que el servidor tenga acceso a internet y que los repositorios estén actualizados." +} +``` + +### Pasos de instalación base (Modo A) + +Ejecutar en este orden exacto. Si algún paso falla, detenerse y reportar el error con `step_error`. + +#### Paso A1: `apt_update` — Actualizar repositorios + +```bash +# Comando a ejecutar (con la contraseña sudo del operador): +echo '{SUDO_PASSWORD}' | sudo -S apt-get update -y +``` + +- Label para el frontend: `"Actualizando repositorios del sistema..."` +- Timeout: 120 segundos +- Si falla: hint = `"Verificá que el servidor tenga conexión a internet."` + +#### Paso A2: `install_packages` — Instalar Nginx y Certbot + +```bash +echo '{SUDO_PASSWORD}' | sudo -S apt-get install -y nginx certbot python3-certbot-nginx +``` + +- Label: `"Instalando Nginx y Certbot..."` +- Timeout: 300 segundos (puede tardar mucho descargando paquetes) +- Si falla: hint = `"Intentá correr manualmente: sudo apt-get install nginx certbot python3-certbot-nginx"` + +#### Paso A3: `create_dirs` — Crear estructura de carpetas + +```bash +echo '{SUDO_PASSWORD}' | sudo -S mkdir -p /opt/pymesbot/nginx/conf.d +echo '{SUDO_PASSWORD}' | sudo -S mkdir -p /opt/pymesbot/scripts +echo '{SUDO_PASSWORD}' | sudo -S chmod 755 /opt/pymesbot +``` + +- Label: `"Creando estructura de carpetas /opt/pymesbot..."` +- Timeout: 10 segundos +- Idempotente: `mkdir -p` no falla si ya existe + +#### Paso A4: `create_docker_network` — Crear red Docker + +```bash +docker network create pymesbot_net +``` + +**IMPORTANTE:** Este comando puede fallar con "network already exists". Eso NO es un error. Si falla con ese mensaje exacto, marcar como `step_done` igualmente. + +- Label: `"Creando red Docker pymesbot_net..."` +- Timeout: 15 segundos + +#### Paso A5: `nginx_base_config` — Configurar Nginx base + +Escribir el archivo `/etc/nginx/nginx.conf` base si no existe o si el existente no tiene la directiva `include /etc/nginx/conf.d/*.conf`. + +```bash +# Verificar si ya tiene el include +grep -q "include /etc/nginx/conf.d/" /etc/nginx/nginx.conf +``` + +Si ya lo tiene: marcar como done sin hacer nada. +Si no lo tiene: agregar la línea al bloque `http {}`. + +Después: +```bash +echo '{SUDO_PASSWORD}' | sudo -S nginx -t +echo '{SUDO_PASSWORD}' | sudo -S systemctl enable nginx +echo '{SUDO_PASSWORD}' | sudo -S systemctl start nginx +``` + +- Label: `"Configurando Nginx base..."` +- Si `nginx -t` falla: `step_error` con el output del error como hint + +#### Paso A6: Continuar con Modo B + +Una vez terminada la instalación base, continuar directamente con el flujo de alta del primer cliente (llama a `client_setup.py`). + +--- + +## 8. Módulo client_setup.py — alta de cliente + +Este módulo da de alta un nuevo cliente. Se usa en Modo B y en la segunda parte del Modo A. + +### Input: clase `ClientConfig` + +```python +@dataclass +class ClientConfig: + slug: str # "castillo" — solo minúsculas, guiones, sin espacios + nombre: str # "Librería Castillo" + email: str # "dueño@gmail.com" + rubro: str # "libreria" | "kiosco" | "bazar" | "otro" + moneda: str # "ARS" | "USD" | "UYU" + dominio_base: str # "rvconsultas.com" + anthropic_api_key: str # "sk-ant-..." + sudo_password: str # contraseña sudo del operador (nunca loguear) + puerto: int # asignado por detect.py, ej: 8201 + admin_password: str # generada aleatoriamente + vendor_pin: str # generada aleatoriamente (4 dígitos) +``` + +### Función principal: `async def setup_cliente(config: ClientConfig, progress_callback)` + +Ejecutar estos pasos en orden. Si alguno falla, ejecutar rollback. + +#### Paso C1: `create_client_dirs` — Crear carpetas del cliente + +```bash +mkdir -p /opt/pymesbot/{slug}/picoclaw +mkdir -p /opt/pymesbot/{slug}/backend/templates +mkdir -p /opt/pymesbot/{slug}/data/uploads +``` + +- Label: `"Creando estructura de carpetas del cliente..."` +- Idempotente: `mkdir -p` no falla si ya existe + +#### Paso C2: `generate_configs` — Generar archivos de configuración + +Llamar a `templates_engine.py` para generar: + +1. `/opt/pymesbot/{slug}/.env` +2. `/opt/pymesbot/{slug}/docker-compose.yml` +3. `/opt/pymesbot/{slug}/picoclaw/config.json` + +- Label: `"Generando archivos de configuración..."` +- Si falla la escritura: verificar permisos de la carpeta + +#### Paso C3: `init_database` — Inicializar la base de datos SQLite + +Copiar el archivo `schema.sql` desde `app/config_templates/schema.sql` y ejecutarlo: + +```bash +sqlite3 /opt/pymesbot/{slug}/data/stock.db < /installer/app/config_templates/schema.sql +``` + +Después de crear la DB, insertar la configuración inicial del negocio: + +```sql +UPDATE config SET valor = '{nombre}' WHERE clave = 'nombre_negocio'; +UPDATE config SET valor = '{rubro}' WHERE clave = 'rubro'; +UPDATE config SET valor = '{moneda}' WHERE clave = 'moneda'; +UPDATE config SET valor = '{admin_password}' WHERE clave = 'admin_password'; +UPDATE config SET valor = '{vendor_pin}' WHERE clave = 'vendedor_pin'; +``` + +- Label: `"Inicializando base de datos SQLite..."` +- Timeout: 10 segundos + +#### Paso C4: `copy_backend` — Copiar el código del backend + +El instalador lleva consigo el código del backend de PymesBot dentro de la imagen Docker. +Copiar desde `/installer/pymesbot-backend/` a `/opt/pymesbot/{slug}/backend/`. + +```bash +cp -r /installer/pymesbot-backend/. /opt/pymesbot/{slug}/backend/ +``` + +**NOTA PARA LA IA:** El proyecto `pymesbot-backend` es un directorio incluido en la imagen del instalador. Contiene el código FastAPI del backend de PymesBot (el que se describe en `01_PYMESBOT_PROJECT_SPEC.md`). Al construir la imagen del instalador, este código debe estar incluido. + +- Label: `"Copiando código del backend..."` + +#### Paso C5: `configure_nginx` — Configurar Nginx para el subdominio + +1. Generar el archivo de configuración usando el template `nginx_site.conf.j2` +2. Escribirlo en `/etc/nginx/conf.d/{slug}.conf` +3. Validar: `nginx -t` +4. Recargar: `systemctl reload nginx` + +```bash +echo '{SUDO_PASSWORD}' | sudo -S nginx -t +echo '{SUDO_PASSWORD}' | sudo -S systemctl reload nginx +``` + +- Label: `"Configurando Nginx para {slug}.{dominio}..."` +- Si `nginx -t` falla: reportar el error exacto de nginx como hint + +#### Paso C6: `emit_ssl` — Emitir certificado SSL + +```bash +echo '{SUDO_PASSWORD}' | sudo -S certbot --nginx \ + -d {slug}.{dominio_base} \ + --non-interactive \ + --agree-tos \ + -m instalador@rvconsultas.com +``` + +- Label: `"Emitiendo certificado SSL para {slug}.{dominio_base}..."` +- Timeout: 120 segundos +- Si falla con "DNS problem": hint = `"Verificá que el registro DNS A de {slug}.{dominio_base} apunte a la IP de este servidor ({ip_publica}) y que haya propagado. Podés verificarlo en https://dnschecker.org"` +- Si falla con "too many certificates": hint = `"Let's Encrypt tiene un límite de certificados por semana. Esperá unos días o usá un subdominio diferente."` +- **Este paso es el que más puede fallar.** Manejar todos los errores posibles de certbot. + +#### Paso C7: `docker_up` — Levantar los contenedores + +```bash +docker compose -f /opt/pymesbot/{slug}/docker-compose.yml up -d --build +``` + +- Label: `"Levantando contenedores Docker..."` +- Timeout: 300 segundos (puede tardar si tiene que construir la imagen) +- `--build` fuerza rebuild si el código cambió + +#### Paso C8: `health_check` — Verificar que el cliente esté vivo + +Hacer GET a `https://{slug}.{dominio_base}/health` con reintentos: + +```python +for intento in range(1, 6): # 5 intentos + await asyncio.sleep(10) # esperar 10 segundos entre intentos + try: + response = await httpx.get(f"https://{slug}.{dominio_base}/health", timeout=10) + if response.status_code == 200: + # ¡Éxito! + break + except: + pass + # Si llegamos al intento 5 y falló: step_error +``` + +- Label: `"Verificando que el cliente esté respondiendo..."` +- Si falla después de 5 intentos: hint = `"Los contenedores levantaron pero el servicio no responde. Revisá los logs: docker logs pymesbot_{slug}"` + +#### Paso C9: `generate_credentials` — Guardar credenciales + +Guardar en `/opt/pymesbot/{slug}/credentials.json`: + +```json +{ + "url": "https://castillo.rvconsultas.com", + "admin_user": "admin", + "admin_password": "Xk7mQ2p", + "vendor_pin": "4821", + "instalado_el": "2026-02-14T15:30:00", + "cliente": "Librería Castillo" +} +``` + +- Label: `"Guardando credenciales..."` +- **Nunca loguear el contenido de este archivo** + +### Rollback ante fallo + +Si cualquier paso de C1 a C9 falla: + +```python +async def rollback_cliente(slug: str, sudo_password: str, progress_callback): + """ + Intenta deshacer todo lo que se hizo para el cliente. + No lanza excepciones aunque el rollback falle. + """ + # 1. Bajar los contenedores si están corriendo + await run_cmd(f"docker compose -f /opt/pymesbot/{slug}/docker-compose.yml down 2>/dev/null") + + # 2. Eliminar la carpeta del cliente + await run_cmd(f"echo '{sudo_password}' | sudo -S rm -rf /opt/pymesbot/{slug}") + + # 3. Eliminar el archivo de Nginx + await run_cmd(f"echo '{sudo_password}' | sudo -S rm -f /etc/nginx/conf.d/{slug}.conf") + + # 4. Recargar Nginx + await run_cmd(f"echo '{sudo_password}' | sudo -S systemctl reload nginx 2>/dev/null") + + # 5. Notificar al frontend + await progress_callback({ + "type": "rollback_done", + "message": f"Se revirtieron todos los cambios para el cliente '{slug}'." + }) +``` + +--- + +## 9. Módulo templates_engine.py — generador de configs + +Genera todos los archivos de configuración usando Jinja2. + +```python +# app/templates_engine.py + +from jinja2 import Environment, FileSystemLoader +import os + +TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "config_templates") +env = Environment(loader=FileSystemLoader(TEMPLATES_DIR)) + +def generar_env_cliente(config: ClientConfig) -> str: + """Genera el contenido del archivo .env del cliente""" + template = env.get_template("env_client.j2") + return template.render( + anthropic_api_key=config.anthropic_api_key, + cliente_nombre=config.nombre, + cliente_id=config.slug, + puerto=config.puerto, + admin_password=config.admin_password, + vendor_pin=config.vendor_pin, + dominio_base=config.dominio_base + ) + +def generar_docker_compose_cliente(config: ClientConfig) -> str: + """Genera el docker-compose.yml del cliente""" + template = env.get_template("docker-compose.client.yml.j2") + return template.render(slug=config.slug) + +def generar_picoclaw_config(config: ClientConfig) -> str: + """Genera el config.json de PicoClaw con el system prompt""" + template = env.get_template("picoclaw_config.json.j2") + return template.render( + nombre_negocio=config.nombre, + rubro=config.rubro + ) + +def generar_nginx_conf(config: ClientConfig) -> str: + """Genera el bloque de Nginx para el subdominio del cliente""" + template = env.get_template("nginx_site.conf.j2") + return template.render( + slug=config.slug, + dominio=config.dominio_base, + puerto=config.puerto + ) + +def escribir_archivo(ruta: str, contenido: str) -> None: + """Escribe el contenido en la ruta especificada. Crea directorios si no existen.""" + os.makedirs(os.path.dirname(ruta), exist_ok=True) + with open(ruta, "w", encoding="utf-8") as f: + f.write(contenido) +``` + +--- + +## 10. Backend FastAPI — rutas y WebSocket + +### Archivo principal: `app/main.py` + +```python +# app/main.py + +import asyncio +import secrets +import os +import time +from fastapi import FastAPI, WebSocket, HTTPException, Request, Depends +from fastapi.staticfiles import StaticFiles +from fastapi.responses import HTMLResponse, FileResponse, JSONResponse +from pydantic import BaseModel +from typing import Optional + +from .detect import detectar_entorno +from .installer import instalar_infraestructura +from .client_setup import setup_cliente, ClientConfig, rollback_cliente +from .security import validar_sudo, generar_credenciales, INSTALL_TOKEN +from .state import get_session, save_session + +app = FastAPI(title="PymesBot Installer", docs_url=None, redoc_url=None) + +# Montar archivos estáticos +app.mount("/static", StaticFiles(directory="static"), name="static") + +# ── TOKEN DE SEGURIDAD ──────────────────────────────────────────────────────── + +@app.on_event("startup") +async def startup(): + # Imprimir el token en la terminal para que el operador lo vea + print("\n" + "="*60) + print(f" 🔐 TOKEN DE ACCESO: {INSTALL_TOKEN}") + print(f" 🌐 URL: http://TU-SERVIDOR/install?token={INSTALL_TOKEN}") + print("="*60 + "\n") + # También guardar en un archivo para referencia + with open("/tmp/install_token.txt", "w") as f: + f.write(INSTALL_TOKEN) + + +# ── RUTAS DEL WIZARD ───────────────────────────────────────────────────────── + +def verificar_token(request: Request): + """Dependencia de FastAPI: verifica el token en query param o en cookie""" + token = request.query_params.get("token") or request.cookies.get("install_token") + if token != INSTALL_TOKEN: + raise HTTPException(status_code=404, detail="No encontrado") + return token + + +@app.get("/install") +async def wizard_html(token: str = Depends(verificar_token)): + """Sirve el HTML del wizard""" + return FileResponse("static/index.html") + + +@app.get("/install/detect") +async def api_detect(token: str = Depends(verificar_token)): + """Detecta el estado del servidor y devuelve el resultado como JSON""" + result = await detectar_entorno() + return result + + +@app.post("/install/validate-sudo") +async def api_validate_sudo(body: dict, token: str = Depends(verificar_token)): + """ + Valida la contraseña sudo. + Body: { "password": "..." } + Response: { "ok": true } | { "ok": false, "error": "..." } + """ + password = body.get("password", "") + if not password: + return JSONResponse({"ok": False, "error": "Contraseña vacía"}) + + ok, error = await validar_sudo(password) + if ok: + # Guardar en la sesión (en memoria, nunca en disco) + save_session("sudo_password", password) + return {"ok": True} + else: + return JSONResponse({"ok": False, "error": error}) + + +@app.post("/install/check-slug") +async def api_check_slug(body: dict, token: str = Depends(verificar_token)): + """ + Verifica si un slug está disponible. + Body: { "slug": "castillo" } + Response: { "disponible": true } | { "disponible": false, "razon": "..." } + """ + slug = body.get("slug", "").strip().lower() + + # Validar formato + import re + if not re.match(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$', slug) or len(slug) < 3: + return {"disponible": False, "razon": "El slug debe tener al menos 3 caracteres, solo minúsculas, números y guiones."} + + # Verificar que no existe + if os.path.exists(f"/opt/pymesbot/{slug}"): + return {"disponible": False, "razon": f"Ya existe un cliente con el slug '{slug}'."} + + return {"disponible": True} + + +@app.post("/install/start") +async def api_start_installation(body: dict, token: str = Depends(verificar_token)): + """ + Inicia la instalación. Guarda la config en sesión y devuelve el session_id para el WebSocket. + Body: ClientConfig completo + Response: { "session_id": "abc123" } + """ + sudo_password = get_session("sudo_password") + if not sudo_password: + raise HTTPException(status_code=400, detail="Sesión expirada. Volvé al paso de autenticación.") + + # Generar credenciales aleatorias + admin_password = secrets.token_urlsafe(8) + vendor_pin = str(secrets.randbelow(9000) + 1000) # 4 dígitos, no empieza con 0 + + config = ClientConfig( + slug=body["slug"], + nombre=body["nombre"], + email=body.get("email", ""), + rubro=body.get("rubro", "libreria"), + moneda=body.get("moneda", "ARS"), + dominio_base=body["dominio_base"], + anthropic_api_key=body["anthropic_api_key"], + sudo_password=sudo_password, + puerto=body["puerto"], # asignado por el frontend (viene del detect) + admin_password=admin_password, + vendor_pin=vendor_pin + ) + + session_id = secrets.token_hex(8) + save_session(session_id, { + "config": config, + "modo": body.get("modo", "B"), + "status": "pending" + }) + + return {"session_id": session_id} + + +@app.websocket("/install/ws/{session_id}") +async def ws_progress(websocket: WebSocket, session_id: str): + """ + WebSocket de progreso. El frontend se conecta acá para recibir eventos en tiempo real. + """ + await websocket.accept() + + session = get_session(session_id) + if not session: + await websocket.send_json({"type": "error", "error": "Sesión no encontrada"}) + await websocket.close() + return + + config = session["config"] + modo = session["modo"] + + async def progress_callback(evento: dict): + """Envía un evento al frontend via WebSocket""" + try: + await websocket.send_json(evento) + except: + pass # Si el WebSocket se cerró, ignorar + + try: + # Modo A: primero instalar infraestructura base, luego el cliente + if modo == "A": + await instalar_infraestructura(config, progress_callback) + + # Siempre dar de alta el cliente (tanto Modo A como Modo B) + await setup_cliente(config, progress_callback) + + # Enviar evento final de éxito + await websocket.send_json({ + "type": "done", + "url": f"https://{config.slug}.{config.dominio_base}", + "credentials": { + "admin_user": "admin", + "admin_password": config.admin_password, + "vendor_pin": config.vendor_pin, + "cliente": config.nombre + } + }) + + except Exception as e: + # Error inesperado: intentar rollback y notificar + await progress_callback({ + "type": "fatal_error", + "error": str(e), + "hint": "Error inesperado. Revisá los logs del instalador." + }) + # Intentar rollback + try: + await rollback_cliente(config.slug, config.sudo_password, progress_callback) + except: + pass + finally: + await websocket.close() +``` + +--- + +## 11. El wizard HTML — especificación completa + +El wizard es un único archivo HTML (`static/index.html`). Contiene HTML, CSS y JavaScript. + +### Diseño visual + +**Paleta de colores:** +```css +--bg: #080E1A; /* fondo principal */ +--bg2: #0D1526; /* fondo secundario (sidebar, header) */ +--bg3: #111D33; /* fondo terciario (cards, inputs) */ +--border: #1E2D4A; /* bordes */ +--accent: #00D4FF; /* azul cian — color principal */ +--accent2: #00FF9D; /* verde — éxito */ +--warn: #FFB800; /* amarillo — advertencia */ +--err: #FF4757; /* rojo — error */ +--text: #C8D8F0; /* texto principal */ +--text2: #6B89B0; /* texto secundario */ +``` + +**Fuentes:** +```html + + + +/* Body: Space Grotesk */ +/* Código, slugs, terminales: IBM Plex Mono */ +``` + +### Layout general + +``` +┌─────────────────────────────────────────────┐ +│ ⚙️ PymesBot / installer v1.0.0 │ ← header fijo +├────────────────┬────────────────────────────┤ +│ │ │ +│ SIDEBAR │ CONTENIDO DEL PASO │ +│ (240px) │ (flexible) │ +│ │ │ +│ ○ Paso 1 │ │ +│ ○ Paso 2 │ │ +│ ● Paso 3 ◄ │ │ +│ ○ Paso 4 │ │ +│ ○ Paso 5 │ │ +│ ○ Paso 6 │ │ +│ │ │ +└────────────────┴────────────────────────────┘ +``` + +Los pasos completados tienen ícono ✓ verde. El paso actual tiene una línea de color en el borde izquierdo y el texto en blanco. Los pasos futuros tienen el número y texto en gris. + +### PASO 0 — Detección del entorno + +**Trigger:** se ejecuta automáticamente al cargar la página (si el token es válido). + +**Mientras carga:** +- Spinner animado en el centro +- Texto: `"Analizando el servidor..."` + +**Cuando termina:** +- Grid 2x3 de tarjetas, una por componente detectado: + - Docker ✅ v24.0.7 / ❌ no encontrado + - Nginx ✅ / ❌ + - Certbot ✅ / ❌ + - /opt/pymesbot ✅ existe / ❌ no existe + - Red Docker pymesbot_net ✅ / ❌ + - Clientes activos: `N instancias` + +- Caja de modo detectado (color según el modo): + - **Modo A** (fondo verde oscuro): `"VPS limpia detectada. Se instalará la infraestructura base y luego el primer cliente."` + - **Modo B** (fondo azul oscuro): `"Infraestructura detectada. N clientes activos. Se dará de alta un cliente nuevo."` + +- Botón `[Continuar →]` + +**API call:** `GET /install/detect` + +--- + +### PASO 1 — Autenticación sudo + +**Campos:** +- Input `type="password"` con label `"Contraseña sudo del servidor"` +- Texto de ayuda: `"La contraseña se usa solo durante esta sesión para configurar el servidor. No se guarda en ningún archivo."` + +**Al hacer click en "Verificar permisos":** +1. Deshabilitar el botón +2. Mostrar spinner en el botón +3. `POST /install/validate-sudo` con `{ password: "..." }` +4. Si responde `{ ok: true }`: avanzar al paso 2 +5. Si responde `{ ok: false }`: mostrar el mensaje de error debajo del input, habilitar el botón + +--- + +### PASO 2 — Datos del servidor (solo Modo A) + +**Este paso aparece SOLO si el modo detectado es A.** +Si es Modo B, saltar directo al paso 3. + +**Campos:** +| Campo | Tipo | Placeholder | Validación | +|-------|------|-------------|-----------| +| Dominio raíz | text | `rvconsultas.com` | Formato de dominio válido, sin `http://` ni `/` | +| Email SSL | email | `admin@rvconsultas.com` | Email válido | +| API Key Anthropic | password | `sk-ant-...` | Debe empezar con `sk-ant-` | + +**Al continuar:** guardar los valores en memoria del wizard (variables JS). + +--- + +### PASO 3 — Datos del cliente nuevo + +**Campos:** + +**Nombre del negocio** (text, requerido) +- Al escribir: auto-generar el slug en tiempo real (ver lógica abajo) + +**Slug** (text, requerido) +- Se auto-llena mientras el usuario escribe el nombre +- Editable manualmente +- Al perder el foco (onblur): `POST /install/check-slug` con el slug +- Mostrar debajo: `castillo.rvconsultas.com ✓ disponible` (verde) o `❌ ya existe` (rojo) + +**Lógica de auto-generación del slug (JS):** +```javascript +function nombreASlug(nombre) { + return nombre + .toLowerCase() + .replace(/[áàä]/g, 'a').replace(/[éèë]/g, 'e') + .replace(/[íìï]/g, 'i').replace(/[óòö]/g, 'o') + .replace(/[úùü]/g, 'u').replace(/ñ/g, 'n') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} +``` + +**Rubro** (select) +- Librería / Papelería +- Kiosco +- Bazar +- Otro + +**Moneda** (select) +- 🇦🇷 ARS — Peso argentino +- 🇺🇸 USD — Dólar +- 🇺🇾 UYU — Peso uruguayo + +**Email del dueño** (email, opcional) +- Texto de ayuda: `"Se envían las credenciales de acceso a esta dirección."` + +--- + +### PASO 4 — Confirmación antes de ejecutar + +**Muestra un resumen de acciones:** + +Lista numerada de las acciones que se van a realizar (adaptada según el modo): + +**Modo A:** +1. Actualizar repositorios del sistema +2. Instalar Nginx y Certbot +3. Crear estructura /opt/pymesbot/ +4. Crear red Docker pymesbot_net +5. Crear estructura del cliente '{nombre}' +6. Generar archivos de configuración +7. Inicializar base de datos SQLite +8. Configurar subdominio Nginx ({slug}.{dominio}) +9. Emitir certificado SSL (requiere que el DNS ya apunte a este servidor) +10. Levantar contenedores Docker +11. Verificar que el servicio responde + +**Modo B:** solo los pasos 5 en adelante. + +**Checkbox:** `☐ Entiendo que esta operación modificará la configuración del servidor` + +**Botón "Ejecutar":** deshabilitado hasta que el checkbox esté marcado. Al hacer click: +1. `POST /install/start` con toda la config +2. Si responde con `session_id`: avanzar al paso 5 y conectar el WebSocket + +--- + +### PASO 5 — Ejecución con progreso en tiempo real + +**Barra de progreso** (0% → 100%, animada) + +**Lista de pasos** — se van actualizando en tiempo real con el WebSocket: +- ⬜ paso pendiente (gris) +- 🔄 paso en ejecución (ícono girando animado) +- ✅ paso completado (verde + duración en ms) +- ❌ paso con error (rojo + mensaje de error expandible + hint) + +**Panel de output** (colapsable, debajo de cada paso): +- Fondo oscuro tipo terminal +- Muestra el output raw del comando (los eventos `type: "output"`) +- El usuario puede hacer click en "ver detalles" para expandirlo + +**Si hay un error:** +- El paso se marca con ❌ +- Aparece el mensaje de error y el hint en rojo +- Aparece un botón `[Reintentar desde este paso]` +- La barra de progreso se detiene + +**WebSocket:** +```javascript +const ws = new WebSocket(`ws://${location.host}/install/ws/${sessionId}`); + +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + switch(data.type) { + case 'step_start': // mostrar paso como 🔄 + case 'output': // agregar línea al panel de output de ese paso + case 'step_done': // marcar como ✅ con la duración + case 'step_error': // marcar como ❌, mostrar error y hint + case 'done': // avanzar automáticamente al paso 6 + case 'fatal_error': // mostrar error crítico con opción de reintentar todo + } +}; +``` + +--- + +### PASO 6 — Resumen final (éxito) + +**Animación de entrada:** el número del check animado con un efecto de escala + +**Contenido:** +- `🎉` grande animado +- `"¡{NOMBRE} está activo!"` +- Link clickeable: `https://{slug}.{dominio}` (se abre en nueva pestaña) + +**Tarjetas de credenciales** (3 en fila): +- Usuario admin: `admin` +- Contraseña: `Xk7mQ2p` (con botón de copiar individual) +- PIN vendedor: `4821` + +**Advertencia destacada** (fondo amarillo): +``` +⚠️ Cerrá el instalador cuando termines: +docker compose down +``` + +**Botones:** +- `[📋 Copiar todas las credenciales]` → copia un texto formateado al portapapeles +- `[📧 Enviar por email]` → si se configuró un email en el paso 3 (sino, deshabilitado) +- `[⬇️ Descargar resumen]` → descarga un `.txt` con las credenciales + +**Al fondo (separado):** +- `[➕ Dar de alta otro cliente]` → vuelve al paso 3 con todo limpio + +--- + +## 12. Templates Jinja2 que genera el instalador + +### `env_client.j2` + +```jinja2 +# Generado automáticamente por PymesBot Installer +# No editar manualmente. Regenerar con el instalador. + +ANTHROPIC_API_KEY={{ anthropic_api_key }} +CLIENTE_NOMBRE={{ cliente_nombre }} +CLIENTE_ID={{ cliente_id }} +PUERTO={{ puerto }} +ADMIN_PASSWORD={{ admin_password }} +VENDOR_PIN={{ vendor_pin }} +DOMINIO_BASE={{ dominio_base }} +``` + +### `docker-compose.client.yml.j2` + +```jinja2 +version: '3.8' + +services: + + backend_{{ slug }}: + build: + context: ./backend + dockerfile: Dockerfile + container_name: pymesbot_{{ slug }} + restart: always + ports: + - "${PUERTO}:8000" + volumes: + - ./data:/app/data + env_file: + - .env + networks: + - pymesbot_net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + + picoclaw_{{ slug }}: + image: ghcr.io/sipeed/picoclaw:latest + container_name: picoclaw_{{ slug }} + restart: always + volumes: + - ./picoclaw:/root/.picoclaw + - ./data:/data:ro + depends_on: + backend_{{ slug }}: + condition: service_healthy + networks: + - pymesbot_net + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + +networks: + pymesbot_net: + external: true +``` + +### `picoclaw_config.json.j2` + +```jinja2 +{ + "agents": { + "defaults": { + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 1000, + "temperature": 0.3, + "max_tool_iterations": 5, + "system": "Sos el asistente de ventas de {{ nombre_negocio }}, una {{ rubro }} en Argentina.\nTu trabajo es ayudar al vendedor a atender clientes rápido y con información exacta.\n\nHERRAMIENTAS DISPONIBLES:\n- buscar_stock: busca productos por nombre\n- confirmar_venta: registra una venta y descuenta el stock\n- armar_combo: genera lista de productos para un presupuesto\n- ver_promociones: muestra las promos activas de hoy\n\nREGLAS OBLIGATORIAS:\n1. NUNCA des precios aproximados. Siempre usá buscar_stock.\n2. Si no hay stock, ofrecé siempre la alternativa más cercana.\n3. Al armar combos: preguntá presupuesto, edad, género (optativo).\n4. Al final de cada consulta: preguntá si se concretó la venta.\n5. Si confirman la venta: llamá a confirmar_venta inmediatamente.\n6. Respondé en español argentino coloquial pero profesional.\n7. Sé conciso: máximo 3-4 líneas por respuesta.\n8. Nunca menciones otros negocios ni competidores." + } + }, + "providers": { + "anthropic": { + "api_key": "${ANTHROPIC_API_KEY}" + } + } +} +``` + +### `nginx_site.conf.j2` + +```jinja2 +server { + listen 80; + server_name {{ slug }}.{{ dominio }}; + + location / { + proxy_pass http://127.0.0.1:{{ puerto }}; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } +} +``` + +### `schema.sql` (no es template Jinja2, es SQL puro) + +Ver la sección 7 del documento `01_PYMESBOT_PROJECT_SPEC.md` para el SQL completo. + +--- + +## 13. Seguridad + +### Token de acceso (`app/security.py`) + +```python +# app/security.py +import secrets +import os + +# Generar token al importar el módulo (una vez al arrancar el contenedor) +INSTALL_TOKEN = secrets.token_urlsafe(16) + +async def validar_sudo(password: str) -> tuple[bool, str]: + """ + Verifica que la contraseña sudo sea correcta. + Usa 'sudo -n' primero para no lockear la cuenta. + Returns: (ok: bool, error_msg: str) + """ + from .detect import run_cmd + + # Verificar con un comando inofensivo: whoami + rc, stdout, stderr = await run_cmd( + f"echo '{password}' | sudo -S whoami", + timeout=10 + ) + + if rc == 0 and stdout.strip() == "root": + return True, "" + else: + if "incorrect password" in stderr.lower() or "no passwd" in stderr.lower(): + return False, "Contraseña incorrecta" + elif "not in the sudoers" in stderr.lower(): + return False, "Este usuario no tiene permisos sudo" + else: + return False, f"Error al verificar: {stderr.strip()}" +``` + +### Estado de sesión (`app/state.py`) + +```python +# app/state.py +# Estado en memoria. Se pierde al reiniciar el contenedor. +# NUNCA persistir la contraseña sudo en disco. + +import time +from typing import Any, Optional + +_store: dict = {} +_timestamps: dict = {} +SESSION_TIMEOUT = 1800 # 30 minutos + +def save_session(key: str, value: Any) -> None: + _store[key] = value + _timestamps[key] = time.time() + +def get_session(key: str) -> Optional[Any]: + if key not in _store: + return None + # Verificar expiración + if time.time() - _timestamps[key] > SESSION_TIMEOUT: + del _store[key] + del _timestamps[key] + return None + return _store[key] + +def clear_session(key: str) -> None: + _store.pop(key, None) + _timestamps.pop(key, None) +``` + +### Reglas de seguridad del instalador + +1. **La contraseña sudo NUNCA se loguea.** Ni en `print()`, ni en logs de uvicorn, ni en archivos. Usar `{SUDO_PASSWORD}` en comandos sin loguear la variable. +2. **El token de acceso se regenera cada vez que el contenedor arranca.** No hay forma de reutilizar un token viejo. +3. **Sin token, `/install` devuelve 404**, no 401 ni 403. No revelar que existe el endpoint. +4. **El instalador no escucha en puertos que no sea el 80.** Nginx del host tampoco debe proxear el puerto del instalador una vez que termina. +5. **`restart: "no"` en el docker-compose.** El instalador no se reinicia solo. +6. **No hay endpoint de API sin verificación de token.** Absolutamente todos los endpoints verifican el token. + +--- + +## 14. Testing y validación + +### Cómo testear el instalador localmente + +Para testear sin un servidor real, usar Docker-in-Docker: + +```bash +# Levantar un Ubuntu limpio con Docker disponible +docker run -it --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -p 8080:8000 \ + ubuntu:24.04 bash + +# Dentro del contenedor: instalar Docker y Python +apt-get update && apt-get install -y docker.io python3 python3-pip curl + +# Simular que es un VPS limpio +# (docker ya está, nginx y certbot no) + +# Desde afuera: levantar el instalador apuntando al Ubuntu +docker compose up -d +# Entrar a http://localhost:8080/install?token=... +``` + +### Checklist de validación antes de marcar como completo + +**Detección:** +- [ ] Detecta correctamente cuando Docker no está instalado +- [ ] Detecta correctamente cuando Nginx sí está instalado +- [ ] Lista correctamente los clientes existentes en /opt/pymesbot +- [ ] Determina el modo correcto (A, B o C) +- [ ] Encuentra el próximo puerto disponible correctamente + +**Wizard UI:** +- [ ] El spinner de detección aparece y desaparece +- [ ] Los 6 pasos del sidebar se marcan correctamente +- [ ] El auto-complete del slug funciona con acentos y ñ +- [ ] La verificación de slug disponible funciona en tiempo real +- [ ] El checkbox de confirmación habilita el botón de ejecutar +- [ ] El WebSocket muestra el progreso en tiempo real +- [ ] Los errores de steps muestran el hint correcto +- [ ] Las credenciales se muestran en el paso 6 + +**Seguridad:** +- [ ] Sin token en la URL, `/install` devuelve 404 +- [ ] Con token incorrecto, `/install` devuelve 404 +- [ ] La contraseña sudo no aparece en los logs de uvicorn +- [ ] La contraseña sudo no se guarda en ningún archivo + +**Idempotencia:** +- [ ] Correr el instalador dos veces para el mismo slug falla graciosamente con Modo C +- [ ] Crear la red Docker cuando ya existe no falla +- [ ] `mkdir -p` no falla si la carpeta ya existe + +--- + +## 15. Orden de construcción recomendado + +Construir en este orden exacto. Cada paso debe funcionar antes de seguir con el siguiente. + +### Sprint 1: Fundación (empezar acá) + +1. **`detect.py`**: implementar `detectar_entorno()` y `run_cmd()`. Testear localmente que detecta bien Docker y Nginx. +2. **`main.py` esqueleto**: FastAPI con las rutas `/install`, `/install/detect`, y el token de seguridad. Probar que sin token devuelve 404. +3. **`static/index.html`** paso 0: solo el paso de detección. Que llame a `/install/detect` y muestre las tarjetas. Sin el resto del wizard. + +### Sprint 2: Wizard completo (sin ejecutar nada real) + +4. Completar los **6 pasos del wizard** con datos hardcodeados o mock. El wizard debe navegar entre pasos, validar campos, mostrar/ocultar según el modo. +5. Conectar el **WebSocket** de progreso con datos mock (simular que los pasos pasan de ⬜ a 🔄 a ✅ con delays). + +### Sprint 3: Backend real + +6. **`security.py`**: validación sudo real con `run_cmd`. +7. **`templates_engine.py`**: generar los 4 archivos de configuración. Testear que el output es correcto para un cliente de ejemplo. +8. **`client_setup.py`**: implementar los 9 pasos de C1 a C9. Testear en un servidor de prueba real. +9. **`installer.py`**: implementar los 5 pasos de A1 a A5. Testear en un Ubuntu limpio. + +### Sprint 4: Integración y pulido + +10. Conectar el wizard real con el backend real (reemplazar los mocks del Sprint 2). +11. Implementar el **rollback** completo. +12. Pasar el checklist de validación de la sección 14. +13. Construir la imagen Docker y publicar en el registry. + +--- + +*Fin del documento. Versión 1.0 — PymesBot Installer Spec* diff --git a/inventario_ejemplo.sql b/inventario_ejemplo.sql new file mode 100644 index 0000000..57ab279 --- /dev/null +++ b/inventario_ejemplo.sql @@ -0,0 +1,99 @@ +-- Inventario de ejemplo para PymesBot +-- Este es un archivo SQL que puede importarse a la base de datos SQLite +-- Ejecutar: sqlite3 stock.db < inventario_ejemplo.sql + +-- ═══════════════════════════════════════════════════════ +-- DATOS DE EJEMPLO: Librería "El Rincón del Saber" +-- ═══════════════════════════════════════════════════════ + +-- Productos de escritura +INSERT INTO productos (nombre, marca, categoria, precio, stock, variantes, codigo) VALUES +('Birome Bic Cristal', 'Bic', 'escritura', 850.00, 45, '{"color": ["azul", "rojo", "negro", "verde"]}', '7501031311309'), +('Birome Bic Cristal Fine', 'Bic', 'escritura', 920.00, 32, '{"color": ["azul", "negro"]}', '7501031311408'), +('Birome Faber Castell Trilux', 'Faber Castell', 'escritura', 780.00, 28, '{"color": ["azul", "rojo", "negro"]}', '7891360302456'), +('Lápiz Grafito HB', 'Faber Castell', 'escritura', 350.00, 120, '{"talle": ["standard", "jumbo"]}', '7891360301121'), +('Marcador Permanente Sharpie', 'Sharpie', 'escritura', 1200.00, 18, '{"color": ["negro", "azul", "rojo"]}', '071641005024'), +('Corrector Líquido Paper Mate', 'Paper Mate', 'escritura', 650.00, 24, NULL, '071641023456'), +('Goma de Borrar Staedtler', 'Staedtler', 'escritura', 480.00, 56, NULL, '4007817331515'), +('Marcadores de Colores', 'Faber Castell', 'escritura', 2800.00, 15, '{"cantidad": ["12 colores", "24 colores"]}', '7891360306782'), +('Lápices de Colores Largos', 'Maped', 'escritura', 1850.00, 22, '{"cantidad": ["12 unidades", "24 unidades"]}', '3154140256123'), +('Resaltadores Pastel', 'Stabilo', 'escritura', 1450.00, 19, '{"cantidad": ["6 unidades", "8 unidades"]}', '4006381567890'); + +-- Cuadernos y papel +INSERT INTO productos (nombre, marca, categoria, precio, stock, variantes, codigo) VALUES +('Cuaderno Rivadavia A4 Rayado', 'Rivadavia', 'cuadernos', 2500.00, 80, '{"color": ["azul", "rojo", "negro"]}', '7798021234567'), +('Cuaderno Rivadavia A4 Cuadriculado', 'Rivadavia', 'cuadernos', 2500.00, 75, '{"color": ["azul", "verde"]}', '7798021234574'), +('Cuaderno ABC A4 48h', 'ABC', 'cuadernos', 1800.00, 95, '{"tipo": ["rayado", "cuadriculado"]}', '7796543210001'), +('Cuaderno Tilibra 80h', 'Tilibra', 'cuadernos', 3200.00, 40, '{"tipo": ["rayado", "cuadriculado", "blanco"]}', '7891023345678'), +('Libreta Anotador Chico', 'Sin Marca', 'cuadernos', 450.00, 200, NULL, NULL), +('Block de Dibujo A4', 'Filgo', 'cuadernos', 2200.00, 30, '{"hojas": ["20 hojas", "40 hojas"]}', '7796578912345'), +('Papel A4 Resma (500h)', 'Chamex', 'cuadernos', 8500.00, 12, NULL, '7896321456987'), +('Papel A4 Resma (500h)', 'Catalyst', 'cuadernos', 7900.00, 15, NULL, '7897412589632'), +('Carpeta A4 2 Anillos', 'Genérica', 'cuadernos', 1200.00, 45, '{"color": ["azul", "rojo", "verde", "negro"]}', NULL), +('Separadores A4', 'Genéricos', 'cuadernos', 650.00, 38, NULL, NULL); + +-- Geometría +INSERT INTO productos (nombre, marca, categoria, precio, stock, variantes, codigo) VALUES +('Regla 30cm Plástica', 'Maped', 'geometria', 650.00, 50, '{"color": ["transparente", "azul", "rosa"]}', '3154140151234'), +('Regla 20cm Metálica', 'Maped', 'geometria', 1200.00, 25, NULL, '3154140151241'), +('Escuadra 45° + Cartabón', 'Maped', 'geometria', 1850.00, 30, NULL, '3154140256789'), +('Compás Metálico con Tiralíneas', 'Maped', 'geometria', 3500.00, 15, NULL, '3154140351234'), +('Compás Escolar Plástico', 'Genérico', 'geometria', 850.00, 40, NULL, NULL), +('Transportador 180°', 'Maped', 'geometria', 480.00, 35, NULL, '3154140451234'), +('Juego de Geometría Escolar', 'Maped', 'geometria', 2800.00, 20, NULL, '3154140551234'), +('Lapicera Tiralíneas', 'Rotring', 'geometria', 4200.00, 8, '{"tamaño": ["0.3mm", "0.5mm", "0.7mm"]}', '3501179256123'), +('Tijera Escolar Punta Roma', 'Maped', 'geometria', 950.00, 42, NULL, '3154140651234'), +('Tijera Oficina 21cm', 'Maped', 'geometria', 1850.00, 18, NULL, '3154140751234'); + +-- Artículos de arte y colores +INSERT INTO productos (nombre, marca, categoria, precio, stock, variantes, codigo) VALUES +('Plastilina 12 Colores', 'Faber Castell', 'colores', 2800.00, 25, NULL, '7891360309875'), +('Plastilina 24 Colores', 'Faber Castell', 'colores', 4500.00, 12, NULL, '7891360309882'), +('Temperas 6 Colores', 'Alba', 'colores', 1800.00, 35, '{"tamaño": ["12ml", "25ml"]}', '7798029876543'), +('Temperas 12 Colores', 'Alba', 'colores', 3200.00, 20, '{"tamaño": ["12ml", "25ml"]}', '7798029876550'), +('Pinceles Set x3', 'Alba', 'colores', 950.00, 40, '{"tamaño": ["n°4, n°6, n°8", "n°6, n°8, n°10"]}', '7798029876567'), +('Pinceles Set x5', 'Alba', 'colores', 1450.00, 28, NULL, '7798029876574'), +('Papel Glacé 10h', 'Filgo', 'colores', 650.00, 60, NULL, '7796578912346'), +('Papel Crepe', 'Filgo', 'colores', 450.00, 80, '{"color": ["rojo", "azul", "amarillo", "verde", "blanco", "negro", "rosa"]}', '7796578912347'), +('Cartulina 50x70', 'Filgo', 'colores', 180.00, 150, '{"color": ["blanco", "negro", "rojo", "azul", "verde", "amarillo"]}', '7796578912348'), +('Fibras Maped 12 unidades', 'Maped', 'colores', 1950.00, 32, NULL, '3154140851234'), +('Fibras Maped 24 unidades', 'Maped', 'colores', 3200.00, 18, NULL, '3154140951234'); + +-- Accesorios y otros +INSERT INTO productos (nombre, marca, categoria, precio, stock, variantes, codigo) VALUES +('Grapadora Chica', 'Staples', 'accesorios', 1200.00, 22, NULL, '0071641098765'), +('Grapas Caja x1000', 'Genéricas', 'accesorios', 350.00, 85, NULL, NULL), +('Sacagrapas', 'Genérico', 'accesorios', 180.00, 50, NULL, NULL), +('Engrapadora Oficina', 'Rapid', 'accesorios', 2500.00, 12, NULL, '7313460156123'), +('Cinta Adhesiva 18mm', 'Scotch', 'accesorios', 450.00, 70, '{"tipo": ["transparente", "papel marrón"]}', '0511319876543'), +('Cinta Adhesiva Doble Faz', 'Scotch', 'accesorios', 850.00, 35, NULL, '0511319876544'), +('Pegamento Barra', 'Pritt', 'accesorios', 680.00, 55, '{"tamaño": ["11g", "22g"]}', '0791469876543'), +('Pegamento Líquido Escolar', 'Pritt', 'accesorios', 520.00, 48, NULL, '0791469876544'), +('Silicona Líquida 30ml', 'Genérica', 'accesorios', 380.00, 60, NULL, NULL), +('Lápices de Cera Gruesos', 'Filgo', 'colores', 1600.00, 28, '{"cantidad": ["12 colores", "24 colores"]}', '7796578912355'); + +-- Configuración inicial del negocio +INSERT OR REPLACE INTO config (clave, valor) VALUES + ('nombre_negocio', 'Librería El Rincón del Saber'), + ('moneda', 'ARS'), + ('moneda_simbolo', '$'), + ('combo_categorias', '["escritura","cuadernos","geometria","colores"]'), + ('alerta_stock_minimo', '5'), + ('vendedor_pin', '1234'), + ('rubro', 'libreria'); + +-- Ejemplo de promociones +INSERT INTO promociones (nombre, tipo, valor, categorias, activa, fecha_inicio, fecha_fin) VALUES +('Vuelta al Cole 15% off en Cuadernos', 'descuento_pct', 15, '["cuadernos"]', 1, '2026-02-01', '2026-03-31'), +('Día del Padre - 10% off en Escritura', 'descuento_pct', 10, '["escritura"]', 0, '2026-06-15', '2026-06-16'); + +-- Notas adicionales: +-- Este inventario incluye 51 productos distribuidos en 5 categorías: +-- - escritura: 10 productos +-- - cuadernos: 10 productos +-- - geometria: 10 productos +-- - colores: 11 productos +-- - accesorios: 10 productos +-- +-- Para usar: importar este archivo a un cliente de prueba con: +-- sqlite3 /opt/pymesbot/{cliente}/data/stock.db < inventario_ejemplo.sql