commit c2ae140078d94c9e0c956b0a754ef0c9b21c1a74 Author: consultoria-as Date: Tue Jun 9 00:10:07 2026 -0700 Art4Hotel Hub: código + documentación extensiva ERP a la medida (Python stdlib + SQLite + vanilla JS SPA). Incluye server.py, index.html, utilidades y documentación: README, MODELO_DATOS, API, INSTALACION, CONTEXTO, NEGOCIO, WEB, ONBOARDING, VALOR_SISTEMA, CLAUDE. Secretos y datos (art4hotel.db, secret.key, ACCESOS.html, uploads/, backups/) excluidos vía .gitignore. Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68c296a --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# ── Secretos y datos (NUNCA subir) ── +# clave de firma de sesiones (login) +secret.key +# base de datos con datos del negocio +art4hotel.db +art4hotel.db-wal +art4hotel.db-shm +# respaldos de la DB +*.bak_* +# tarjeta con contraseñas +ACCESOS.html + +# ── Archivos generados / temporales ── +uploads/ +backups/ +__pycache__/ +*.pyc +*.log + +# ── Sistema ── +.DS_Store +Thumbs.db diff --git a/API.md b/API.md new file mode 100644 index 0000000..01187a3 --- /dev/null +++ b/API.md @@ -0,0 +1,73 @@ +# API REST — Art4Hotel Hub + +Servidor: `server.py` (Python stdlib `http.server`), puerto **4401**. +Respuestas en JSON. Autenticación por **cookie de sesión** (firmada HMAC). + +--- + +## Autenticación + +Todas las rutas requieren sesión **excepto** las públicas de auth. Sin sesión: las páginas HTML muestran el login; las rutas `/api/*` devuelven `401`. + +| Método | Ruta | Descripción | +|---|---|---| +| GET | `/api/needs-setup` | ¿Primera vez? → `{needs_setup: bool}` (pública) | +| POST | `/api/setup` | Crea la cuenta admin inicial (solo si no hay usuarios). Body: `{username, password, nombre}` | +| POST | `/api/login` | Inicia sesión. Body: `{username, password}` → set-cookie de sesión | +| POST | `/api/logout` | Cierra sesión (borra cookie) | + +- Contraseñas: hash **PBKDF2-SHA256** (200k iteraciones + salt). +- Sesión: cookie `a4h_session` firmada con HMAC (clave en `secret.key`), válida 14 días. + +--- + +## CRUD genérico + +Un solo handler maneja todas las tablas vía el dict `TABLES` en `server.py`: + +| Método | Ruta | Descripción | +|---|---|---| +| GET | `/api/{tabla}` | Lista todos los registros | +| POST | `/api/{tabla}` | Crea un registro (body = campos) | +| PUT | `/api/{tabla}/{id}` | Actualiza un registro | +| DELETE | `/api/{tabla}/{id}` | Elimina un registro | + +Tablas disponibles: `ordenes`, `oc`, `productos`, `proyectos`, `propuestas`, `catalogos`, `clientes`, `trabajos`, `modelos`, `materiales`, `inventario`, `compras`, `tareas`, `bitacora`. + +El dict define por tabla: `fields`, `int_fields`, `float_fields`, `nullable_fields` (para casteo/validación automática). + +--- + +## Endpoints especiales + +| Método | Ruta | Descripción | +|---|---|---| +| GET | `/api/dashboard` | KPIs del dashboard (stages, clientes activos, alertas) | +| GET | `/api/ventas` | Analítica de ventas: comparativo mensual, top clientes, tiempos de ciclo, pricing por producto | +| GET | `/api/entregas` | Pedidos entregados (agrupables por fecha/cliente/OC) | +| GET | `/api/oc` | OCs con sus líneas (`lineas`), totales y `progress` agregado | +| POST | `/api/oc-split/{oc_id}` | Entrega parcial: crea OC hermana con pedidos seleccionados | +| GET | `/api/file-counts` | `{entidad: {count, first_image}}` para TODAS las carpetas en 1 request. `first_image` prefiere fotos y excluye documentos | +| POST | `/api/upload/{id}?tipo=X&label=Y` | Sube archivo(s). `tipo` define el prefijo; `label` opcional como parte descriptiva del nombre | +| GET | `/api/files/{id}` | Lista archivos de una entidad | +| DELETE | `/api/files/{id}/{nombre}` | Elimina un archivo | +| POST | `/api/backup-now` | Dispara el respaldo en segundo plano | +| GET | `/api/backups` | Lista los respaldos disponibles | + +--- + +## Servir contenido + +| Ruta | Sirve | +|---|---| +| `/` o `/index.html` | La SPA (frontend completo) | +| `/uploads/{entidad}/{archivo}` | Archivos subidos (requiere sesión) | + +--- + +## Notas de implementación + +- **IDs legibles** (orden_id, oc_id) se generan con `MAX(num)+1`, no count — evita colisiones tras borrados. +- **Validación de unicidad** en `orden_id` al insertar (devuelve 409 si existe). +- **CORS**: headers `Access-Control-Allow-*` habilitados; `OPTIONS` responde preflight. +- Sin dependencias externas: todo con `http.server`, `sqlite3`, `json`, `urllib`, `hashlib`, `hmac` (stdlib). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8ab6f96 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# Art4Hotel Hub — Índice y Contexto para Claude + +> Punto de entrada. Aquí está el mapa de TODO: documentación, sitios/servicios y accesos. +> Última actualización: 2026-06-06 + +--- + +## 📚 ÍNDICE DE DOCUMENTACIÓN (qué hay y dónde) + +| Documento | Cubre | Léelo cuando… | +|---|---|---| +| **[NEGOCIO.md](./NEGOCIO.md)** | Contexto del negocio: clientes, productos, flujo real, equipo, reglas | …antes de cualquier cambio (la app refleja el negocio) | +| **[CONTEXTO.md](./CONTEXTO.md)** | Estado técnico del Hub: stack, modelo de datos, producto unificado, catálogos, galería de ejemplos, archivos, ventas | …vas a tocar código del Hub | +| **[WEB.md](./WEB.md)** | Sitio público art4hotel.com + `sync_catalogo.py` (Hub → web) | …trabajas en la página pública o el sync | +| **[ONBOARDING.md](./ONBOARDING.md)** | Handover: acceso, arquitectura, tabs del SPA, fórmula de margen | …repaso general rápido | +| **[VALOR_SISTEMA.html](./VALOR_SISTEMA.html)** | Valor/costo del sistema, hosting, esquema (para presentar a terceros) | …explicar el sistema o cotizarlo a alguien | +| **[ACCESOS.html](./ACCESOS.html)** | Tarjeta de accesos de usuario final (links de servicios) | …recordar cómo entrar a cada servicio | + +--- + +## 🌐 MAPA DE SITIOS / SERVICIOS (servidor "iclaude") + +Todo corre en el servidor **iclaude** (`192.168.50.46`, una Surface vieja). Acceso por **Tailscale** (red privada) salvo lo marcado público. + +### Proyectos web (cada uno su propio puerto, todos del usuario) +Cada sitio tiene acceso **directo por Tailscale** (`iclaude:PUERTO`, requiere Tailscale activo) y, si está en Funnel, también una **URL pública** (desde cualquier lado, sin Tailscale). + +| Puerto | Sitio | Acceso privado (Tailscale) | Acceso público (Funnel) | Notas | +|---|---|---|---|---| +| **4401** | **Art4Hotel Hub** (este proyecto) | `http://iclaude:4401` | — (privado) | Login (usuario `claude`). Operaciones/ventas/catálogo | +| **4402** | **Airbnb Pricing** | `http://iclaude:4402` | — (privado) | Panel pricing Airbnb (Hacienda Cabo Bello / Casa Montoya) | +| **4403** | **Catálogo** | `http://iclaude:4403` | `https://iclaude.tail69ab9b.ts.net:8443/` 🌍 | Login. Público vía Funnel | +| **4404** | **Portafolio** | `http://iclaude:4404` | `https://iclaude.tail69ab9b.ts.net/` 🌍 | Público vía Funnel (raíz) | + +**Cada proyecto tiene su carpeta y su propia documentación en el servidor:** +| Proyecto | Carpeta | Servicio systemd | Su doc | +|---|---|---|---| +| Hub | `/mnt/iclaude/art4hotel-hub/` | `art4hotel-hub.service` | este `CLAUDE.md` + los .md de abajo | +| Airbnb Pricing | `/mnt/iclaude/airbnb-pricing/` | `airbnb-pricing.service` | `NEGOCIO.md` | +| Catálogo | `/mnt/iclaude/catalogo-borrador/` | `catalogo-borrador.service` | `CLAUDE.md` | +| Portafolio/Foto Studio | `/mnt/iclaude/foto-studio/` | `foto-studio.service` | `CLAUDE.md` | + +Todos comparten el mismo stack (Python stdlib + SQLite + vanilla JS) y se reinician con `sudo systemctl restart `. + +### Servicios de nube personal (Docker) +| Servicio | Puerto | Acceso | Usuario | +|---|---|---|---| +| **Immich** (fotos) | 2283 | `iclaude:2283` | `claudeandrefg@gmail.com` | +| **Filebrowser** (archivos) | 8085 | `iclaude:8085` | `admin` (carpeta `/mnt/iclaude/escritorio`) | + +### Sitio público externo +| Sitio | Dónde | Notas | +|---|---|---| +| **art4hotel.com** | GitHub Pages (NO en el servidor) | Landing + catálogo + wizard de leads. Repo `Claudeandrefg/art4hotel`. Se alimenta del Hub vía `sync_catalogo.py` | + +--- + +## 🔧 INFRAESTRUCTURA (claves del servidor) + +- **Acceso admin**: `ssh claude@192.168.50.46` (o `claude@iclaude` por Tailscale). Usuario `claude`, sudo sin password, llave SSH. +- **Tailscale**: red privada. MagicDNS activo → `iclaude` resuelve a `100.110.177.1`. Tailnet: `tail69ab9b.ts.net`. Dispositivos: iphone, macbook, pixel. +- **Tailscale Funnel**: expone a internet `:443 → 4404 (Portafolio)` y `:8443 → 4403 (Catálogo)`. +- **⏰ Reloj**: la Surface tiene CMOS muerta (RTC=2009) y NTP bloqueado. `sync_clock.sh` (cron @reboot + cada hora) lo corrige desde IPs fijas (1.1.1.1). **Si los logins fallan con "logged out due to inactivity" → revisar el reloj** (`date`); era el bug raíz. +- **DNS**: Tailscale ya NO gestiona el DNS (`--accept-dns=false`); usa 1.1.1.1/8.8.8.8. Resuelve nombres públicos OK. +- **RAM**: solo 3.7GB (sin ampliar). Apagados Odoo/NocoDB/Jellyfin/qBittorrent/pihole para liberar. NO instalar plataformas pesadas (n8n, etc.) — usar scripts ligeros (Python stdlib/cron). +- **Backups**: cron diario medianoche → `backup.py` (DB + uploads, 30 días en `backups/`). + +--- + +## ⚠️ Principio clave: producto = fuente única de verdad +Cada atributo de producto impacta hasta 3 funciones. Antes de agregar/cambiar uno, define su impacto en: **(1) Operación/producción · (2) Catálogo/cotizador · (3) Página web**. + +## Deploy del Hub + +- Host: `claude@192.168.50.46` · Path: `/mnt/iclaude/art4hotel-hub/` +- Restart: `sudo systemctl restart art4hotel-hub` +- Deploy: `scp index.html server.py claude@192.168.50.46:/mnt/iclaude/art4hotel-hub/ && ssh claude@192.168.50.46 "sudo systemctl restart art4hotel-hub"` + +## Convenciones de edición + +- Toda edición a `index.html` o `server.py` se hace LOCAL aquí y se despliega con scp. +- Después de cualquier cambio en DB (ALTER TABLE, migración), verificar con SSH que se aplicó. +- No instalar dependencias nuevas — stack es Python stdlib + vanilla JS. Si una feature lo requiere, discutir antes. +- Cambios masivos (renombrar IDs, reorganizar tablas) → backup primero: `cp art4hotel.db art4hotel.db.bak_`. +- Listas de personalización: editar la tabla `trabajos` en DB, no hardcodear en index.html. Hay `TRABAJO_OPTS` como fallback + `trabajoOpts()` helper que prefiere `S.trabajos` dinámico. + +## Tareas frecuentes + +- **Ver duplicados**: `python3 -c "import sqlite3; c=sqlite3.connect('art4hotel.db'); print(list(c.execute('SELECT orden_id,COUNT(*) FROM ordenes GROUP BY orden_id HAVING COUNT(*)>1')))"` +- **Backup manual**: `cp art4hotel.db art4hotel.db.bak_$(date +%Y%m%d_%H%M%S)` +- **Logs del servicio**: `sudo journalctl -u art4hotel-hub -n 50 --no-pager` + +## Si Clod te pide algo + +- Si es feature nueva → propón primero antes de implementar (usar AskUserQuestion). +- Si es fix → identifica la causa raíz, confírmala con el código y datos, después arregla. +- Si es cambio cosmético → directo, sin overthink. diff --git a/CONTEXTO.md b/CONTEXTO.md new file mode 100644 index 0000000..b858806 --- /dev/null +++ b/CONTEXTO.md @@ -0,0 +1,93 @@ +# Art4Hotel Hub — Contexto del proyecto + +Sistema interno de operaciones para Clod (bolsas/accesorios custom, Los Cabos). +Última actualización mayor: 2026-05-31 + +## Stack +- **Backend**: Python 3 stdlib HTTP server + SQLite (WAL, FK on) +- **Frontend**: SPA vanilla JS, sin frameworks (`index.html` ~9000 líneas) +- **Deploy**: `claude@192.168.50.46:/mnt/iclaude/art4hotel-hub/` puerto 4401 +- **Acceso**: red local `192.168.50.46:4401` · Tailscale `100.110.177.1:4401` +- **Deploy cmd**: `scp index.html server.py claude@192.168.50.46:/mnt/iclaude/art4hotel-hub/ && ssh claude@192.168.50.46 "sudo systemctl restart art4hotel-hub"` + +## Modelo de datos (SQLite) +`ordenes` (pedidos/líneas de producción), `oc` (orden de compra que agrupa pedidos), `productos` (catálogo), `proyectos` (recetas recurrentes), `propuestas` (cotizaciones), `catalogos` (presentaciones PDF), `clientes`, `trabajos`, `modelos`, `materiales`, `inventario`, `tareas`, `bitacora`. + +CRUD genérico: dict `TABLES` en server.py define fields/int_fields/float_fields/nullable_fields por tabla; un solo handler maneja POST/PUT/DELETE de todas. + +## Conceptos clave +- **Pedido** (`ordenes`) = línea de producción individual +- **Orden (OC)** (`oc`) = agrupa pedidos, lleva factura/cobranza +- **Proyecto recurrente** (`proyectos`) = receta autorizada (cliente+producto+trabajo+logo) +- **Propuesta** → al aceptarse, botón "Convertir a Orden" crea OC + N pedidos (`oc.propuesta_id`) +- **tipo_orden**: OC / Resurtido / Muestra / Defecto / Faltante +- **Bodega**: *Con orden* (hoteles, facturable) vs *Sin orden* (resurtido/POS libre) +- **Stages**: Nuevo → En 2 Mares / En Taller Sofía → En Almacén → En Vehículo → Entregado + +## Producto — modelo unificado (editor de 4 secciones) +El producto es la **fuente única de verdad**. Editor `openProductoEdit` organizado por impacto: +- **📋 Identidad** (catálogo·web·cotizador): nombre, categoría, talla, medidas, material, `descripcion_web` +- **🎨 Personalización** (cotizador·web): `tipos_trabajo_disponibles` (CSV multi-select) — al cotizar SOLO aparecen estos +- **⚙️ Operación** (producción·inventario): sku (no editable), costo_base, proveedor, stock_actual, punto_reorden +- **🌐 Publicación**: `mostrar_en_web` + +Quickview `openProductoView` = visual read-only + botón Editar (ARRIBA de la galería) + galería de ejemplos. + +### ⚠️ PRINCIPIO: cada atributo de producto impacta hasta 3 funciones +Al agregar/cambiar un atributo de producto, **definir su impacto en las 3 antes de implementar**: +1. **Operación / flujo de producción** (kanban, inventario, costos) +2. **Catálogo / cotizador** (propuestas, catálogo PDF) +3. **Página web** (sync a art4hotel.com) +Ejemplo: agregar "uso" (boda/empresa/hotel) → afecta filtros web + tal vez segmentación de cotización. + +### Campos legacy (en DB, retirados del editor) +`color` (datos sucios), `logo_diseno` (pertenece a proyecto/pedido), `modelo` (vacío), `tipo_personalizacion` (ya migrado → `tipos_trabajo_disponibles`). Pendiente: limpieza con script. + +## Galería de ejemplos (base + personalización) +- Un producto base muestra "cómo se ha personalizado" = pedidos (`ordenes`) con foto cuyo `producto` coincide por nombre. +- Curaduría por ejemplo: `ordenes.web_ejemplo` (1=va a web) + `ordenes.web_etiqueta` (etiqueta PÚBLICA: zona/"muestra", **nunca el cliente real** — protege la cartera). +- `getEjemplosDeProducto(nombre)` calcula en cliente desde S.ordenes + fileIndex. + +## Archivos / fotos +- Guardados en `uploads/{entidad}/`, prefijo del tipo en el nombre: `factura_`, `foto_avance_produccion_`, `foto_producto_base_`, `recibo_entrega_`, etc. +- **El prefijo es crítico**: `/api/file-counts` lo usa para elegir `first_image` PREFIRIENDO fotos (`foto_`/`mockup`) y EXCLUYENDO documentos (`factura`,`recibo`,`comprobante`,`soporte`,`contrato`,`propuesta`). +- Subida con **etiqueta opcional**: `?tipo=X&label=...` → nombre `{tipo}_{ts}_{label}` (prefijo siempre se conserva). Sugerencia automática según tipo+contexto (`suggestFileLabel`). + +## Catálogos (Propuestas → 📚 Catálogos) +- Tabla `catalogos`: nombre, segmento, cliente_nombre, fecha, items(JSON), show_prices/show_clientes/show_contacto, entrega, minimo_compra, terminos, status. +- Builder: picker de Productos / Proyectos / Pedidos-con-foto. Items guardan snapshot + **rehidratación** (`catRehydrateItem`) al abrir → refresca datos vivos del producto, conserva `precio_unit` manual. +- Precio unitario **editable inline** por item (vacío = no se muestra). +- PDF: portada (logo oficial centrado) → grids 2 productos/página → pie con términos comerciales (entrega·mínimo) + contacto toggleable en cada página. +- Imprimir: `catPrintCatalog()` agrega clase `cat-printing` al body → `@media print` A4 vertical. + +## Ventas (tab unificada) +Sub-vistas: 📊 Dashboard (KPIs, comparativo mes/mes-pasado/año-pasado, top clientes, tiempos de ciclo, pricing por producto) · 📋 Por OC (kanban cobranza) · 📦 Por Entregas (panel "En Vehículo" + histórico). + +## Marca / estilo UI +- Fuentes: **Outfit** (cuerpo) + **Playfair Display Italic** (display) + **DM Sans** (labels) +- Colores: olivo `#5C6B4F`, olivo-osc `#3D4A33`, café `#6B4F3C`, arena `#D4C5A9`, crema `#FAF7F0` +- Constantes canónicas: `TRABAJO_OPTS`, `CONDICIONES_PAGO_OPTS`, `PRODUCTO_CATEGORIAS` +- Nav: tabs en desktop, dropdown en móvil (≤700px) + +## Optimizaciones +- `/api/file-counts` batched (1 request) con first_image inteligente +- Imágenes `loading="lazy" decoding="async"`, kanban/bodega sin thumbs por default +- IDs con `MAX(num)+1` (no count) — evita colisiones + +## Endpoints +- `GET/POST/PUT/DELETE /api/{table}` +- `GET /api/file-counts` → `{entity: {count, first_image}}` +- `GET /api/ventas` → dashboard analytics +- `POST /api/upload/{id}?tipo=X&label=Y` · `GET /api/files/{id}` · `DELETE /api/files/{id}/{name}` + +## Backup +- `backup.py` por cron: snapshot DB (online backup, safe c/ WAL) + `uploads.tar.gz`, 30 días en `backups/` +- Backups manuales antes de migraciones: `cp art4hotel.db art4hotel.db.bak__` + +## Pendientes (al 2026-05-31) +- **Web**: filtros por uso (boda/empresa/tienda/hotel — requiere atributo "uso") + secciones por tipo · foto base del modal de producto web recorta en cuadrado · analytics +- **Limpieza DB**: retirar columnas legacy (color, logo_diseno, modelo, tipo_personalizacion) +- **Operación**: limpieza de campos de pedido poco usados · distinguir 3 paths visualmente + +## Sitio web público → ver WEB.md +art4hotel.com (GitHub Pages) se alimenta del Hub vía `sync_catalogo.py`. Detalle en `WEB.md`. diff --git a/INSTALACION.md b/INSTALACION.md new file mode 100644 index 0000000..09800a0 --- /dev/null +++ b/INSTALACION.md @@ -0,0 +1,122 @@ +# Instalación y Despliegue — Art4Hotel Hub + +Sin Docker, sin pip, sin CI/CD. Solo Python 3 y `systemd`. + +--- + +## Requisitos + +- **Python 3.8+** (stdlib únicamente — `http.server`, `sqlite3`, `hashlib`, `hmac`) +- Linux con `systemd` (para correr como servicio). En Windows/Mac corre igual con `python3 server.py`. + +--- + +## 1. Desarrollo local + +```bash +git clone https://git.consultoria-as.com/consultoria-as/art4hotel-hub.git +cd art4hotel-hub +python3 server.py +# → http://localhost:4401 +``` + +Primera vez: la app pide **crear la cuenta admin** (pantalla de setup). Genera `art4hotel.db` y `secret.key` automáticamente. + +--- + +## 2. Despliegue en servidor + +### a) Copiar archivos + +```bash +scp server.py index.html backup.py usuario@servidor:/ruta/art4hotel-hub/ +``` + +> **No copiar** `secret.key` ni `art4hotel.db` desde tu máquina local — el servidor genera los suyos en el primer arranque (o migra los existentes en producción). Los datos del negocio viven solo en el servidor. + +### b) Servicio systemd + +`/etc/systemd/system/art4hotel-hub.service`: + +```ini +[Unit] +Description=Art4Hotel Hub +After=network.target + +[Service] +Type=simple +User=claude +WorkingDirectory=/ruta/art4hotel-hub +ExecStart=/usr/bin/python3 server.py +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now art4hotel-hub +sudo systemctl status art4hotel-hub +``` + +### c) Redespliegue (actualizar código) + +```bash +scp server.py index.html usuario@servidor:/ruta/art4hotel-hub/ +ssh usuario@servidor 'sudo systemctl restart art4hotel-hub' +``` + +Las migraciones de esquema corren solas al reiniciar (idempotentes). + +--- + +## 3. Acceso remoto (Tailscale) + +El Hub vive en una red privada **Tailscale** (MagicDNS `iclaude`): + +- **Privado** (equipos en la tailnet): `http://iclaude:4401/` +- **Público** (si se requiere exponer): Tailscale **Funnel** en un puerto dedicado (443/8443/10000). + +```bash +tailscale funnel --bg 4401 # expone el Hub públicamente +tailscale funnel status # ver qué está expuesto +``` + +> Nota de seguridad: el Hub ya tiene login propio, pero exponerlo públicamente amplía la superficie de ataque. Mantener el login activo y contraseñas fuertes. + +--- + +## 4. Respaldos + +`backup.py` por cron diario: + +```cron +0 3 * * * cd /ruta/art4hotel-hub && /usr/bin/python3 backup.py +``` + +Hace: online backup de la DB (seguro con WAL) + `uploads.tar.gz`. Retiene 30 días en `backups/`. +También disponible bajo demanda desde la UI (`POST /api/backup-now`). + +--- + +## 5. Reloj del servidor (importante) + +El servidor de producción tiene CMOS muerto y NTP bloqueado; un reloj desfasado **rompe los logins basados en token** (Filebrowser/Immich) y firma de sesiones. +Mitigación: cron `sync_clock.sh` que toma el header `Date` de `1.1.1.1` y ajusta la hora. Verificar `date` tras reinicios. + +--- + +## 6. Solución de problemas + +| Síntoma | Causa probable | Solución | +|---|---|---| +| "Sesión inválida" constante | reloj del servidor desfasado | corregir hora; revisar `sync_clock.sh` | +| `secret.key` regenerado → todos deslogueados | se borró/cambió la clave | restaurar `secret.key`; es la clave de firma | +| Imágenes equivocadas como ejemplo | prefijos de archivo | ver lógica `first_image` en `/api/file-counts` | +| No resuelve nombres públicos | DNS de Tailscale sin upstream | `tailscale set --accept-dns=false` | + +--- + +*Stack deliberadamente mínimo: todo el sistema cabe en una USB y corre con un solo `python3 server.py`.* diff --git a/MODELO_DATOS.md b/MODELO_DATOS.md new file mode 100644 index 0000000..a89a8d3 --- /dev/null +++ b/MODELO_DATOS.md @@ -0,0 +1,90 @@ +# Modelo de Datos — Art4Hotel Hub + +Base de datos **SQLite** (`art4hotel.db`), modo WAL + foreign keys ON. 14 tablas. +Todas las tablas tienen `id INTEGER PRIMARY KEY AUTOINCREMENT` y, la mayoría, `created_at` / `updated_at`. + +> El esquema se crea/migra automáticamente al arrancar (`init_db()` en `server.py`), con migraciones idempotentes (`ALTER TABLE` solo si la columna no existe). + +--- + +## Diagrama de relaciones (lógicas) + +``` +propuestas ──(propuesta_id)──> oc ──(oc_id)──> ordenes + └──(proyecto_id)──> proyectos ──> productos +clientes ───────(por nombre)───────> oc, ordenes, proyectos +productos <──(por nombre)── ordenes (los "ejemplos" del producto = pedidos con foto) +trabajos ──> tipos de personalización (CSV en productos.tipos_trabajo_disponibles) +``` + +Las relaciones son por **FK nullable** (`oc.propuesta_id`, `ordenes.oc_id`, `ordenes.proyecto_id`) o **por nombre** (cliente/producto como texto). El CRUD es genérico vía el dict `TABLES` en `server.py`. + +--- + +## Tablas + +### `ordenes` — Pedidos (líneas de producción) · 36 columnas +El corazón operativo. Cada fila = una pieza/lote en producción. +| Campo | Notas | +|---|---| +| `orden_id` | ID legible (ej. `ORD-2026-071`). Se genera con MAX+1 | +| `tipo_orden` | OC / Resurtido / Muestra / Defecto / Faltante | +| `cliente`, `producto`, `sku`, `cantidad` | datos del pedido | +| `tipo_trabajo` | personalización (Bordado, Serigrafía, DTF UV…) | +| `stage` | etapa del kanban | +| `fecha_oc/inicio/estimada/recepcion/entrega`, `recibio` | fechas y firma | +| `urgente`, `logo_instrucciones`, `notas` | | +| `costo_producto`, `costo_trabajo`, `costo_logistica`, `precio_factura` | economía | +| `check_facturada/empacada/etiquetas/vehiculo` | checklist | +| `grupo_oc`, `piezas_recibidas`, `piezas_danadas`, `nota_recepcion` | recepción/parciales | +| `oc_id` → oc, `proyecto_id` → proyectos | vínculos | +| `web_ejemplo`, `web_etiqueta` | curaduría web: si la foto de este pedido es ejemplo público + etiqueta pública (zona, NO el cliente real) | + +### `oc` — Órdenes de Compra · 22 columnas +Agrupan pedidos de un cliente; llevan la facturación/cobranza. +`oc_id`, `cliente`, `fecha_oc`, `fecha_entrega`, `recibio`, `costo_logistica`, `precio_factura`, `factura_num`, `condiciones_pago`, `status`, `iva_pct`, `otros_gastos(+desc)`, `pagado`, `fecha_pago`, `metodo_pago`, `oc_origen_id` (entregas parciales), `propuesta_id` (de qué propuesta nació). + +### `productos` — Catálogo (fuente única de verdad) · 18 columnas +`sku`, `nombre`, `categoria`, `talla`, `material`, `medidas`, `descripcion_web`, `tipos_trabajo_disponibles` (CSV de personalizaciones que aplican), `costo_base`, `proveedor`, `stock_actual`, `punto_reorden`, `activo`, `mostrar_en_web` (publicar en art4hotel.com). + +### `proyectos` — Recetas recurrentes · 17 columnas +Receta autorizada reutilizable. `nombre`, `producto_id/_nombre`, `cliente`, `tipo_trabajo`, `costo_unitario`, `costo_trabajo`, `logo_descripcion`, `logo_archivo`, `foto_terminado`, `activo`, `veces_usado`, `ultimo_uso`. + +### `propuestas` — Cotizaciones · 19 columnas +`numero`, `cliente_nombre`, `contacto`, `empresa`, `direccion`, `locacion`, `tipo_negocio`, `email`, `telefono`, `fecha`, `vigencia_dias`, `items` (JSON), `iva_pct`, `descuento_pct`, `status` (borrador/enviada/aceptada/rechazada). + +### `catalogos` — Presentaciones PDF · 17 columnas +`nombre`, `segmento`, `cliente_nombre`, `fecha`, `items` (JSON con snapshot + precio manual), `show_prices/clientes/contacto`, `entrega`, `minimo_compra`, `terminos`, `status`. + +### `clientes` — CRM · 10 columnas +`nombre`, `tipo` (hotel/restaurante/tienda…), `contacto`, `zona_entrega`, `costo_entrega`, `condiciones_pago`, `notas`, `activo`. + +### `trabajos` — Tipos de personalización · 8 columnas +`clave`, `nombre`, `costo_base`, `variable_por`, `proveedor_default`, `activo`. Fuente de la lista de personalizaciones. + +### `inventario` — SKUs legacy · 15 columnas +`sku`, `nombre`, `descripcion`, `tipo`, `talla`, `color_base`, `proveedor`, `stock_inicial`, `punto_reorden`, `costo_unitario`, `ultimo_conteo`. + +### `compras` — Compras a proveedor · 13 columnas +`compra_id`, `proveedor`, `fecha_compra`, `fecha_llegada`, `sku`, `nombre_producto`, `cantidad`, `costo_unitario`, `status`. + +### `modelos` · `materiales` — Catálogos auxiliares +`modelos`: clave, nombre, descripcion, activo. `materiales`: clave, nombre, tipo, costo_unitario, activo. + +### `tareas` — Kanban interno del equipo · 11 columnas +`titulo`, `descripcion`, `prioridad`, `stage`, `categoria`, `fecha_limite`, `asignado`, `notas`. + +### `bitacora` — Timeline de eventos · 6 columnas +`tipo`, `titulo`, `descripcion`, `referencia`, `fecha`. Registro de acciones del sistema. + +### `usuarios` — Login del Hub · 6 columnas +`username`, `pass_hash` (PBKDF2-SHA256, 200k iteraciones, con salt), `nombre`, `activo`. La sesión usa cookie firmada con HMAC (clave en `secret.key`, NO versionada). + +--- + +## Archivos (no en DB) + +Guardados en `uploads/{entidad}/` con prefijo de tipo en el nombre: +`factura_`, `recibo_entrega_`, `foto_producto_base_`, `foto_avance_produccion_`, `comprobante_`, etc. + +**El prefijo es crítico**: `/api/file-counts` lo usa para elegir la imagen representativa, **prefiriendo** fotos (`foto_`/`mockup`) y **excluyendo** documentos (`factura`, `recibo`, `comprobante`, `soporte`, `contrato`, `propuesta`). diff --git a/NEGOCIO.md b/NEGOCIO.md new file mode 100644 index 0000000..050f83e --- /dev/null +++ b/NEGOCIO.md @@ -0,0 +1,147 @@ +# Art4Hotel / Clod — Contexto de negocio + +> Doc de contexto operativo. Léelo antes de proponer cambios al Hub. +> Última actualización: 2026-05-28 + +## 1. Qué es el negocio + +Empresa de **bolsas y accesorios personalizados** con base en Los Cabos. Cliente principal: hoteles boutique y tiendas de la región. Marca paraguas: *Art4Hotel*. + +**Modelo**: comercial + logística. No hay fábrica propia. Todo lo que se vende es: + +1. Producto base de un proveedor (bolsa, taza, accesorio) +2. + Personalización hecha por un taller externo (bordado, serigrafía, DTF UV, etc.) + +Clod (operador principal) hace **todo el ciclo end-to-end excepto fabricar**: + +- Cierra venta y manda propuesta +- Recibe mercancía base del proveedor +- Lleva físicamente en su carro al taller de personalización +- Recoge el producto terminado +- Empaca +- Entrega físicamente con el cliente + +**Cobranza**: solo de los clientes que él mismo cerró. Sandra y otros vendedores cobran los suyos. Las ventas están divididas por cliente entre el equipo. + +## 2. Mezcla del negocio + +- **80% hoteles** (Los Cabos) +- **20% otros** (tiendas, eventos, distribuidores) + +## 3. Clientes recurrentes + +### Top actuales +- **Nobu Los Cabos** — hotel luxury, volúmenes consistentes +- **Flora Farms / Flora Mango** — restaurante destino, logo recurrente +- **Tienda La Ventana** — tienda recurrente que resurte +- **Tienda El Sargento** — tienda recurrente que resurte + +### Otros relevantes +Cabo Bello, Hacienda Cabo Bello, Concepción, Mas Olas Todos Santos, Puerta Cortés, Puerto Los Cabos, Tres Guerras, The Cape, Wild Cabo, Café Saturno, Todos Santos Hotel Boutique, Hnos Salgados, El Burro Terko, Chapitos Los Barriles, Tienda Los Torotes, Tienda Centro, Rubas, Ventanas, Corazón. + +### Reglas por tipo de cliente +- **Hoteles** → factura formal siempre. Confianza alta (no requieren anticipo para arrancar producción). +- **Tiendas** → mezcla: algunas factura, algunas consignación. +- **Trigger de producción**: la **propuesta aceptada basta** para mandar a producción. No se espera anticipo. + +## 4. Productos + +No hay un producto dominante — **mezcla balanceada**. Los más recurrentes: + +- Bolsa de manta con base de yute (versátil, logo donde sea) +- Bolsa Loneta 2026 (asas café o negro) +- Bolsa Cabo Bello +- Maleta de viaje de manta gruesa +- Bolsa yute grande asas café +- Taza blanca minimalista +- Sombrero (con/sin piel) +- Mandil de mezclilla +- Llaveros +- Termo doble pared + +## 5. Personalizaciones (tipos de trabajo) + +Las más pedidas / rentables: + +- **Serigrafía** — volumen alto, costo bajo, márgenes amplios en pedidos grandes +- **Bordado** — premium, hoteles lo piden para imagen, márgenes buenos pero más caro +- **DTF UV / Impresión DTF** — para tazas, sustratos rígidos, diseños complejos a todo color + +Lista canónica de personalizaciones (sincronizada con tabla `trabajos`): +`Bordado, Serigrafia, Grabado laser, Impresion DTF, Sublimación, DTF UV, Costura, Modificacion, Sin personalización, Otro`. + +Si se agrega una nueva, registrarla en el catálogo (`Catalogo → Trabajos`) con su costo base. El Hub la verá en pedido / propuesta / proyecto automáticamente. + +## 6. Talleres / proveedores de personalización + +- **2 Mares** — taller externo, el principal. Bordado / serigrafía estándar. +- **Taller Sofía** — taller externo de costura. +- Cobran su trabajo por pieza. +- **Logística**: Clod los recorre en su carro — llevar mercancía base, recoger terminado. + +El stage **"En 2 Mares"** y **"En Taller Sofía"** en el kanban representan literalmente "el producto está físicamente ahí". + +## 7. Flujo de un pedido (stages) + +``` +Nuevo + ↓ (Clod lleva en carro al taller) +En 2 Mares / En Taller Sofía + ↓ (Clod va y recoge — botón "Recoger") +En Almacen + ↓ (Clod carga el carro para entregar) +En Vehiculo + ↓ (entrega física con firma) +Entregado +``` + +Stages adicionales: +- **En Tránsito** — producto base viajando del proveedor (poco usado, opcional) +- **Cancelado** — pedido abortado + +## 8. Tipos de pedido (`tipo_orden`) + +| Tipo | Significado | +|---|---| +| `OC` | Pedido principal de cliente, con orden de compra y factura | +| `Muestra` | 1 pieza para presentar a un prospecto antes de cerrar venta | +| `Resurtido` | Producción libre sin OC, va a bodega como stock disponible (POS / venta rápida) | +| `Defecto` | Reposición cuando llegó producto dañado | +| `Faltante` | Reposición cuando faltaron piezas en una entrega | + +**Regla**: todo pedido `OC` vive vinculado a una `oc`. Resurtido/Muestra/Defecto/Faltante pueden vivir sueltos. + +## 9. Estructura conceptual + +- **Pedido** (`ordenes`) — una línea de producción (1 producto, 1 personalización, 1 cantidad) +- **Orden de Compra (OC)** (`oc`) — agrupa varios pedidos del mismo cliente, lleva la factura/cobranza +- **Proyecto recurrente** (`proyectos`) — receta autorizada (cliente + producto + tipo_trabajo + logo). Se reusa entre pedidos para no volver a definir el diseño. +- **Propuesta** (`propuestas`) — cotización antes de cerrar venta. Al aceptarse, se convierte en OC + pedidos. + +## 10. Operación diaria + +**No hay rutina fija** — depende de carga semanal. Lo que más urge ver al abrir el Hub: + +> **Qué hay listo en talleres para recoger** — para planear los viajes del día. + +Por eso vale la pena tener visibles: +- Pedidos `En 2 Mares` / `En Taller Sofía` con tiempo acumulado +- Pedidos `En Vehículo` listos para entregar (panel ya implementado en Entregas) + +## 11. Equipo + +| Persona | Rol | +|---|---| +| **Clod** | Fundador. Operaciones end-to-end (logística, talleres, entregas) + cierra sus propias ventas | +| **Tess** | Socia. Diseño. | +| **Andre** | Repartidor / entregas físicas. | +| **Sandra** | Contabilidad, facturación. Cierra sus propios clientes y cobra. | + +Las ventas están **divididas por cliente** entre vendedores. Cada quien cobra lo suyo. + +## 12. Por documentar (pendiente) + +- Cobranza: ciclos típicos, condiciones de pago por cliente, anticipos +- Propuestas: estructura típica, cómo se arma una, qué incluye +- Decisiones de diseño técnico del Hub (por qué cada feature está como está) +- Plan a futuro: integraciones, automatizaciones deseadas diff --git a/ONBOARDING.md b/ONBOARDING.md new file mode 100644 index 0000000..fb1a1e9 --- /dev/null +++ b/ONBOARDING.md @@ -0,0 +1,63 @@ +# Art4Hotel Hub — Handover + +## Que es +Sistema interno de gestion de ordenes, inventario, entregas y ventas para Art4Hotel (bolsas personalizadas para hoteles en Los Cabos). Single-page app con Python stdlib server + SQLite. + +## Acceso servidor +- **Host**: `claude@192.168.50.46` +- **Path**: `/mnt/iclaude/art4hotel-hub/` +- **Archivos**: `server.py` (backend), `index.html` (frontend SPA), `art4hotel.db` (SQLite) +- **Servicio**: `sudo systemctl restart art4hotel-hub` +- **Puerto**: 4401 +- **Deploy**: `scp archivo claude@192.168.50.46:/mnt/iclaude/art4hotel-hub/ && ssh claude@192.168.50.46 "sudo systemctl restart art4hotel-hub"` + +## Stack +- Python 3 stdlib (`http.server` + `sqlite3` + `json`) — zero deps +- SQLite WAL mode, foreign keys +- Frontend vanilla JS SPA, no frameworks +- Branding: Outfit font, paleta olive/sand/cream del brandbook Art4Hotel + +## Arquitectura +**Tablas principales**: ordenes, inventario, tareas, bitacora, modelos, materiales, trabajos, clientes, productos, archivos +**API REST**: `/api/{tabla}` GET/POST, `/api/{tabla}/{id}` GET/PUT/DELETE +**Archivos**: `/api/upload/{orden_id}?tipo=X` POST multipart, `/api/files/{orden_id}` GET + +## Flujo de ordenes (stages kanban) +`En 2 Mares` → `En Taller Sofia` → `En Almacen` → `En Vehiculo` → `Entregado` + +- Drag & drop entre columnas +- Al mover a "Entregado" se abre modal de confirmacion (fecha, recibio, upload soporte) +- Boton "Entregar" directo en tarjetas de "En Vehiculo" + +## Tabs del SPA +1. **Dashboard** — KPIs, stages bar chart, clientes activos, alertas stock, timeline +2. **Ventas** — Revenue por cliente, por mes, margenes, entregas del mes +3. **Ordenes** — Kanban + tabla, wizard 4-pasos para crear, search, toggle entregados +4. **Entregas** — Post-entrega agrupado por fecha; editar costos inline, subir facturas/recibos/fotos +5. **Inventario** — SKUs con stock, reorden, costos +6. **Catalogo** — CRUD: modelos, materiales, trabajos, clientes (fuente de datos para dropdowns) +7. **Tareas** — Kanban interno del equipo +8. **Bitacora** — Timeline de eventos/decisiones + +## Modelo de precios +``` +costo_total = (costo_producto + costo_trabajo) * cantidad + logistica +utilidad = precio_factura - costo_total +margen = utilidad / precio_factura * 100 +``` + +## Equipo +- **Clod** — fundador, operaciones, entregas +- **Tess** — socia, diseño +- **Andre** — repartidor, entregas fisicas +- **Sandra** — contabilidad, facturacion + +## Archivos locales (dev) +- `C:\Users\claud\Documents\Claude\Art 4 Hotel\hub\` — server.py + index.html +- `C:\Users\claud\Documents\Claude\Art 4 Hotel\Pagina Web\art4hotel\Recursos\art4hotel-brandbook.html` — referencia de marca + +## Notas rapidas +- El usuario actualiza datos reales de inventario/modelos el mismo (CRUD en Catalogo) +- Mobile-first para Andre/Sandra (wizard, botones grandes) +- Entregas agrupadas por fecha para navegacion cronologica +- Archivos se guardan en `/mnt/iclaude/art4hotel-hub/uploads/{orden_id}/` diff --git a/README.md b/README.md new file mode 100644 index 0000000..870427d --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# Art4Hotel Hub + +Sistema interno de gestión integral (ERP a la medida) para **Art4Hotel** — bolsas y accesorios personalizados para hoteles, restaurantes y eventos en Los Cabos, México. + +Centraliza todo el ciclo del negocio: **cotización → producción → bodega → entrega → cobranza**, más un catálogo digital, generador de catálogos PDF y sincronización con el sitio web público. + +--- + +## ✨ Características + +- **Operaciones** — kanban de producción con drag & drop por etapas +- **Ventas** — dashboard de KPIs, workflow de cobranza por OC, gestión de entregas +- **Clientes** — CRM con historial, condiciones de pago, zonas +- **Propuestas** — cotizador visual; convierte propuestas aceptadas en órdenes + pedidos +- **Catálogos** — generador de catálogo PDF con la marca, con storytelling (producto base + galería de personalizaciones) +- **Productos** — catálogo unificado (fuente única de verdad) +- **Inventario** — SKUs, stock, punto de reorden +- **Archivos** — facturas, fotos, recibos con nombres inteligentes +- **Sitio web** — alimenta art4hotel.com vía script de sincronización +- **Login** — autenticación con sesiones firmadas (HMAC) +- **Respaldos** — automáticos diarios + +--- + +## 🧱 Stack + +| Capa | Tecnología | +|---|---| +| Backend | **Python 3 stdlib** (`http.server` + `sqlite3`) — cero dependencias externas | +| Base de datos | **SQLite** (WAL, foreign keys) — un solo archivo | +| Frontend | **Vanilla JS SPA** en un solo `index.html` — sin frameworks | +| Archivos | Filesystem (`uploads/`) servido por el mismo server | +| Deploy | `scp` + `systemctl` — sin Docker, sin CI/CD | + +Filosofía: **minimalismo deliberado**. Todo el sistema (código + datos) pesa <1 MB. Cabe en una USB. Sin licencias, sin dependencias que mantener. + +--- + +## 🚀 Arranque rápido + +```bash +# Requiere solo Python 3 (stdlib). Sin pip install. +python3 server.py +# → http://localhost:4401 +``` + +La primera vez, la app muestra una pantalla para **crear la cuenta de administrador**. Después, login normal. + +Ver **[INSTALACION.md](./INSTALACION.md)** para el despliegue completo en servidor. + +--- + +## 📁 Estructura del repositorio + +``` +art4hotel-hub/ +├── server.py # Backend: HTTP server + SQLite + API REST + auth +├── index.html # Frontend: SPA completa (~9000 líneas) +├── backup.py # Script de respaldo (DB + uploads) +├── fix_dup.py # Utilidad: corrige IDs duplicados +├── README.md # Este archivo +├── INSTALACION.md # Guía de despliegue desde cero +├── MODELO_DATOS.md # Esquema de la base de datos (14 tablas) +├── API.md # Referencia de endpoints +├── CONTEXTO.md # Estado técnico: decisiones, optimizaciones, deploy +├── NEGOCIO.md # Contexto operativo: clientes, productos, flujo real +├── WEB.md # Sitio público art4hotel.com + sync +├── ONBOARDING.md # Handover técnico rápido +├── CLAUDE.md # Índice maestro + contexto para asistentes IA +└── VALOR_SISTEMA.md # Valor/costo del sistema (para presentar a terceros) +``` + +> **No incluidos en el repo** (por seguridad / ver `.gitignore`): `art4hotel.db` (datos del negocio), `secret.key` (clave de sesiones), `ACCESOS.html` (contraseñas), `uploads/`, `backups/`. + +--- + +## 📚 Documentación + +| Documento | Para qué | +|---|---| +| **[NEGOCIO.md](./NEGOCIO.md)** | Entender el negocio: clientes, productos, flujo real, reglas. **Léelo primero.** | +| **[CONTEXTO.md](./CONTEXTO.md)** | Estado técnico: modelo de datos, producto unificado, catálogos, decisiones UX | +| **[MODELO_DATOS.md](./MODELO_DATOS.md)** | Esquema completo de la base de datos | +| **[API.md](./API.md)** | Referencia de endpoints REST | +| **[INSTALACION.md](./INSTALACION.md)** | Desplegar desde cero | +| **[WEB.md](./WEB.md)** | Sitio público + sincronización | +| **[ONBOARDING.md](./ONBOARDING.md)** | Repaso técnico rápido | +| **[VALOR_SISTEMA.md](./VALOR_SISTEMA.md)** | Valor/costo del sistema | + +--- + +## 🔑 Conceptos clave + +- **Pedido** (`ordenes`) — línea de producción individual (1 producto × 1 personalización × cantidad) +- **Orden de Compra (OC)** (`oc`) — agrupa pedidos, lleva la factura/cobranza +- **Proyecto recurrente** (`proyectos`) — receta autorizada reutilizable (cliente + producto + trabajo + logo) +- **Propuesta** (`propuestas`) — cotización; al aceptarse genera OC + pedidos +- **Stages** del kanban: `Nuevo → En 2 Mares / En Taller Sofía → En Almacén → En Vehículo → Entregado` + +### ⚠️ Principio de diseño: el producto es la fuente única de verdad +Cada atributo de producto impacta hasta 3 funciones. Antes de agregar/cambiar uno, definir su impacto en: **(1) Operación/producción · (2) Catálogo/cotizador · (3) Página web**. + +--- + +## 🛡 Respaldos + +`backup.py` corre por cron diario: snapshot de la DB (online backup, seguro con WAL) + `uploads.tar.gz`. Retiene 30 días. + +--- + +*Sistema desarrollado para Art4Hotel · Los Cabos, BCS, México.* diff --git a/VALOR_SISTEMA.html b/VALOR_SISTEMA.html new file mode 100644 index 0000000..d0869ce --- /dev/null +++ b/VALOR_SISTEMA.html @@ -0,0 +1,138 @@ + + + + + +Art4Hotel — Valor del Sistema + + + + + +
+
+ +

Estructura, Hosting y Valor del Sistema

+
Documento para explicar el sistema a socios, inversionistas o proveedores · 2026
+
+
+ +

1 · Qué es

+

Un ERP a la medida (sistema de gestión integral) para Art4Hotel. Centraliza en una sola plataforma todo el ciclo del negocio: cotización → producción → bodega → entrega → cobranza, más un catálogo digital y un sitio web público que captura clientes.

+

Hecho específicamente para el flujo de bolsas/accesorios personalizados — no es un software genérico adaptado, sino uno que "entiende" qué es una bolsa con bordado para un hotel.

+ +

2 · Arquitectura técnica

+
USUARIOS (Clod, Sandra, Andre, Tess) + Navegador / Celular — acceso privado por VPN + │ + ▼ Internet privado (Tailscale VPN) +SERVIDOR (1 máquina pequeña) + ┌────────────┐ ┌──────────────┐ ┌──────────────────┐ + │ App Python │→ │ Base SQLite │ │ Archivos/fotos │ + │ (servidor) │ │ (1 archivo) │ │ (facturas, fotos)│ + └────────────┘ └──────────────┘ └──────────────────┘ + │ respaldo automático diario (30 días) + ▼ sincronización (script) +SITIO WEB PÚBLICO — art4hotel.com (gratis, GitHub) + Catálogo + formulario de cotización → llega al correo
+

Claves: stack minimalista (Python + SQLite + JavaScript), cero dependencias, cero licencias. Todo el código pesa <1 MB. Privado por VPN; la web pública es una capa separada de solo lectura. Respaldos diarios automáticos.

+ +

3 · Dónde vive — hosting y costo

+

Opción A — Servidor propio (lo de hoy) ✅ inversión única

+ + + + + + + +
EquipoCosto (MXN)Notas
Mini PC (Intel N100, 16GB, SSD 500GB)$3,500 – $5,000Recomendado, silencioso
Raspberry Pi 5 (8GB) + kit~$3,000Alternativa económica
Reusar una PC vieja$0Funciona perfecto
No-break / UPS$800 – $1,500Protege ante apagones
Total inversión única~$4,000 – $6,500+ ~$50/mes luz
+

Opción B — Nube (VPS rentado) · renta mensual

+ + + + + +
ProveedorPlanCosto/mes (MXN)
Hetzner2 CPU / 4GB~$90
DigitalOceanBásico~$110
Vultr / LinodeSimilar$100 – $150
+

Sitio web público art4hotel.com: gratis (GitHub Pages, SSL incluido). Solo el dominio ~$200/año.

+
Hoy operamos con servidor propio (~$5,000 una vez). Migrar a nube costaría ~$100–150/mes. La web es gratis.
+ +

4 · Qué incluye el sistema

+
ART4HOTEL HUB +├── 🛠 OPERACIONES — kanban de producción +├── 💼 VENTAS — Dashboard (KPIs, comparativos) · Por OC · Por Entregas +├── 👥 CLIENTES — CRM con historial y condiciones +├── 📝 PROPUESTAS — cotizador → convierte a Orden + Pedidos +├── 📚 CATÁLOGOS — generador PDF con marca + storytelling +├── 📦 PRODUCTOS — catálogo unificado (fuente única) +├── 📁 ARCHIVOS — facturas/fotos con nombres inteligentes +├── 🌐 SITIO WEB — landing + catálogo + captura de leads +└── ❓ MANUAL integrado · 🛡 Respaldos automáticos
+

Funciones que normalmente cuestan extra en software comercial: generador de catálogo PDF con tu marca, dashboard de analítica con comparativos, sitio web con captura de leads, conversión automática cotización→producción, curaduría de qué se publica (protegiendo tu cartera de clientes).

+ +

5 · Cuánto costaría hacerlo desde cero

+ + + + + + + + +
FaseTiempo
Levantamiento y diseño1 – 2 semanas
Backend + base de datos + API2 – 3 semanas
Frontend (la app, ~9,000 líneas)6 – 10 semanas
Catálogo PDF + sitio web + sync3 – 4 semanas
Pruebas, despliegue, manual1 – 2 semanas
Total (~400–600 horas)~3.5 – 5 meses
+ + + + + + +
PerfilTarifaCosto del proyecto
Freelancer mid (México)$350–550 MXN/hr$150,000 – $330,000 MXN
Freelancer senior (México)$600–1,000 MXN/hr$250,000 – $500,000 MXN
Agencia de software (México)proyecto fijo$250,000 – $600,000 MXN
Internacional (USD)$30–70 USD/hr$12,000 – $35,000 USD
+
Rango realista para construirlo profesionalmente desde cero: + ~$180,000 a $450,000 MXN (≈ $11,000 – $27,000 USD) · 3.5 – 5 meses +
+

Mantenimiento si lo hiciera un tercero: $5,000 – $15,000 MXN/mes, o ~15–20% del costo al año.

+

Vs. software de renta (SaaS): uno comparable costaría $3,000 – $12,000 MXN/mes y aun así no se adaptaría al flujo de personalización (talleres externos, bodega con/sin orden, catálogo con storytelling).

+ +

6 · Conclusión de valor

+
    +
  • Lo que tienes: un sistema a la medida valuado en $180k–450k MXN de desarrollo, operando con ~$5,000 de equipo y $0 de licencias.
  • +
  • Sin costo recurrente de software (vs $36k–144k/año de un SaaS).
  • +
  • Datos en tu poder, adaptado exactamente a cómo trabaja Art4Hotel.
  • +
  • Escalable: para más usuarios/volumen, migrar a Postgres y nube es trabajo de días, no meses.
  • +
+
+
Documento generado 2026 · Las cifras de mercado son estimaciones y varían por proveedor y región.
+
+ + diff --git a/VALOR_SISTEMA.md b/VALOR_SISTEMA.md new file mode 100644 index 0000000..fc81c49 --- /dev/null +++ b/VALOR_SISTEMA.md @@ -0,0 +1,176 @@ +# Art4Hotel Hub — Estructura, Hosting y Valor del Sistema + +> Documento para explicar el sistema a socios, inversionistas o proveedores. +> Cifras en MXN con referencia USD (tipo de cambio ~$17.5). Estimaciones 2026. + +--- + +## 1. Qué es + +Un **ERP a la medida** (sistema de gestión integral) para Art4Hotel. Centraliza en una sola plataforma todo el ciclo del negocio: cotización → producción → bodega → entrega → cobranza, más un catálogo digital y un sitio web público que captura clientes. + +Hecho específicamente para el flujo de bolsas/accesorios personalizados — no es un software genérico adaptado, sino uno que "entiende" qué es una bolsa con bordado para un hotel. + +--- + +## 2. Arquitectura técnica + +``` +┌──────────────────────────────────────────────────────────┐ +│ USUARIOS (Clod, Sandra, Andre, Tess) │ +│ Navegador / Celular — acceso privado por VPN │ +└───────────────────────┬──────────────────────────────────┘ + │ Internet privado (Tailscale VPN) + ▼ +┌──────────────────────────────────────────────────────────┐ +│ SERVIDOR (1 máquina pequeña) │ +│ ┌────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ App Python │ │ Base de datos │ │ Archivos/fotos │ │ +│ │ (servidor │→ │ SQLite │ │ (facturas, fotos │ │ +│ │ web) │ │ (1 archivo) │ │ de producto) │ │ +│ └────────────┘ └──────────────┘ └──────────────────┘ │ +│ ↓ respaldo automático diario (30 días) │ +└───────────────────────┬──────────────────────────────────┘ + │ script de sincronización (1 vez/cambio) + ▼ +┌──────────────────────────────────────────────────────────┐ +│ SITIO WEB PÚBLICO — art4hotel.com (gratis, GitHub) │ +│ Catálogo + formulario de cotización → llega al correo │ +└──────────────────────────────────────────────────────────┘ +``` + +**Características técnicas clave:** +- Stack minimalista: Python + SQLite + JavaScript. **Cero dependencias externas**, cero licencias. +- Todo el sistema (programa + datos) pesa menos de 1 MB de código — cabe en una USB. +- Privado: solo accesible por VPN, no expuesto a internet. La web pública es una capa separada de solo lectura. +- Respaldos automáticos diarios. + +--- + +## 3. Dónde vive el sistema — opciones de hosting y costo + +### Opción A — Servidor propio (lo que usamos hoy) ✅ +Una máquina pequeña en la oficina/casa. **Inversión única, sin renta mensual.** + +| Equipo | Costo aprox. (MXN) | Notas | +|---|---|---| +| Mini PC (Intel N100, 16GB, SSD 500GB) | $3,500 – $5,000 | Recomendado. Silencioso, bajo consumo | +| Raspberry Pi 5 (8GB) + kit | ~$3,000 | Alternativa económica | +| Reusar una PC vieja | $0 | Funciona perfecto, el sistema es ligero | +| No-break / UPS (600VA) | $800 – $1,500 | Protege ante apagones | +| **Total inversión única** | **~$4,000 – $6,500** | + ~$50/mes de electricidad | + +**Pros**: pagas una vez, control total, datos en tu poder. **Contras**: depende de tu internet/luz local (mitigado con VPN + UPS). + +### Opción B — Nube (servidor rentado / VPS) +Un servidor virtual en la nube. **Renta mensual, cero equipo.** + +| Proveedor | Plan | Costo/mes (MXN) | +|---|---|---| +| Hetzner | 2 CPU / 4GB RAM | ~$90 (€4.5) | +| DigitalOcean | Básico | ~$110 ($6 USD) | +| Vultr / Linode | Similar | ~$100 – $150 | +| **Total** | | **~$100 – $150/mes** (~$1,200 – $1,800/año) | + +**Pros**: accesible desde cualquier lado sin VPN, sin depender de tu luz/internet, respaldos del proveedor. **Contras**: renta perpetua, datos en servidor externo. + +### Opción C — Plataforma administrada (PaaS) +Railway / Render / Fly.io — despliegue sin administrar el servidor. ~$0 – $400/mes según uso. Innecesario para este tamaño; un VPS de $100/mes sobra. + +### Sitio web público +art4hotel.com vive en **GitHub Pages = gratis** (hosting + SSL incluidos). Solo se paga el dominio (~$200/año, ya se tiene en Wix). + +> **Resumen hosting**: hoy operamos con servidor propio (~$5,000 una vez). Migrar a nube costaría ~$100–150/mes. La web pública es gratis. + +--- + +## 4. Esquema funcional — qué incluye el sistema + +``` +ART4HOTEL HUB +│ +├── 🛠 OPERACIONES — kanban de producción +│ Pedidos por etapa · drag&drop · recoger de taller · bodega con/sin orden +│ +├── 💼 VENTAS (3 vistas) +│ 📊 Dashboard: facturación, márgenes, comparativo mes/año, tiempos de ciclo, pricing +│ 📋 Por OC: workflow de cobranza +│ 📦 Por Entregas: panel "en vehículo" + histórico +│ +├── 👥 CLIENTES — CRM con historial, condiciones de pago, zonas +│ +├── 📝 PROPUESTAS — cotizador +│ Editor visual · convertir propuesta aceptada → Orden + Pedidos +│ +├── 📚 CATÁLOGOS — generador de catálogo PDF imprimible +│ Builder de productos · precios manuales · términos comerciales +│ Storytelling: producto base + galería de personalizaciones +│ +├── 📦 PRODUCTOS — catálogo unificado (fuente única de verdad) +│ Identidad · personalizaciones por producto · operación/inventario +│ +├── 📁 ARCHIVOS — facturas, fotos, recibos con nombres inteligentes +│ +├── 🌐 SITIO WEB (art4hotel.com) +│ Landing · catálogo dinámico · wizard de cotización → leads al correo +│ Sincronización automática desde el Hub +│ +└── ❓ MANUAL integrado · 🛡 Respaldos automáticos +``` + +**Funciones destacadas que normalmente cuestan extra en software comercial:** +- Generador de catálogo PDF con tu marca +- Dashboard de analítica de ventas con comparativos +- Sitio web con captura de leads integrada +- Conversión automática cotización → producción +- Curaduría de qué productos/ejemplos se publican (con protección de cartera de clientes) + +--- + +## 5. Valor de desarrollo — cuánto costaría hacerlo desde cero + +Si contrataras a un desarrollador o agencia para construir este sistema completo: + +### Tiempo estimado (1 desarrollador senior) + +| Fase | Tiempo | +|---|---| +| Levantamiento de requerimientos y diseño | 1 – 2 semanas | +| Backend + base de datos + API | 2 – 3 semanas | +| Frontend (la app, ~9,000 líneas) | 6 – 10 semanas | +| Catálogo PDF + sitio web + sincronización | 3 – 4 semanas | +| Pruebas, despliegue, documentación, manual | 1 – 2 semanas | +| **Total** | **~3.5 – 5 meses** | + +Equivale a **~400 – 600 horas** de desarrollo. + +### Costo según quién lo desarrolle + +| Perfil | Tarifa | Costo total del proyecto | +|---|---|---| +| Freelancer mid (México) | $350 – $550 MXN/hr | **$150,000 – $330,000 MXN** | +| Freelancer senior (México) | $600 – $1,000 MXN/hr | **$250,000 – $500,000 MXN** | +| Agencia de software (México) | proyecto fijo | **$250,000 – $600,000 MXN** | +| Internacional (USD) | $30 – $70 USD/hr | **$12,000 – $35,000 USD** | + +> **Rango realista para construirlo profesionalmente desde cero:** +> **~$180,000 a $450,000 MXN** (≈ **$11,000 – $27,000 USD**), en **3.5 – 5 meses**. + +### Mantenimiento continuo (si lo hiciera un tercero) +- Retainer mensual de soporte/ajustes: **$5,000 – $15,000 MXN/mes**, o +- ~15 – 20% del costo de construcción al año. + +### Comparación con software de renta (SaaS) +Un sistema comercial comparable (Odoo, monday.com + integraciones, o un CRM+ERP a renta) costaría **$3,000 – $12,000 MXN/mes** según usuarios y módulos — y aun así **no se adaptaría** al flujo específico de personalización (talleres externos, bodega con/sin orden, catálogo con storytelling). + +--- + +## 6. Conclusión de valor + +- **Lo que tienes**: un sistema a la medida valuado en **$180k – $450k MXN** de desarrollo, operando con **~$5,000 de equipo** (o ~$100/mes en nube) y **$0 de licencias**. +- **Sin costo recurrente de software** (vs $3k–12k/mes de un SaaS = $36k–144k/año). +- **Datos en tu poder**, adaptado exactamente a cómo trabaja Art4Hotel. +- **Escalable**: para más usuarios/volumen, migrar a una base de datos más robusta (Postgres) y nube — trabajo estimado de días, no meses. + +--- +*Documento generado 2026-05-31. Las cifras de mercado son estimaciones y varían por proveedor y región.* diff --git a/WEB.md b/WEB.md new file mode 100644 index 0000000..1824fca --- /dev/null +++ b/WEB.md @@ -0,0 +1,68 @@ +# Art4Hotel — Sitio web público (art4hotel.com) + +Landing pública que se alimenta del Hub. Captura leads y presenta productos. +Última actualización: 2026-05-31 + +## Infraestructura +- **Repo**: `github.com/Claudeandrefg/art4hotel` (rama `main`) +- **Hosting**: GitHub Pages → `art4hotel.com` (apex) + `www.art4hotel.com` +- **SSL**: Let's Encrypt automático (GitHub Pages). DNS gestionado en **Wix**. + - Apex: 4 A records a GitHub Pages `185.199.108-111.153` + - `www` CNAME → `claudeandrefg.github.io` + - ⚠️ Wix NO deja cambiar nameservers (bloqueados a wixdns) → no se pudo migrar a Cloudflare sin transferir el dominio +- **Carpeta local**: `C:\Users\claud\Documents\Claude\Art 4 Hotel\Pagina Web\art4hotel\` + +## Estructura del sitio +- `index.html` — landing (hero, trust strip, grid productos, proceso, wizard de cotización) +- `gracias.html` — página post-envío del formulario +- `productos.json` — datos de productos (generado por el sync) +- `Recursos/catalogo/` — fotos comprimidas (generadas por el sync) +- `Recursos/` — logos SVG, favicons, imágenes de marca +- `sync_catalogo.py` — script de sincronización Hub → sitio + +## Formulario de leads +- Wizard de 4 pasos (negocio → productos → detalles → contacto) +- Enviado vía **FormSubmit** a `ventas@art4hotel.com` (sin API key, ya activado) +- Campos con `name=` legibles → llegan estructurados al correo +- `_next` redirige a `gracias.html` + +## Catálogo dinámico Hub → Web +Flujo: +``` +HUB sync_catalogo.py SITIO (GitHub Pages) +producto 🌐 mostrar_en_web descarga + comprime fotos grid de productos base +ejemplo 🌐 web_ejemplo (7MB → ~80KB, Pillow) ↓ click ++ web_etiqueta (zona) genera productos.json modal storytelling + git commit + push "Así se ve personalizado" +``` + +### Correr el sync +``` +cd "C:\Users\claud\Documents\Claude\Art 4 Hotel\Pagina Web\art4hotel" +python sync_catalogo.py # descarga + JSON + commit + push (publica) +python sync_catalogo.py --no-push # solo local (para revisar antes) +``` +Requiere estar en la red del Hub (192.168.50.46). Usa Pillow para comprimir. + +### Qué publica +- Productos con `mostrar_en_web=1` Y con foto +- Por producto: nombre, descripcion_web, categoria, personalizaciones (tipos_trabajo), foto base, y **ejemplos** (pedidos con `web_ejemplo=1`) +- Ejemplos: foto + técnica + **web_etiqueta** (zona pública, NUNCA el cliente real → privacidad) + +## Diseño / storytelling +- Grid de productos base; cards con foto + badge de ejemplos +- Click → modal: foto base + descripción + personalizaciones + galería "Así se ve personalizado" (ejemplos grandes, `object-fit:contain` para no recortar) +- Branding A4H (Outfit + Playfair + olive/sand/cream), responsive + +## Workflow de desarrollo del sitio (preview local) +1. Servidor local: `python -m http.server 8899` en la carpeta del repo → `http://localhost:8899` +2. Editar `index.html` local → refrescar localhost (cambio instantáneo) +3. Cuando esté listo: `git add . && git commit && git push` → GitHub Pages publica en ~1 min +4. Nota: la extensión Claude-in-Chrome NO puede navegar a localhost ni art4hotel.com (dominios restringidos); el preview lo ve el usuario en su navegador. + +## Pendientes web +- Filtros por uso (boda/empresa/tienda/hotel) — requiere atributo "uso" en producto +- Secciones por tipo de producto +- Foto base del modal recorta en cuadrado (hacer sin recorte) +- Analytics (Cloudflare Web Analytics o GA) +- Más productos curados + etiquetas de zona en ejemplos diff --git a/backup.py b/backup.py new file mode 100644 index 0000000..3515eb6 --- /dev/null +++ b/backup.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +Art4Hotel Hub — Backup diario +Crea snapshot del DB + uploads, conserva últimos N días. +Corre cada noche via cron. +""" +import sqlite3, shutil, datetime, tarfile, sys +from pathlib import Path + +BASE = Path("/mnt/iclaude/art4hotel-hub") +DB = BASE / "art4hotel.db" +UPLOADS = BASE / "uploads" +BACKUPS = BASE / "backups" +KEEP_DAYS = 30 + + +def log(msg): + ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"[{ts}] {msg}", flush=True) + + +def backup_db(out): + """Use SQLite online backup (safe with WAL, doesn't lock writers).""" + src = sqlite3.connect(str(DB)) + dst = sqlite3.connect(str(out)) + with dst: + src.backup(dst) + src.close() + dst.close() + + +def backup_uploads(out_tar): + if not UPLOADS.exists(): + return 0 + n = 0 + with tarfile.open(str(out_tar), "w:gz") as tar: + for p in UPLOADS.rglob("*"): + if p.is_file(): + tar.add(str(p), arcname=str(p.relative_to(UPLOADS.parent))) + n += 1 + return n + + +def prune_old(): + cutoff = datetime.datetime.now() - datetime.timedelta(days=KEEP_DAYS) + removed = 0 + for d in BACKUPS.iterdir(): + if not d.is_dir(): + continue + try: + date_part = d.name.split("_")[0] + d_date = datetime.datetime.strptime(date_part, "%Y-%m-%d") + if d_date < cutoff: + shutil.rmtree(d) + removed += 1 + except Exception: + pass + return removed + + +def main(): + BACKUPS.mkdir(exist_ok=True) + stamp = datetime.datetime.now().strftime("%Y-%m-%d_%H%M") + out_dir = BACKUPS / stamp + out_dir.mkdir(exist_ok=True) + + log(f"Backup → {out_dir}") + try: + backup_db(out_dir / "art4hotel.db") + log(f" DB OK ({(out_dir/'art4hotel.db').stat().st_size//1024} KB)") + except Exception as e: + log(f" DB ERROR: {e}") + sys.exit(1) + + try: + n = backup_uploads(out_dir / "uploads.tar.gz") + if (out_dir / "uploads.tar.gz").exists(): + log(f" uploads OK ({n} archivos, {(out_dir/'uploads.tar.gz').stat().st_size//1024} KB)") + except Exception as e: + log(f" uploads ERROR: {e}") + + removed = prune_old() + if removed: + log(f" Eliminados {removed} backup(s) > {KEEP_DAYS} días") + + log("Listo.") + + +if __name__ == "__main__": + main() diff --git a/fix_dup.py b/fix_dup.py new file mode 100644 index 0000000..a27e3ac --- /dev/null +++ b/fix_dup.py @@ -0,0 +1,37 @@ +"""One-time: renumber duplicate orden_ids. Keep oldest row (lowest id) with original, +rename later rows to MAX+1, MAX+2, ... Does NOT touch uploads/ folders — files stay +attached to the original (lowest id) pedido, which is the desired behavior.""" +import sqlite3, sys + +DB = 'art4hotel.db' +c = sqlite3.connect(DB) +c.row_factory = sqlite3.Row + +dups = [r['orden_id'] for r in c.execute( + "SELECT orden_id FROM ordenes WHERE orden_id GLOB 'ORD-2026-[0-9][0-9][0-9]' " + "GROUP BY orden_id HAVING COUNT(*)>1")] +print(f'Duplicados encontrados: {len(dups)}') + +max_num = c.execute( + "SELECT MAX(CAST(SUBSTR(orden_id,10,3) AS INTEGER)) FROM ordenes " + "WHERE orden_id GLOB 'ORD-2026-[0-9][0-9][0-9]'").fetchone()[0] or 0 +next_num = max_num + 1 + +renames = [] +for oid in dups: + rows = c.execute("SELECT id, cliente, producto FROM ordenes WHERE orden_id=? ORDER BY id", (oid,)).fetchall() + keep = rows[0] + print(f"\n {oid} KEEP id={keep['id']} ({keep['cliente']} - {keep['producto']})") + for r in rows[1:]: + new_oid = f'ORD-2026-{next_num:03d}' + next_num += 1 + print(f" id={r['id']} ({r['cliente']} - {r['producto']}) → {new_oid}") + renames.append((new_oid, r['id'])) + +if '--apply' in sys.argv: + for new_oid, rid in renames: + c.execute("UPDATE ordenes SET orden_id=? WHERE id=?", (new_oid, rid)) + c.commit() + print(f'\nAplicado: {len(renames)} renombrados.') +else: + print(f'\n(dry-run — corre con --apply para aplicar {len(renames)} cambios)') diff --git a/index.html b/index.html new file mode 100644 index 0000000..b2fdbab --- /dev/null +++ b/index.html @@ -0,0 +1,9771 @@ + + + + + +art 4 hotel — Hub + + + + + + + + +
+
+
+
Ordenes por Stage
+
Clientes Activos (piezas en proceso)
+
+
+
Alertas de Stock
+
Actividad Reciente
+
+
+ + + + + +
+
+
+
Operaciones
+
+ + +
+
+
+ + + + + + + +
+
+
+
+
+
+
+
+ + +
+ + +
+
+
+
Ventas
+
+ + + +
+
+
+
+ + +
+
+
+
⏱ Tiempos de flujo · inicio → entrega
+
🎯 Tipo de Trabajo (entregados)
+
+
🏆 Top clientes · comparativo mensual
+
📦 Productos · precio promedio + volumen
+
+ + + + + + +
+ + +
+
+
Clientes
+
+ + +
+
+
+
+
+
+
+ + + + + + + + +
+
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+
+
Propuestas
+
+ + +
+
+
+
+ +
+
+
+ + +
+ + + + + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ + +
+

Agregar productos

+ +
+
+ + + + +
+
+
Inventario
+ +
+
+
+
+ + + + + +
+
+ + + +
+ +
+

Bienvenido al A4H Hub

+

Esta es la herramienta interna donde gestionamos todo: propuestas, pedidos, producción, bodega, entregas y facturación. Si tienes una duda mientras trabajas, regresa a este manual y busca la sección que necesites.

+
+ Cómo usar este manual: usa el menú de la izquierda para saltar a la sección que te interese. Cada función tiene un instructivo corto + un ejemplo real. +
+

Reglas básicas para todos

+
    +
  • Si no estás seguro, pregúntale a Clod antes de borrar. Editar es seguro, borrar es definitivo.
  • +
  • Cada pedido vive ligado a una Orden de Compra (OC) — excepto resurtidos, muestras y reposiciones.
  • +
  • Captura todo en el sistema, no en WhatsApp. Cuando un cliente confirma algo (cantidad, color, fecha, precio), entra al Hub y lo dejas guardado. Los mensajes resumidos se olvidan y faltan datos al producir.
  • +
+
+ +
+

Tour del sistema

+

Hay 5 pestañas principales arriba:

+
+
🛠

Operaciones

Kanban de producción. Aquí movemos pedidos entre etapas: Nuevo → 2 Mares/Sofía → Almacén → Vehículo → Entregado.

+
💼

Ventas

3 vistas: Dashboard (KPIs y comparativos), Por OC (workflow de cobranza), Por Entregas (entregas históricas).

+
👥

Clientes

Ficha de cada cliente con su historial, contacto, zona de entrega y condiciones de pago.

+
📝

Propuestas

Cotizaciones antes de cerrar. Cuando se aceptan, se convierten en órdenes.

+
📦

Productos

Catálogo de bolsas y accesorios + proyectos recurrentes (recetas autorizadas) + inventario.

+
+
+ +
+

Conceptos clave

+

Antes de operar el sistema, entiende estas 4 ideas:

+ +
+

📝 Propuesta

+

Cotización antes de cerrar venta. Lista de productos con precio, cantidad, descripción del logo. Tiene status: borrador, enviada, aceptada, rechazada.

+
+ +
+

💼 Orden de Compra (OC)

+

El "contrato" con el cliente. Agrupa varios pedidos del mismo cliente bajo un mismo número de factura. Toda OC tiene precio, IVA, fecha de entrega, condiciones de pago.

+
+ +
+

🛠 Pedido

+

Una línea de producción: 1 producto × 1 personalización × 1 cantidad. Vive dentro de una OC (excepto resurtidos/muestras/defectos). Pasa por los stages del kanban.

+
+ +
+

📐 Proyecto recurrente

+

Una receta autorizada = cliente + producto + tipo de trabajo + logo. Se reusa entre pedidos para no volver a definir el diseño. Por ej., "Bolsa Cabo Bello + Logo Flora Mango Serigrafía Negro".

+
+ +

Flujo típico de una venta

+
    +
  1. Propuesta → cliente acepta
  2. +
  3. Botón ➡ Convertir a Orden → se crea OC + pedidos
  4. +
  5. Pedidos arrancan en stage Nuevo
  6. +
  7. Clod los lleva al taller → stage En 2 Mares o En Taller Sofía
  8. +
  9. Clod los recoge → stage En Almacén
  10. +
  11. Se cargan al carro → En Vehículo
  12. +
  13. Se entregan → Entregado
  14. +
  15. Sandra registra factura/recibo, cliente paga
  16. +
+
+ +
+

Crear una propuesta

+
    +
  1. Tab Propuestas → botón + Nueva Propuesta
  2. +
  3. Llena cliente (puedes escribir nombre nuevo o elegir uno registrado), empresa, email, teléfono
  4. +
  5. Agrega items: click en producto del catálogo o crea uno nuevo. Para cada item indica cantidad, precio unitario, color, tipo de personalización
  6. +
  7. Ajusta descuento % e IVA % si aplica
  8. +
  9. Cambia status según corresponda: borrador mientras la armas, enviada cuando la mandaste al cliente, aceptada cuando cierra
  10. +
  11. 👁 Vista previa te muestra cómo la verá el cliente — puedes imprimir o guardar PDF
  12. +
+
+ Tip: si el cliente recorta cantidades o productos antes de aceptar, edita la propuesta primero. La conversión a orden usará lo que esté guardado al momento de convertir. +
+
+ +
+

Convertir propuesta aceptada → Orden

+
    +
  1. Abre la propuesta
  2. +
  3. Cambia status a aceptada
  4. +
  5. Aparece el botón verde ➡ Convertir a Orden
  6. +
  7. Click → se crea automáticamente: +
      +
    • 1 OC con el cliente, precio total, fecha de hoy
    • +
    • N pedidos (uno por línea de la propuesta) en stage Nuevo
    • +
    +
  8. +
  9. El sistema te lleva al editor de la OC nueva — ahí puedes ajustar fecha de entrega, condiciones de pago, etc.
  10. +
+
+ Trazabilidad: la OC queda ligada a la propuesta original. Si más adelante quieres ver la propuesta de donde salió, abre la OC y revisa la nota. +
+
+ Si ya convertiste y el cliente cambia algo: edita los pedidos manualmente desde Operaciones (borra, agrega, edita). La propuesta queda como histórico de la venta original. +
+
+ +
+

Crear una OC manual (sin propuesta)

+

Cuando un cliente confirma un pedido sin propuesta formal (WhatsApp, llamada), creas la OC directo:

+
    +
  1. Tab Ventas → sub-vista 📋 Por OC
  2. +
  3. Botón + Nueva Orden
  4. +
  5. Llena: cliente, fecha de OC, precio facturado, IVA, condiciones de pago, número de factura (si ya tienes)
  6. +
  7. Guarda → ahora puedes agregarle pedidos uno por uno
  8. +
+
+ +
+

Crear un pedido suelto

+

Pedidos que no van bajo una OC: resurtidos (stock propio), muestras (prospección), defectos/faltantes (reposición).

+
    +
  1. Tab Operaciones → botón + Nuevo
  2. +
  3. Selecciona tipo de orden: +
      +
    • OC — pedido normal con orden de compra
    • +
    • Resurtido — producción libre para bodega
    • +
    • Muestra — 1 pieza para presentar a prospecto
    • +
    • Defecto / Faltante — reposición
    • +
    +
  4. +
  5. Llena producto, cantidad, tipo de trabajo, cliente
  6. +
  7. Si es OC → asigna a una OC existente o créala
  8. +
+
+ +
+

Mover pedidos en el kanban (Operaciones)

+

El kanban tiene columnas que representan dónde está el pedido físicamente:

+ + + + + + + + +
StageSignificado
NuevoRecién creado, aún no se ha movido
En 2 MaresEstá físicamente en taller 2 Mares
En Taller SofíaEstá físicamente con Sofía (costura)
En AlmacénProducto terminado, esperando entregar
En VehículoCargado para entregar hoy
EntregadoCliente lo recibió
+

Cómo moverlos

+

Opción 1 — arrastrar: agarra la tarjeta con el mouse y suéltala en la columna nueva.

+

Opción 2 — botones: en cada tarjeta hay botones contextuales:

+
    +
  • 📦 Recoger aparece si está en 2 Mares o Taller Sofía → mueve a Almacén
  • +
  • ✓ Entregar aparece si está En Vehículo → abre modal de entrega
  • +
+
+ +
+

Recoger del taller

+

Cuando vas físicamente al taller y traes el producto terminado:

+
    +
  1. Click en el pedido o botón 📦 Recoger de la tarjeta
  2. +
  3. Indica cuántas piezas recibiste realmente
  4. +
  5. Si todas → pasa a Almacén automáticamente
  6. +
  7. Si hay piezas dañadas → crea automáticamente una orden tipo Defecto con la cantidad para reposición
  8. +
  9. Si hay piezas faltantes/pendientes → divide el pedido: lo recibido va a Almacén, lo pendiente queda en el stage anterior
  10. +
+
+ +
+

Ver pedidos en bodega

+

En el tab Operaciones debajo del kanban hay un panel de Bodega que muestra todo lo que está en stage En Almacén + En Vehículo.

+

Agrupado por:

+
    +
  • Con Orden — pedidos de hoteles/órdenes específicas, listos para entregar al cliente correcto
  • +
  • Sin Orden — resurtidos, stock libre para POS o venta rápida
  • +
+

Click en cualquier card para ver detalle.

+
+ +
+

Registrar una entrega

+
    +
  1. Pedidos listos para entregar están en stage En Vehículo
  2. +
  3. En el tab Ventas → Por Entregas verás un panel arriba con todos los En Vehículo
  4. +
  5. Botón ✓ Entregar en cada uno
  6. +
  7. Llena: fecha de entrega, quién recibió (firma), notas si las hay
  8. +
  9. Sube foto del producto entregado o recibo firmado
  10. +
  11. Guardar → pasa a stage Entregado
  12. +
+
+ +
+

Subir foto de avance

+

Foto del producto en proceso o terminado, ligada al pedido:

+
    +
  1. Abre el pedido (click en cualquier tarjeta)
  2. +
  3. En la vista del pedido, debajo de la foto principal hay un botón 📷 Subir foto de avance
  4. +
  5. Selecciona la foto desde tu celular/computadora
  6. +
  7. Se sube automáticamente y queda asociada solo a ese pedido
  8. +
+
+ Importante: esta foto vive en el pedido específico. NO es la foto del catálogo. Para cambiar la foto general del producto, ve a Productos → Editar producto. +
+
+ +
+

Registrar costos y precios

+

Cada pedido tiene 3 costos internos + el precio que cobramos:

+ + + + + + +
CampoQué es
Costo ProductoLo que pagamos por el producto base (proveedor)
Costo TrabajoLo que cobra 2 Mares / Sofía por la personalización
Costo LogísticaGasolina, viáticos, etc. (se aplica una vez por OC)
Precio FacturaLo que cobramos al cliente por pza × cantidad
+

Margen: el sistema calcula automáticamente (precio - costo total) / precio.

+ +

Precios mínimos / spread por producto

+

En el tab Ventas → Dashboard, sección "📦 Productos · precio promedio + volumen" muestra el precio mín, max y promedio histórico por producto. Útil para cotizar consistentemente.

+

Si el spread es >30%, sale alerta ámbar — significa que ese producto se ha vendido a precios muy distintos y vale la pena revisar política.

+
+ +
+

💼 Seguimiento de ventas

+

Aquí entra todo lo de atender clientes, cerrar ventas y capturar pedidos completos en el sistema.

+ +
+ Regla de oro: cuando un cliente confirme algo (cantidad, color, fecha, precio, logo), captúralo aquí, no en WhatsApp ni en un mensaje resumido. Los detalles que se pierden cuestan tiempo y dinero al producir. +
+ +

🔁 Flujo típico de una venta

+
    +
  1. Cliente pide cotización → abre Propuestas → + Nueva Propuesta
  2. +
  3. Cliente acepta → cambia status a aceptada y presiona ➡ Convertir a Orden
  4. +
  5. Cliente cambia algo antes de aceptar → edita la propuesta y guarda
  6. +
  7. Cliente cierra sin propuesta formal (WhatsApp directo) → ve a Ventas → Por OC → + Nueva Orden y captura todo
  8. +
+ +

Información mínima por pedido

+

Antes de guardar cualquier pedido en el sistema, asegúrate de tener:

+ + + + + + + + + + + +
DatoPor qué importa
ClienteSi es nuevo, créalo en Clientes con zona de entrega y condiciones de pago
Producto exactoDel catálogo. Si no existe, créalo (no escribas "bolsa de tela" — pide el modelo específico)
CantidadTotal de piezas
Tipo de personalizaciónBordado, Serigrafía, DTF UV, etc. Si el cliente no especifica, pregunta
Logo / descripciónQué se imprime y dónde. Sube el archivo del logo a la propuesta o pedido
Color del producto baseNatural, negro, crema, etc.
Precio por piezaLo que se cobra al cliente
Fecha de entrega esperadaPara que entre en el calendario operativo
Condiciones de pagoUna de las 6 opciones disponibles
+ +
+ Tip: si el cliente te dice algo importante por WhatsApp (cambio de fecha, descuento acordado, nota de logística), copia y pega ese mensaje en las notas internas del pedido u OC. Así queda registro. +
+ +

Tus secciones clave

+
    +
  • Propuestas: arma cotizaciones con fotos, items con descripción completa
  • +
  • Ventas → Por OC: crea órdenes manuales para ventas que vienen sin propuesta
  • +
  • Clientes: ficha del cliente con histórico, condiciones, contacto
  • +
  • Operaciones: ahí verás los pedidos avanzar después de capturarlos
  • +
+
+ +
+

📊 Seguimiento de contabilidad y facturación

+

Funciones para cobranza, facturación y reportes financieros.

+ +

🔁 Flujo típico

+
    +
  1. Cliente paga → Tab Ventas → Por OC, busca la OC, márcala como pagada y registra método de pago
  2. +
  3. Cliente pide factura → editor de OC, llena N° Factura y demás datos fiscales
  4. +
  5. Cliente nuevo cierra → Clientes → crea ficha con condiciones de pago acordadas
  6. +
  7. Reporte fin de mes → Tab Ventas → Dashboard, ahí están los comparativos mes actual vs anterior vs año pasado
  8. +
+ +

Tus secciones clave

+
    +
  • Ventas → Por OC: kanban de cobranza, cada OC con su status
  • +
  • Ventas → Por Entregas: entregas históricas con detalle de factura, editable inline
  • +
  • Ventas → Dashboard: KPIs, comparativos mensuales, top clientes, márgenes, tiempos de flujo
  • +
  • Clientes: ficha con histórico de compras, condiciones, notas
  • +
+ +

Pasos para facturar una OC

+
    +
  1. Abre la OC desde Ventas → Por OC
  2. +
  3. Llena: Precio Factura (subtotal sin IVA), IVA % (default 16), N° Factura
  4. +
  5. Opcional: Costo Logística, Otros gastos
  6. +
  7. Sube el PDF de la factura como archivo de la OC (botón 📁 Soporte, tipo "Factura")
  8. +
  9. Guarda
  10. +
+ +

Condiciones de pago disponibles

+ + + + + + + +
Por definirDefault cuando aún no sabemos
A la entregaPago al momento de entregar
Crédito 30 díasEstándar para hoteles
ConsignaciónPara arrancar con clientes nuevos / probar
EfectivoPago en efectivo en el momento
Anticipo 50%Pedidos especiales o si piden descuento
+
+ +
+

💰 Costos y precios mínimos

+

Cómo registrar lo que nos cuesta producir y cómo asegurar que no vendemos por debajo.

+ +

4 valores económicos por pedido

+ + + + + + +
CampoQué esQuién lo carga
Costo ProductoLo que pagamos al proveedor por el producto baseAl recibir mercancía / al cotizar
Costo TrabajoLo que cobra 2 Mares / Sofía por la personalizaciónAl confirmar trabajo con taller
Costo LogísticaGasolina, viáticos. Aplica una vez por OC, no por pedidoAl cerrar entrega
Precio FacturaLo que cobramos al cliente (sin IVA)Al cerrar venta
+ +

Cómo ver precios mínimos por producto

+

En el tab Ventas → Dashboard, sección "📦 Productos · precio promedio + volumen" muestra:

+
    +
  • Precio promedio histórico por pieza de cada producto
  • +
  • Mín y Max históricos — útil para no vender por debajo del piso
  • +
  • Costo base promedio — para comparar margen
  • +
  • Spread — diferencia entre mín y max. Si es >30%, sale alerta ámbar (significa precios muy variables, vale revisar política)
  • +
+ +
+ Tip: antes de cotizar un producto, revisa esta tabla para usar referencia histórica. Si el cliente nuevo quiere precio bajo, compara contra el mín histórico y el costo base para no quedar en pérdida. +
+ +

Margen calculado

+

El sistema calcula automáticamente (precio − costo total) / precio. Aparece en el quickview de cada pedido y en el dashboard. Si ves un margen rojo, ese pedido va en pérdida.

+ +

Registrar costos al cotizar

+

Cuando armas una propuesta o pedido nuevo, llena Costo Producto y Costo Trabajo aunque sean estimados — así el sistema puede mostrar margen desde el día 1. Después se ajustan con los costos reales cuando llegue la factura del proveedor.

+
+ + + +
+

💬 Preguntas frecuentes

+ +
+

"Subí una foto y se vinculó a otro pedido"

+

Probablemente los dos pedidos tienen el mismo orden_id. Esto ya no debería pasar (el sistema valida unicidad), pero si lo ves: dile a Clod, revisa la lista de operaciones y renombra el duplicado.

+
+ +
+

"¿Cómo borro una orden?"

+

Abre el pedido/OC, botón rojo 🗑 al final. Antes de borrar revisa que no sea un error de cliente — borrar es definitivo.

+
+ +
+

"El cliente cambió la cantidad después de aceptar la propuesta"

+

Si aún no convertiste: edita la propuesta antes de convertir. Si ya convertiste: edita el pedido en Operaciones, o borra el que sobra.

+
+ +
+

"¿Cómo manejo un defecto/reposición?"

+

Cuando recoges del taller, usa el botón 📦 Recoger y elige "Defecto" con la cantidad dañada. El sistema crea automáticamente un pedido tipo Defecto para reposición.

+
+ +
+

"¿Qué hago si un pedido se canceló?"

+

Edita el pedido, cambia stage a Cancelado. No se borra, queda en el histórico con explicación en notas.

+
+ +
+

"¿Por qué el dashboard de Ventas muestra menos que lo que vendí?"

+

Solo cuenta pedidos en stage Entregado y con precio_factura registrado. Si te falta dinero, busca pedidos entregados sin precio cargado.

+
+ +
+

"No encuentro un cliente / producto"

+

Usa el buscador en cada tab (arriba a la derecha). Si no aparece en Productos, créalo desde el catálogo. Si no aparece en Clientes, créalo desde Clientes.

+
+ +
+

"¿Puedo usar esto desde el celular?"

+

Sí, la app es responsive. Algunas vistas (kanban, dashboard) se ven mejor en computadora. Para entregar en campo, móvil funciona bien.

+
+ +
+

"Olvidé subir foto de avance, ya está entregado"

+

Sin problema — abre el pedido entregado (filtros: + Entregados en Operaciones), botón 📷 abajo de la foto para subirla después.

+
+ +
+

"¿Cuándo uso 'Resurtido' vs 'OC'?"

+

OC = pedido específico para un cliente. Resurtido = producción libre para tener stock disponible en bodega (sin cliente asignado). Útil para tiendas que rotan inventario.

+
+
+ +
+

Vocabulario del sistema

+ + + + + + + + + + + + + + + + + +
TérminoSignificado
OCOrden de Compra. Agrupa pedidos de un cliente bajo una factura.
PedidoLínea de producción individual. 1 producto × 1 personalización × cantidad.
PropuestaCotización antes de cerrar venta.
Proyecto recurrenteReceta autorizada reutilizable (cliente+producto+trabajo+logo).
StageLa etapa en la que está el pedido (Nuevo, En Taller, etc.).
Tipo TrabajoLa personalización: Bordado, Serigrafía, DTF UV, etc.
2 MaresTaller externo principal de bordado/serigrafía.
Taller SofíaTaller externo de costura/modificaciones.
RecogerIr físicamente al taller y traer producto terminado.
ResurtidoProducción sin OC, para tener stock en bodega.
Muestra1 pieza para presentar a prospecto antes de cerrar venta.
DefectoReposición de piezas dañadas que llegaron del taller.
FaltanteReposición de piezas que faltaron en una entrega.
BodegaPedidos en stage Almacén/Vehículo, agrupados con/sin OC.
Spread (precio)Diferencia entre precio mínimo y máximo histórico de un producto.
+

Última actualización: 2026-05-28 · Si encuentras algo desactualizado, dile a Clod.

+
+ +
+
+
+ + +
+
+
+
Tareas
+
+ + +
+
+
+ + +
+
+
+ +
+ + +
+
+
+
Bitacora
+
+ + +
+
+ +
+
+ +
+ + + + +
+

Nuevo Pedido

+
+
+ +
+
+
+ + +
+
+
+
+ +
+ + + + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
Costo total$0
+
Factura$0
+
Utilidad$0 (0%)
+
+
+ +
+
+
+
+ + +
+
Vincula este pedido a una Orden de Compra del cliente
+
+
+
+
+
+
+ + +
+
+ + +
+

Nuevo SKU

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + +
+

Nueva Tarea

+
+
+
+
+
+
+
+
+ +
+ + + +
+

Nueva Orden

+
La Orden agrupa varios pedidos que se entregan y facturan juntos
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+ +
+

Registrar

+
+
+
+ +
+ + +
+

Registrar Orden Pasada

+
Para vaciar entregas anteriores con sus fechas reales
+
+
+ + + +
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+

Agregar producto

+
+ + +
+ + +
+
+ + +
+
+
+
+ + +
+

¿Qué quieres hacer?

+
+
+ + + +
+
+ + +
+

Entrega parcial

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + +
+

Editar

+
+ +
+ + +
+

Recoger Orden

+
+
+ +
+ +
+ + +
+

Confirmar Entrega

+
+
+
+
+
+
Subir soporte (opcional)
+
+ + + +
+ +
+
+ +
+ + +
+

Agregar

+
+ +
+ + +
+

Archivos

+
+
+ +
+
+
Click o arrastra archivos
+
JPG, PNG, PDF — max 20MB
+
+
+ + +
+
+ + + + +
+ + + + diff --git a/server.py b/server.py new file mode 100644 index 0000000..5073ade --- /dev/null +++ b/server.py @@ -0,0 +1,1577 @@ +""" +Art4Hotel Hub v2 - Servidor local +Catalogo estandarizado + tabulador de precios + entregas + mobile +Zero dependencias externas (stdlib only) + +Uso: + python server.py + http://localhost:4401 +""" + +import http.server, json, sqlite3, os, urllib.parse, re, mimetypes, io +import hashlib, hmac, base64, secrets, time, http.cookies +from datetime import datetime, date +from pathlib import Path + +PORT = 4401 +DB_PATH = Path(__file__).parent / "art4hotel.db" +STATIC_DIR = Path(__file__).parent +UPLOADS_DIR = Path(__file__).parent / "uploads" +UPLOADS_DIR.mkdir(exist_ok=True) +MAX_UPLOAD = 20 * 1024 * 1024 # 20MB + +# ── Autenticación ── +SESSION_HOURS = 24 * 14 # la sesión dura 14 días +SECRET_FILE = Path(__file__).parent / "secret.key" +def _load_secret(): + if SECRET_FILE.exists(): + return SECRET_FILE.read_bytes() + s = secrets.token_bytes(32) + SECRET_FILE.write_bytes(s) + try: os.chmod(SECRET_FILE, 0o600) + except Exception: pass + return s +SECRET = _load_secret() + +def hash_password(password, salt=None): + if salt is None: salt = secrets.token_hex(16) + h = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 200000).hex() + return salt + '$' + h + +def verify_password(password, stored): + try: + salt, _ = stored.split('$', 1) + return hmac.compare_digest(hash_password(password, salt), stored) + except Exception: + return False + +def make_session(username): + exp = int(time.time()) + SESSION_HOURS * 3600 + payload = f"{username}|{exp}" + sig = hmac.new(SECRET, payload.encode(), hashlib.sha256).hexdigest() + return base64.urlsafe_b64encode(payload.encode()).decode() + '.' + sig + +def check_session(token): + """Devuelve el username si el token es válido y no expiró, si no None.""" + try: + b64, sig = token.split('.', 1) + payload = base64.urlsafe_b64decode(b64.encode()).decode() + expect = hmac.new(SECRET, payload.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(sig, expect): return None + username, exp = payload.split('|') + if int(exp) < time.time(): return None + return username + except Exception: + return None + + +def get_db(): + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def init_db(): + conn = get_db() + c = conn.cursor() + + # ── Catalogo: Modelos (lineas de producto) ── + c.execute(""" + CREATE TABLE IF NOT EXISTS modelos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + clave TEXT UNIQUE NOT NULL, + nombre TEXT NOT NULL, + descripcion TEXT DEFAULT '', + activo INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now','localtime')) + ) + """) + + # ── Catalogo: Materiales ── + c.execute(""" + CREATE TABLE IF NOT EXISTS materiales ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + clave TEXT UNIQUE NOT NULL, + nombre TEXT NOT NULL, + tipo TEXT DEFAULT 'base', + costo_unitario REAL DEFAULT 0, + activo INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now','localtime')) + ) + """) + + # ── Catalogo: Tipos de trabajo ── + c.execute(""" + CREATE TABLE IF NOT EXISTS trabajos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + clave TEXT UNIQUE NOT NULL, + nombre TEXT NOT NULL, + costo_base REAL DEFAULT 0, + variable_por TEXT DEFAULT 'fijo', + proveedor_default TEXT DEFAULT '', + activo INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now','localtime')) + ) + """) + + # ── Catalogo: Productos (reemplaza inventario viejo) ── + c.execute(""" + CREATE TABLE IF NOT EXISTS productos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sku TEXT UNIQUE NOT NULL, + nombre TEXT NOT NULL, + categoria TEXT DEFAULT 'bolsa', + talla TEXT DEFAULT '', + material TEXT DEFAULT '', + costo_base REAL DEFAULT 0, + proveedor TEXT DEFAULT '', + stock_actual INTEGER DEFAULT 0, + punto_reorden INTEGER DEFAULT 10, + activo INTEGER DEFAULT 1, + notas TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now','localtime')), + updated_at TEXT DEFAULT (datetime('now','localtime')) + ) + """) + # (Campos legacy color/modelo/logo_diseno/tipo_personalizacion retirados 2026-05-31) + + # ── Usuarios (login del Hub) ── + c.execute(""" + CREATE TABLE IF NOT EXISTS usuarios ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + pass_hash TEXT NOT NULL, + nombre TEXT DEFAULT '', + activo INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now','localtime')) + ) + """) + + # ── Clientes (ampliado) ── + c.execute(""" + CREATE TABLE IF NOT EXISTS clientes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nombre TEXT UNIQUE NOT NULL, + tipo TEXT DEFAULT 'hotel', + contacto TEXT DEFAULT '', + zona_entrega TEXT DEFAULT '', + costo_entrega REAL DEFAULT 0, + condiciones_pago TEXT DEFAULT 'Por definir', + notas TEXT DEFAULT '', + activo INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now','localtime')) + ) + """) + + # ── OC (Orden de Compra del cliente — agrupa lineas) ── + c.execute(""" + CREATE TABLE IF NOT EXISTS oc ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + oc_id TEXT UNIQUE NOT NULL, + cliente TEXT DEFAULT '', + fecha_oc TEXT DEFAULT '', + fecha_entrega TEXT DEFAULT '', + recibio TEXT DEFAULT '', + costo_logistica REAL DEFAULT 0, + precio_factura REAL DEFAULT 0, + iva_pct REAL DEFAULT 16, + factura_num TEXT DEFAULT '', + condiciones_pago TEXT DEFAULT 'Por definir', + status TEXT DEFAULT 'Activa', + notas TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now','localtime')), + updated_at TEXT DEFAULT (datetime('now','localtime')) + ) + """) + # Migrate OC: add iva_pct, otros_gastos, payment fields if missing + existing_oc = {r[1] for r in c.execute("PRAGMA table_info(oc)").fetchall()} + if 'iva_pct' not in existing_oc: + c.execute("ALTER TABLE oc ADD COLUMN iva_pct REAL DEFAULT 16") + if 'otros_gastos' not in existing_oc: + c.execute("ALTER TABLE oc ADD COLUMN otros_gastos REAL DEFAULT 0") + if 'otros_gastos_desc' not in existing_oc: + c.execute("ALTER TABLE oc ADD COLUMN otros_gastos_desc TEXT DEFAULT ''") + if 'pagado' not in existing_oc: + c.execute("ALTER TABLE oc ADD COLUMN pagado INTEGER DEFAULT 0") + if 'fecha_pago' not in existing_oc: + c.execute("ALTER TABLE oc ADD COLUMN fecha_pago TEXT DEFAULT ''") + if 'metodo_pago' not in existing_oc: + c.execute("ALTER TABLE oc ADD COLUMN metodo_pago TEXT DEFAULT ''") + if 'oc_origen_id' not in existing_oc: + c.execute("ALTER TABLE oc ADD COLUMN oc_origen_id INTEGER DEFAULT NULL") + if 'propuesta_id' not in existing_oc: + c.execute("ALTER TABLE oc ADD COLUMN propuesta_id INTEGER DEFAULT NULL") + + # ── Catálogos (presentaciones de productos para enviar a prospectos) ── + c.execute(""" + CREATE TABLE IF NOT EXISTS catalogos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nombre TEXT NOT NULL, + segmento TEXT DEFAULT '', + cliente_nombre TEXT DEFAULT '', + fecha TEXT DEFAULT '', + items TEXT DEFAULT '[]', + show_prices INTEGER DEFAULT 1, + show_clientes INTEGER DEFAULT 1, + show_lead_time INTEGER DEFAULT 1, + notas TEXT DEFAULT '', + status TEXT DEFAULT 'borrador', + entrega TEXT DEFAULT '2-4 semanas', + minimo_compra TEXT DEFAULT '', + terminos TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now','localtime')), + updated_at TEXT DEFAULT (datetime('now','localtime')) + ) + """) + # Migración catalogos: términos comerciales (tabla puede existir sin estas columnas) + existing_cat = {r[1] for r in c.execute("PRAGMA table_info(catalogos)").fetchall()} + if 'entrega' not in existing_cat: + c.execute("ALTER TABLE catalogos ADD COLUMN entrega TEXT DEFAULT '2-4 semanas'") + if 'minimo_compra' not in existing_cat: + c.execute("ALTER TABLE catalogos ADD COLUMN minimo_compra TEXT DEFAULT ''") + if 'terminos' not in existing_cat: + c.execute("ALTER TABLE catalogos ADD COLUMN terminos TEXT DEFAULT ''") + if 'show_contacto' not in existing_cat: + c.execute("ALTER TABLE catalogos ADD COLUMN show_contacto INTEGER DEFAULT 1") + + # ── Ordenes (lineas de produccion, ahora con oc_id FK) ── + c.execute(""" + CREATE TABLE IF NOT EXISTS ordenes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + orden_id TEXT NOT NULL, + oc_id INTEGER DEFAULT NULL REFERENCES oc(id), + tipo_orden TEXT DEFAULT 'OC', + cliente TEXT DEFAULT '', + producto TEXT DEFAULT '', + sku TEXT DEFAULT '', + cantidad INTEGER DEFAULT 0, + tipo_trabajo TEXT DEFAULT '', + stage TEXT NOT NULL DEFAULT 'En 2 Mares', + fecha_oc TEXT DEFAULT '', + fecha_inicio TEXT DEFAULT '', + fecha_estimada TEXT DEFAULT '', + fecha_recepcion TEXT DEFAULT '', + fecha_entrega TEXT DEFAULT '', + recibio TEXT DEFAULT '', + urgente INTEGER DEFAULT 0, + logo_instrucciones TEXT DEFAULT '', + notas TEXT DEFAULT '', + costo_producto REAL DEFAULT 0, + costo_trabajo REAL DEFAULT 0, + costo_logistica REAL DEFAULT 0, + precio_factura REAL DEFAULT 0, + check_facturada INTEGER DEFAULT 0, + check_empacada INTEGER DEFAULT 0, + check_etiquetas INTEGER DEFAULT 0, + check_vehiculo INTEGER DEFAULT 0, + grupo_oc TEXT DEFAULT '', + piezas_recibidas INTEGER DEFAULT 0, + piezas_danadas INTEGER DEFAULT 0, + nota_recepcion TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now','localtime')), + updated_at TEXT DEFAULT (datetime('now','localtime')) + ) + """) + + # Migrate: add new columns if they don't exist (for existing DBs) + new_cols_ordenes = [ + ("costo_producto","REAL DEFAULT 0"), + ("costo_trabajo","REAL DEFAULT 0"), + ("costo_logistica","REAL DEFAULT 0"), + ("precio_factura","REAL DEFAULT 0"), + ("check_facturada","INTEGER DEFAULT 0"), + ("check_empacada","INTEGER DEFAULT 0"), + ("check_etiquetas","INTEGER DEFAULT 0"), + ("check_vehiculo","INTEGER DEFAULT 0"), + ("grupo_oc","TEXT DEFAULT ''"), + ("piezas_recibidas","INTEGER DEFAULT 0"), + ("piezas_danadas","INTEGER DEFAULT 0"), + ("nota_recepcion","TEXT DEFAULT ''"), + ("oc_id","INTEGER DEFAULT NULL"), + ] + existing = {r[1] for r in c.execute("PRAGMA table_info(ordenes)").fetchall()} + for col, typedef in new_cols_ordenes: + if col not in existing: + c.execute(f"ALTER TABLE ordenes ADD COLUMN {col} {typedef}") + + # ── Inventario legacy (keep for backward compat, hidden) ── + c.execute(""" + CREATE TABLE IF NOT EXISTS inventario ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sku TEXT UNIQUE NOT NULL, + nombre TEXT DEFAULT '', + descripcion TEXT DEFAULT '', + tipo TEXT DEFAULT '', + talla TEXT DEFAULT '', + color_base TEXT DEFAULT '', + proveedor TEXT DEFAULT '', + stock_inicial INTEGER DEFAULT 0, + punto_reorden INTEGER DEFAULT 10, + costo_unitario REAL DEFAULT 0, + ultimo_conteo TEXT DEFAULT '', + notas TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now','localtime')), + updated_at TEXT DEFAULT (datetime('now','localtime')) + ) + """) + + # ── Compras ── + c.execute(""" + CREATE TABLE IF NOT EXISTS compras ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + compra_id TEXT NOT NULL, + proveedor TEXT DEFAULT '', + fecha_compra TEXT DEFAULT '', + fecha_llegada TEXT DEFAULT '', + sku TEXT DEFAULT '', + nombre_producto TEXT DEFAULT '', + cantidad INTEGER DEFAULT 0, + costo_unitario REAL DEFAULT 0, + status TEXT DEFAULT 'Pedido', + notas TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now','localtime')), + updated_at TEXT DEFAULT (datetime('now','localtime')) + ) + """) + + # ── Tareas operativas ── + c.execute(""" + CREATE TABLE IF NOT EXISTS tareas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + titulo TEXT NOT NULL, + descripcion TEXT DEFAULT '', + prioridad TEXT DEFAULT 'normal', + stage TEXT NOT NULL DEFAULT 'pendiente', + categoria TEXT DEFAULT 'general', + fecha_limite TEXT DEFAULT '', + asignado TEXT DEFAULT '', + notas TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now','localtime')), + updated_at TEXT DEFAULT (datetime('now','localtime')) + ) + """) + + # ── Proyectos Recurrentes (receta cliente+modelo+logo+trabajo) ── + c.execute(""" + CREATE TABLE IF NOT EXISTS proyectos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nombre TEXT NOT NULL, + producto_id INTEGER REFERENCES productos(id), + producto_nombre TEXT DEFAULT '', + cliente TEXT DEFAULT '', + tipo_trabajo TEXT DEFAULT '', + costo_unitario REAL DEFAULT 0, + costo_trabajo REAL DEFAULT 0, + logo_descripcion TEXT DEFAULT '', + logo_archivo TEXT DEFAULT '', + foto_terminado TEXT DEFAULT '', + notas TEXT DEFAULT '', + activo INTEGER DEFAULT 1, + veces_usado INTEGER DEFAULT 0, + ultimo_uso TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now','localtime')), + updated_at TEXT DEFAULT (datetime('now','localtime')) + ) + """) + + # Migrate ordenes: add proyecto_id reference + existing_ord = {r[1] for r in c.execute("PRAGMA table_info(ordenes)").fetchall()} + if 'proyecto_id' not in existing_ord: + c.execute("ALTER TABLE ordenes ADD COLUMN proyecto_id INTEGER DEFAULT NULL") + # Curaduría web: ¿este pedido/foto se muestra como ejemplo en el sitio público? + if 'web_ejemplo' not in existing_ord: + c.execute("ALTER TABLE ordenes ADD COLUMN web_ejemplo INTEGER DEFAULT 0") + # Etiqueta pública para el ejemplo (zona genérica, "muestra"...) — NO el cliente real + if 'web_etiqueta' not in existing_ord: + c.execute("ALTER TABLE ordenes ADD COLUMN web_etiqueta TEXT DEFAULT ''") + + # Migrate productos: add tipos_trabajo_disponibles (CSV) + existing_pr = {r[1] for r in c.execute("PRAGMA table_info(productos)").fetchall()} + if 'tipos_trabajo_disponibles' not in existing_pr: + c.execute("ALTER TABLE productos ADD COLUMN tipos_trabajo_disponibles TEXT DEFAULT ''") + # Flag para publicar el producto en el sitio web público (art4hotel.com) + if 'mostrar_en_web' not in existing_pr: + c.execute("ALTER TABLE productos ADD COLUMN mostrar_en_web INTEGER DEFAULT 0") + # Descripción corta para el sitio web (opcional, editable desde el catálogo) + if 'descripcion_web' not in existing_pr: + c.execute("ALTER TABLE productos ADD COLUMN descripcion_web TEXT DEFAULT ''") + # Medidas físicas del producto (texto libre, ej. "40 x 35 x 12 cm") + if 'medidas' not in existing_pr: + c.execute("ALTER TABLE productos ADD COLUMN medidas TEXT DEFAULT ''") + + # ── Propuestas / Cotizaciones ── + c.execute(""" + CREATE TABLE IF NOT EXISTS propuestas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + numero TEXT UNIQUE NOT NULL, + cliente_nombre TEXT DEFAULT '', + contacto TEXT DEFAULT '', + empresa TEXT DEFAULT '', + direccion TEXT DEFAULT '', + locacion TEXT DEFAULT '', + tipo_negocio TEXT DEFAULT '', + email TEXT DEFAULT '', + telefono TEXT DEFAULT '', + fecha TEXT DEFAULT '', + vigencia_dias INTEGER DEFAULT 15, + items TEXT DEFAULT '[]', + iva_pct REAL DEFAULT 16, + descuento_pct REAL DEFAULT 0, + notas TEXT DEFAULT '', + status TEXT DEFAULT 'borrador', + created_at TEXT DEFAULT (datetime('now','localtime')), + updated_at TEXT DEFAULT (datetime('now','localtime')) + ) + """) + # Migrate: add locacion if missing + existing_pp = {r[1] for r in c.execute("PRAGMA table_info(propuestas)").fetchall()} + if 'locacion' not in existing_pp: + c.execute("ALTER TABLE propuestas ADD COLUMN locacion TEXT DEFAULT ''") + + # ── Bitacora ── + c.execute(""" + CREATE TABLE IF NOT EXISTS bitacora ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tipo TEXT NOT NULL DEFAULT 'nota', + titulo TEXT NOT NULL, + descripcion TEXT DEFAULT '', + referencia TEXT DEFAULT '', + fecha TEXT DEFAULT (datetime('now','localtime')) + ) + """) + + conn.commit() + + # Seed catalogo if empty + if c.execute("SELECT COUNT(*) FROM modelos").fetchone()[0] == 0: + seed_catalogo(conn) + + # Seed ordenes if empty + if c.execute("SELECT COUNT(*) FROM ordenes").fetchone()[0] == 0: + seed_data(conn) + + conn.close() + + +def seed_catalogo(conn): + """Seed catalog tables with initial data from Art4Hotel's real products""" + c = conn.cursor() + + modelos = [ + ("marina","Marina","Linea de bolsas con acabado nautico/yute"), + ("cabo_bello","Cabo Bello","Diseno exclusivo Cabo Bello"), + ("2026","2026","Coleccion 2026 - DTF"), + ("east_cape","East Cape","Linea para zona East Cape/Los Barriles"), + ("basica","Basica","Productos sin personalizacion de linea"), + ("flora_mango","Flora Mango","Linea exclusiva Flora Farms"), + ] + for m in modelos: + c.execute("INSERT INTO modelos (clave,nombre,descripcion) VALUES (?,?,?)", m) + + materiales = [ + ("yute","Yute","base",0), + ("manta","Manta cruda","base",0), + ("loneta","Loneta","base",0), + ("lona","Lona","base",0), + ("piel","Piel","secundario",0), + ("ceramica","Ceramica","base",0), + ("metal","Metal","base",0), + ("madera","Madera","base",0), + ] + for m in materiales: + c.execute("INSERT INTO materiales (clave,nombre,tipo,costo_unitario) VALUES (?,?,?,?)", m) + + trabajos = [ + ("bordado","Bordado",35,"complejidad","2 Mares"), + ("serigrafia","Serigrafia",25,"fijo","2 Mares"), + ("grabado_laser","Grabado laser",30,"tamano","2 Mares"), + ("impresion_dtf","Impresion DTF",20,"fijo","2 Mares"), + ("costura","Costura",15,"fijo","Taller Sofia"), + ("otro","Otro",0,"fijo",""), + ] + for t in trabajos: + c.execute("INSERT INTO trabajos (clave,nombre,costo_base,variable_por,proveedor_default) VALUES (?,?,?,?,?)", t) + + clientes = [ + ("Nobu Hotel","hotel","","Corredor turistico",200,"Contra entrega",""), + ("Nobu Residences","hotel","","Corredor turistico",200,"Contra entrega",""), + ("Flora Farms","hotel","Ximena","San Jose del Cabo",150,"30 dias","Logo Flora Mango"), + ("La Ventana","hotel","","La Ventana",350,"Contra entrega",""), + ("Puerta Cortes","hotel","","Corredor turistico",200,"Contra entrega",""), + ("The Cape","hotel","","Corredor turistico",200,"Contra entrega",""), + ("R Residence","hotel","","Corredor turistico",200,"Contra entrega","OC por 100 pzas"), + ("Corazon","hotel","","San Jose del Cabo",150,"Contra entrega","Piden factura"), + ("Los Barriles","tienda","","East Cape",400,"Contra entrega",""), + ("Tienda Chapitos Los Barriles","tienda","","East Cape",400,"Contra entrega",""), + ("Vladimir (Pescadero)","independiente","","Pescadero",300,"Contra entrega","$2600 utilidad anterior"), + ("Tres Guerras","distribuidor","","La Paz",500,"Contra entrega",""), + ("Ventanas","hotel","","Los Cabos",200,"Contra entrega",""), + ("Rubas","tienda","","Los Cabos",150,"Contra entrega",""), + ("Cafe Saturno","restaurante","","Todos Santos",300,"Contra entrega","Prospecto"), + ("Todos Santos Hotel Boutique","hotel","","Todos Santos",300,"Contra entrega","Prospecto"), + ("Wild Cabo","hotel","","Los Cabos",200,"Contra entrega","Prospecto"), + ("Puerto Los Cabos","hotel","","San Jose del Cabo",150,"Contra entrega",""), + ] + for cl in clientes: + c.execute("INSERT INTO clientes (nombre,tipo,contacto,zona_entrega,costo_entrega,condiciones_pago,notas) VALUES (?,?,?,?,?,?,?)", cl) + + conn.commit() + + +def seed_data(conn): + c = conn.cursor() + + # ═══ 38 ORDENES REALES del CSV NocoDB ═══ + ordenes = [ + ("ORD-2026-019","OC","Nobu Hotel","Bolsa yute grande asas cafe (entrega 1)","",102,"Serigrafia","En Almacen","","2026-04-28","","","","",0,"","ENTREGA PARCIAL 1 de 2 - 102 bolsas entregadas. Faltan 98 pendientes en Taller Sofia."), + ("ORD-2026-024","OC","Puerto Los Cabos","Bolsa manta base yute serigrafia","",99,"Serigrafia","Entregado","","2026-05-04","","","2026-05-23","",0,"","Entregado a Nobu (99 piezas). Falta 1 pendiente reponer en 2 Mares."), + ("ORD-2026-029","OC","Tienda Chapitos Los Barriles","Maletin East Cape bordado","",5,"Bordado","En Almacen","","2026-05-04","","","","",0,"","Lista en bodega. Se entregara junto con todo el pedido de Los Barriles."), + ("ORD-2026-034","OC","La Ventana","Tazas","",46,"Serigrafia","En Almacen","","2026-05-05","","2026-05-22","","",0,"","Recibidas 46 tazas. Posiblemente falta 1 en 2 Mares."), + ("ORD-2026-035","OC","La Ventana","Sombreros con rectangulo de piel grabado y pegado","",40,"Grabado laser","En 2 Mares","","2026-05-05","","","","",0,"","PENDIENTE: esperando autorizacion de diseno en piel."), + ("ORD-2026-036","OC","Puerta Cortes","Pieles sombrero","",5,"Grabado laser","En 2 Mares","","2026-05-05","","","","",0,"","PENDIENTE: todavia no sale bien el diseno."), + ("ORD-2026-037","OC","Los Barriles","Pieles sombrero","",14,"Grabado laser","En 2 Mares","","2026-05-05","","","","",0,"","LISTO en taller. Manana entregan la piel para pegar (14 piezas)."), + ("ORD-2026-038","OC","La Ventana","Bolsa base yute serigrafia","",10,"Serigrafia","En Almacen","","2026-05-05","","","2026-05-22","",0,"","Ya entregadas 40 piezas. Quedan 10 en bodega para proxima vuelta a la ventana."), + ("ORD-2026-039","OC","Nobu Residences","Bolsa yute grande asas cafe - Logo Nobu Residences","",50,"Bordado","En Taller Sofia","","2026-05-16","","","","",0,"","En taller Sofia. Posiblemente listos manana, entrega ese mismo dia."), + ("ORD-2026-040","OC","Flora Farms","Bolsa loneta asa cafe 2026 DTF","",50,"Impresion","En 2 Mares","","2026-05-16","","","","",0,"Logo: Flora Mango","Cliente Flora Farms. Logo: Flora Mango."), + ("ORD-2026-041","OC","Flora Farms","Mandiles piel grabada","",59,"Grabado laser","En 2 Mares","","2026-05-16","","","","",0,"Logo: Flora Mango","Cliente Flora Farms. Logo: Flora Mango."), + ("ORD-2026-042","OC","Corazon","Bolsa Cabo Bello serigrafia","",50,"Serigrafia","En 2 Mares","","2026-05-21","","","","",0,"","ORDEN NUEVA. Hotel realizara pago. Piden factura."), + ("ORD-2026-043","OC","Flora Farms","Bolsa 2026 asas negras DTF","",50,"Impresion","Entregado","","2026-05-21","","","2026-05-23","",0,"Logo: Flora Mango","Entregadas hoy."), + ("ORD-2026-031","OC","La Ventana","Bolsa Cabo Bello","",30,"","En Almacen","","","","2026-05-13","","",0,"","Ya entregadas 50, quedan 30 en bodega para proxima vuelta."), + ("ORD-2026-032","OC","La Ventana","Bolsa 2026 DTF","",29,"","En Almacen","","","","2026-05-13","","",0,"","Ya entregadas 31, quedan 29 en bodega para proxima vuelta."), + ("ORD-2026-033","OC","La Ventana","Bolsa base negra","",23,"","Entregado","","","","2026-05-13","2026-05-23","",0,"","Entregadas las 23 piezas."), + ("ORD-2026-019-B","OC","Nobu Hotel","Bolsa yute grande asas cafe - Logo Nobu hotel bordado","",98,"","En Taller Sofia","","","","2026-05-13","","",0,"","Nobu - faltan 98 bolsas en taller Sofia. Esperando completar para entrega unica."), + ("ORD-2026-026","OC","Puerta Cortes","Bolsa Cabo Bello bordado","",10,"","Entregado","","","","2026-05-05","2026-05-23","",0,"","Entrega total 10 piezas (5 produccion nueva + 5 bodega)."), + ("ORD-2026-027","OC","Puerta Cortes","Bolsa mini bordado","",10,"","Entregado","","","","2026-05-05","2026-05-23","",0,"","Entregado."), + ("ORD-2026-028","OC","Puerta Cortes","Maletin loneta bordado","",5,"","Entregado","","","","2026-05-05","2026-05-23","",0,"","Entregado."), + ("ORD-2026-025","OC","Puerta Cortes","Bolsa 2026 DTF","",9,"","Entregado","","","","2026-05-05","2026-05-23","",0,"","Entregadas 9 piezas. Queda 1 para proxima vuelta."), + ("ORD-2026-022","OC","R Residence","Bolsa yute forro interior","",50,"","En Almacen","","","","2026-05-21","","",0,"","Produccion adelantada. Esta semana OC por 100 piezas."), + ("N/A-001","OC","Vladimir (Pescadero)","Bolsas varias","",55,"","Entregado","","","","","","",0,"","Pago recibido. $2600 utilidad. Resurtir 140+ bolsas prox visita"), + ("N/A-002","OC","Flora Farms","Bolsa base yute","",150,"","Entregado","","","","","2026-05-03","Ximena",0,"","Factura firmada por Ximena"), + ("N/A-003","OC","Flora Farms","Llaveros","",50,"","Entregado","","","","","2026-05-03","",0,"","OC 2"), + ("N/A-004","OC","Flora Farms","Imanes","",50,"","Entregado","","","","","2026-05-03","",0,"",""), + ("N/A-005","OC","Flora Farms","Vasos","",450,"","Entregado","","","","","2026-05-03","",0,"","+50 entregados mes anterior"), + ("N/A-006","OC","Rubas","Bolsa 2026","",10,"","Entregado","","","","","2026-05-02","",0,"",""), + ("ORD-2026-014","OC","The Cape","Bolsa playa piel grabada","",50,"","Entregado","","","","","2026-04-30","",0,"","Factura firmada, falta escanear"), + ("N/A-007","OC","Tres Guerras","Bolsa base yute (20+60+60+60)","",200,"","Entregado","","","","","2026-04-30","",0,"",""), + ("N/A-008","OC","Tres Guerras","Maleta","",25,"","Entregado","","","","","2026-04-30","",0,"",""), + ("ORD-2026-023","OC","Ventanas","Bolsa yute con piel","",100,"","Entregado","","","","","2026-05-21","",0,"",""), + ("MUES-2026-001","Muestra","Cafe Saturno","Taza Blanca con logo","",1,"Impresion","En Almacen","2026-05-21","","","2026-05-22","","",0,"DTF UV","MUESTRA para presentar a la duena."), + ("MUES-2026-002","Muestra","Todos Santos Hotel Boutique","Taza Blanca con logo","",1,"Impresion","En Almacen","2026-05-21","","","2026-05-22","","",0,"DTF UV","MUESTRA para presentar a la duena."), + ("MUES-2026-003","Muestra","Wild Cabo","Taza Blanca con logo","",1,"Impresion","En Almacen","2026-05-21","","","2026-05-22","","",0,"DTF UV","MUESTRA para presentar a la duena."), + ("MUESTRA-2026-001","Muestra","Cafe Saturno","Taza blanca con logo DTF UV (muestra)","",1,"Impresion","En 2 Mares","2026-05-21","","","","","",0,"","Muestra para presentar a la duena."), + ("MUESTRA-2026-002","Muestra","Todos Santos Hotel Boutique","Taza blanca con logo DTF UV (muestra)","",1,"Impresion","En 2 Mares","2026-05-21","","","","","",0,"","Muestra para presentar a la duena."), + ("MUESTRA-2026-003","Muestra","Wild Cabo","Taza blanca con logo DTF UV (muestra)","",1,"Impresion","En 2 Mares","2026-05-21","","","","","",0,"","Muestra para presentar a la duena."), + ] + for o in ordenes: + c.execute("""INSERT INTO ordenes + (orden_id,tipo_orden,cliente,producto,sku,cantidad,tipo_trabajo,stage, + fecha_oc,fecha_inicio,fecha_estimada,fecha_recepcion,fecha_entrega,recibio, + urgente,logo_instrucciones,notas) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", o) + + # ═══ INVENTARIO legacy (mantener por compatibilidad) ═══ + inventario = [ + ("BOLSA-YUTE-GR","Bolsa yute grande asas cafe","Bolsa tote de yute con asas de algodon cafe","Bolsa tote","GR","Natural","Sandra",200,20,45,"2026-05-22","Stock principal Nobu + varios clientes"), + ("BOLSA-MANTA-MD","Bolsa manta base yute","Bolsa de manta con base de yute","Bolsa ecologica","MD","Natural","Sandra",100,15,35,"2026-05-22","Para serigrafia"), + ("BOLSA-LONETA-MD","Bolsa loneta asa cafe 2026","Bolsa loneta con asa cafe para DTF","Bolsa de tela","MD","Natural","Sandra",80,10,40,"2026-05-22","DTF Flora Mango"), + ("BOLSA-CABO-BELLO","Bolsa Cabo Bello","Bolsa con diseno Cabo Bello para serigrafia","Bolsa tote","MD","Natural","Sandra",60,10,38,"2026-05-22","Varios clientes"), + ("TAZA-BLANCA-STD","Taza blanca estandar","Taza ceramica blanca para impresion DTF UV","Accesorio","Unica","Blanco","Proveedor tazas",50,10,25,"2026-05-22","Para muestras y ordenes"), + ] + for i in inventario: + c.execute("""INSERT INTO inventario + (sku,nombre,descripcion,tipo,talla,color_base,proveedor,stock_inicial,punto_reorden,costo_unitario,ultimo_conteo,notas) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", i) + + # ═══ TAREAS del handover ═══ + tareas = [ + ("Llenar Stock_Inicial real en Inventario","Conteo fisico de bodega - actualizar cada SKU","alta","pendiente","operaciones","","Tess","Del handover NocoDB"), + ("Ajustar SKUs de ejemplo a productos reales","Los 5 SKUs actuales son aproximados","normal","pendiente","operaciones","","Clod",""), + ("Ligar ordenes existentes a SKU en Inventario","Campo Inventario_Link en cada orden","normal","pendiente","operaciones","","Clod",""), + ("Subir soportes pendientes","Remisiones/fotos: ORD-031, 032, 026, 038","alta","pendiente","operaciones","","Clod",""), + ("Crear cuentas Tess y Andre en NocoDB","Usuarios con permisos adecuados","normal","pendiente","config","","Clod",""), + ("Compartir formulario Nueva Orden por WhatsApp","Link publico al grupo","normal","pendiente","config","","Clod",""), + ("Crear vistas filtradas en NocoDB","Por taller, por cliente, por mes, urgentes","baja","backlog","config","","Clod",""), + ("Configurar respaldos automaticos PostgreSQL","Backup del contenedor NocoDB","normal","backlog","config","","Clod",""), + ("Asegurar Tailscale en celulares del equipo","Para acceso remoto a NocoDB","normal","pendiente","config","","Clod",""), + ("Entregar ORD-029 Los Barriles","Maletin East Cape - listo en bodega","alta","pendiente","entregas","","Clod","Junto con todo el pedido"), + ("Entregar ORD-022 R Residence","50 bolsas yute forro interior listas","alta","pendiente","entregas","","Clod","OC por 100 pzas esta semana"), + ("Entregar muestras Cafe Saturno / Todos Santos / Wild Cabo","3 tazas DTF UV listas en almacen","normal","pendiente","ventas","","Clod","Presentar a las duenas"), + ("Recoger ORD-039 Nobu Residences de Taller Sofia","50 bolsas bordado - posiblemente listas","alta","en_progreso","produccion","","Clod","Entrega mismo dia"), + ("Cobrar factura Hotel Corazon ORD-042","Hotel realizara pago, piden factura","normal","pendiente","cobranza","","Clod",""), + ("Resurtir 140+ bolsas Vladimir Pescadero","Proxima visita","normal","backlog","ventas","","Tess","$2600 utilidad anterior"), + ] + for t in tareas: + c.execute("""INSERT INTO tareas + (titulo,descripcion,prioridad,stage,categoria,fecha_limite,asignado,notas) + VALUES (?,?,?,?,?,?,?,?)""", t) + + # ═══ BITACORA ═══ + bitacora = [ + ("entrega","Entrega Puerta Cortes completa","ORD-026,027,028,025 - bolsas, mini, maletin, DTF","2026-05-23"), + ("entrega","Entrega Flora Farms ORD-043","50 bolsas 2026 asas negras DTF","2026-05-23"), + ("entrega","Entrega Puerto Los Cabos ORD-024","99 bolsas manta serigrafia","2026-05-23"), + ("entrega","Entrega La Ventana ORD-033","23 bolsas base negra","2026-05-23"), + ("produccion","Recepcion tazas La Ventana ORD-034","46 tazas recibidas de 2 Mares","2026-05-22"), + ("produccion","Muestras DTF UV listas","Cafe Saturno, Todos Santos, Wild Cabo","2026-05-22"), + ("hito","Sistema NocoDB operativo","38 ordenes migradas, estructura validada","2026-05-22"), + ("produccion","ORD-042 Corazon registrada","50 bolsas Cabo Bello serigrafia - nueva orden","2026-05-21"), + ("produccion","R Residence produccion adelantada","50 bolsas forro interior listas","2026-05-21"), + ("entrega","Flora Farm entrega grande","150 bolsas + 50 llaveros + 50 imanes + 450 vasos","2026-05-03"), + ] + for b in bitacora: + c.execute("INSERT INTO bitacora (tipo,titulo,descripcion,fecha) VALUES (?,?,?,?)", b) + + conn.commit() + + +# ═══════════════════════════════════════════ +# API +# ═══════════════════════════════════════════ + +TABLES = { + 'oc': { + 'fields': ['oc_id','cliente','fecha_oc','fecha_entrega','recibio', + 'costo_logistica','otros_gastos','otros_gastos_desc', + 'precio_factura','iva_pct','factura_num', + 'condiciones_pago','status','notas', + 'pagado','fecha_pago','metodo_pago','oc_origen_id','propuesta_id'], + 'int_fields': ['pagado','oc_origen_id','propuesta_id'], + 'float_fields': ['costo_logistica','otros_gastos','precio_factura','iva_pct'], + 'nullable_fields': ['oc_origen_id','propuesta_id'], + }, + 'ordenes': { + 'fields': ['orden_id','oc_id','proyecto_id','tipo_orden','cliente','producto','sku','cantidad','tipo_trabajo','stage', + 'fecha_oc','fecha_inicio','fecha_estimada','fecha_recepcion','fecha_entrega','recibio', + 'urgente','logo_instrucciones','notas', + 'costo_producto','costo_trabajo','costo_logistica','precio_factura', + 'check_facturada','check_empacada','check_etiquetas','check_vehiculo', + 'grupo_oc','piezas_recibidas','piezas_danadas','nota_recepcion', + 'web_ejemplo','web_etiqueta'], + 'int_fields': ['cantidad','urgente','check_facturada','check_empacada','check_etiquetas','check_vehiculo','piezas_recibidas','piezas_danadas','oc_id','proyecto_id','web_ejemplo'], + 'float_fields': ['costo_producto','costo_trabajo','costo_logistica','precio_factura'], + 'nullable_fields': ['oc_id','proyecto_id'], + }, + 'inventario': { + 'fields': ['sku','nombre','descripcion','tipo','talla','color_base','proveedor', + 'stock_inicial','punto_reorden','costo_unitario','ultimo_conteo','notas'], + 'int_fields': ['stock_inicial','punto_reorden'], + 'float_fields': ['costo_unitario'], + }, + 'productos': { + 'fields': ['sku','nombre','categoria','talla','material','tipos_trabajo_disponibles', + 'costo_base','proveedor','stock_actual','punto_reorden','activo','notas', + 'mostrar_en_web','descripcion_web','medidas'], + 'int_fields': ['stock_actual','punto_reorden','activo','mostrar_en_web'], + 'float_fields': ['costo_base'], + }, + 'proyectos': { + 'fields': ['nombre','producto_id','producto_nombre','cliente','tipo_trabajo', + 'costo_unitario','costo_trabajo','logo_descripcion','logo_archivo', + 'foto_terminado','notas','activo','veces_usado','ultimo_uso'], + 'int_fields': ['producto_id','activo','veces_usado'], + 'float_fields': ['costo_unitario','costo_trabajo'], + 'nullable_fields': ['producto_id'], + }, + 'modelos': { + 'fields': ['clave','nombre','descripcion','activo'], + 'int_fields': ['activo'], + }, + 'materiales': { + 'fields': ['clave','nombre','tipo','costo_unitario','activo'], + 'int_fields': ['activo'], + 'float_fields': ['costo_unitario'], + }, + 'trabajos': { + 'fields': ['clave','nombre','costo_base','variable_por','proveedor_default','activo'], + 'int_fields': ['activo'], + 'float_fields': ['costo_base'], + }, + 'clientes': { + 'fields': ['nombre','tipo','contacto','zona_entrega','costo_entrega','condiciones_pago','notas','activo'], + 'int_fields': ['activo'], + 'float_fields': ['costo_entrega'], + }, + 'compras': { + 'fields': ['compra_id','proveedor','fecha_compra','fecha_llegada','sku','nombre_producto', + 'cantidad','costo_unitario','status','notas'], + 'int_fields': ['cantidad'], + 'float_fields': ['costo_unitario'], + }, + 'tareas': { + 'fields': ['titulo','descripcion','prioridad','stage','categoria','fecha_limite','asignado','notas'], + }, + 'catalogos': { + 'fields': ['nombre','segmento','cliente_nombre','fecha','items', + 'show_prices','show_clientes','show_lead_time','notas','status', + 'entrega','minimo_compra','terminos','show_contacto'], + 'int_fields': ['show_prices','show_clientes','show_lead_time','show_contacto'], + }, + 'propuestas': { + 'fields': ['numero','cliente_nombre','contacto','empresa','direccion','locacion','tipo_negocio', + 'email','telefono','fecha','vigencia_dias','items','iva_pct', + 'descuento_pct','notas','status'], + 'int_fields': ['vigencia_dias'], + 'float_fields': ['iva_pct','descuento_pct'], + }, + 'bitacora': { + 'fields': ['tipo','titulo','descripcion','referencia','fecha'], + }, +} + + +LOGIN_PAGE = """ + + +Art4Hotel Hub — Acceso + + +
+ +
Hub interno
+

Iniciar sesión

+
Acceso al sistema de operaciones
+
+ + + + +
+
+
+""" + + +class HubHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *a, **kw): + super().__init__(*a, directory=str(STATIC_DIR), **kw) + + def do_GET(self): + p = urllib.parse.urlparse(self.path) + # Ruta pública de auth + if p.path == "/api/needs-setup": return self.handle_needs_setup() + # Gate: requiere sesión + if not self.current_user(): + if p.path.startswith("/api/") or p.path.startswith("/uploads/"): + return self.json_ok({"error": "no autenticado"}, 401) + return self.serve_login_page() # cualquier HTML → página de login + # Autenticado + if p.path in ("", "/"): self.path = "/index.html"; return super().do_GET() + if p.path.startswith("/api/"): return self.api_get(p.path) + if p.path.startswith("/uploads/"): return self.serve_upload(p.path) + return super().do_GET() + + def do_POST(self): + # Rutas públicas de auth + if self.path == "/api/login": return self.handle_login() + if self.path == "/api/setup": return self.handle_setup() + if self.path == "/api/logout": return self.handle_logout() + # Gate + if not self.current_user(): return self.json_ok({"error": "no autenticado"}, 401) + if self.path.startswith("/api/upload/"): return self.handle_upload() + if self.path.startswith("/api/oc-split/"): return self.handle_oc_split() + if self.path == "/api/backup-now": return self.handle_backup_now() + if self.path.startswith("/api/"): return self.api_write("POST", self.path) + self.send_error(404) + + def handle_backup_now(self): + """POST /api/backup-now — runs the backup script in background""" + import subprocess + script = Path(__file__).parent / "backup.py" + try: + subprocess.Popen(["python3", str(script)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + self.json_ok({"ok": True, "msg": "Backup iniciado"}) + except Exception as e: + self.json_ok({"error": str(e)}, 500) + + def handle_oc_split(self): + """POST /api/oc-split/{oc_id_origen} + Body: {pedidos_ids: [int,...], oc_id: 'OC-...', fecha_entrega, recibio, factura_num, precio_factura, iva_pct, costo_logistica, otros_gastos, otros_gastos_desc, condiciones_pago} + Crea una OC hermana con los pedidos seleccionados (los entrega) y mantiene la original con los restantes. + """ + try: + origen_id = int(self.path.split("/")[3]) + except Exception: + self.send_error(400); return + data = self.body() + pedidos_ids = data.get('pedidos_ids') or [] + new_oc_id = data.get('oc_id', '').strip() + if not pedidos_ids or not new_oc_id: + self.json_ok({"error": "Requiere pedidos_ids y oc_id"}, 400); return + + conn = get_db() + try: + origen = conn.execute("SELECT * FROM oc WHERE id=?", (origen_id,)).fetchone() + if not origen: + self.json_ok({"error": "Orden origen no existe"}, 404); return + + fecha_entrega = data.get('fecha_entrega', '') or datetime.now().strftime("%Y-%m-%d") + recibio = data.get('recibio', '') + + # Create new sister OC + cols = ['oc_id','cliente','fecha_oc','fecha_entrega','recibio', + 'costo_logistica','otros_gastos','otros_gastos_desc', + 'precio_factura','iva_pct','factura_num', + 'condiciones_pago','status','notas','oc_origen_id'] + vals = [ + new_oc_id, + origen['cliente'], + origen['fecha_oc'] or '', + fecha_entrega, + recibio, + float(data.get('costo_logistica') or 0), + float(data.get('otros_gastos') or 0), + data.get('otros_gastos_desc', ''), + float(data.get('precio_factura') or 0), + float(data.get('iva_pct') if data.get('iva_pct') is not None else 16), + data.get('factura_num', ''), + data.get('condiciones_pago', origen['condiciones_pago']), + 'Activa', + data.get('notas', f"Entrega parcial de {origen['oc_id']}"), + origen_id, + ] + cur = conn.execute( + f"INSERT INTO oc ({','.join(cols)}) VALUES ({','.join(['?']*len(cols))})", vals) + new_id = cur.lastrowid + + # Move selected pedidos to new OC, mark them Entregado + for pid in pedidos_ids: + conn.execute( + "UPDATE ordenes SET oc_id=?, stage='Entregado', fecha_entrega=?, recibio=COALESCE(NULLIF(?,''),recibio), updated_at=datetime('now','localtime') WHERE id=?", + (new_id, fecha_entrega, recibio, pid)) + + # Log + self.log(conn, "entrega", + f"Entrega parcial: {new_oc_id} (origen {origen['oc_id']})", + f"{len(pedidos_ids)} pedido(s) entregado(s), {fecha_entrega}") + conn.commit() + self.json_ok({"id": new_id, "oc_id": new_oc_id}, 201) + finally: + conn.close() + + def do_PUT(self): + if not self.current_user(): return self.json_ok({"error": "no autenticado"}, 401) + if self.path.startswith("/api/"): return self.api_write("PUT", self.path) + self.send_error(404) + + def do_DELETE(self): + if not self.current_user(): return self.json_ok({"error": "no autenticado"}, 401) + if self.path.startswith("/api/files/"): return self.delete_file() + if self.path.startswith("/api/"): return self.api_delete(self.path) + self.send_error(404) + + def do_OPTIONS(self): + self.send_response(200) + for h, v in [("Access-Control-Allow-Origin","*"),("Access-Control-Allow-Methods","GET,POST,PUT,DELETE,OPTIONS"),("Access-Control-Allow-Headers","Content-Type")]: + self.send_header(h, v) + self.end_headers() + + def json_ok(self, data, status=200, cookie=None): + self.send_response(status) + self.send_header("Content-Type","application/json") + self.send_header("Access-Control-Allow-Origin","*") + if cookie: self.send_header("Set-Cookie", cookie) + self.end_headers() + self.wfile.write(json.dumps(data, ensure_ascii=False, default=str).encode()) + + def body(self): + n = int(self.headers.get("Content-Length", 0)) + return json.loads(self.rfile.read(n)) if n else {} + + # ══════ AUTENTICACIÓN ══════ + def current_user(self): + cookie = self.headers.get('Cookie', '') + if not cookie: return None + try: + c = http.cookies.SimpleCookie(cookie) + if 'a4h_session' in c: + return check_session(c['a4h_session'].value) + except Exception: + return None + return None + + def _session_cookie(self, token): + return f"a4h_session={token}; HttpOnly; Path=/; Max-Age={SESSION_HOURS*3600}; SameSite=Lax" + + def handle_needs_setup(self): + conn = get_db() + n = conn.execute("SELECT COUNT(*) FROM usuarios").fetchone()[0] + conn.close() + self.json_ok({"needs_setup": n == 0}) + + def handle_setup(self): + conn = get_db() + n = conn.execute("SELECT COUNT(*) FROM usuarios").fetchone()[0] + if n > 0: + conn.close(); return self.json_ok({"error": "Ya hay usuarios configurados"}, 403) + data = self.body() + u = (data.get('username') or '').strip().lower() + p = data.get('password') or '' + nombre = (data.get('nombre') or '').strip() + if not u or len(p) < 6: + conn.close(); return self.json_ok({"error": "Usuario requerido y contraseña de 6+ caracteres"}, 400) + conn.execute("INSERT INTO usuarios (username,pass_hash,nombre) VALUES (?,?,?)", + (u, hash_password(p), nombre or u)) + conn.commit(); conn.close() + self.json_ok({"ok": True}, cookie=self._session_cookie(make_session(u))) + + def handle_login(self): + data = self.body() + u = (data.get('username') or '').strip().lower() + p = data.get('password') or '' + conn = get_db() + row = conn.execute("SELECT * FROM usuarios WHERE username=? AND activo=1", (u,)).fetchone() + conn.close() + if not row or not verify_password(p, row['pass_hash']): + return self.json_ok({"error": "Usuario o contraseña incorrectos"}, 401) + self.json_ok({"ok": True, "nombre": row['nombre'] or u}, + cookie=self._session_cookie(make_session(u))) + + def handle_logout(self): + self.json_ok({"ok": True}, cookie="a4h_session=; Path=/; Max-Age=0") + + def serve_login_page(self): + html = LOGIN_PAGE.encode() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(html) + + # ── File uploads ── + def handle_upload(self): + """POST /api/upload/{orden_id}?tipo=soporte_trabajo|factura|recibo|otro""" + parts = self.path.split("/") + orden_id = urllib.parse.unquote("/".join(parts[3:])).split("?")[0] + params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) + tipo = params.get("tipo", ["soporte"])[0] + label = params.get("label", [""])[0].strip() # parte descriptiva opcional + + content_type = self.headers.get("Content-Type", "") + content_length = int(self.headers.get("Content-Length", 0)) + + if content_length > MAX_UPLOAD: + self.json_ok({"error": "Archivo muy grande (max 20MB)"}, 413) + return + + if "multipart/form-data" not in content_type: + self.json_ok({"error": "Usar multipart/form-data"}, 400) + return + + boundary = content_type.split("boundary=")[1].strip() + raw = self.rfile.read(content_length) + + files_saved = [] + parts_data = raw.split(f"--{boundary}".encode()) + idx = 0 + for part in parts_data: + if b"filename=" not in part: + continue + header_end = part.find(b"\r\n\r\n") + if header_end == -1: + continue + header = part[:header_end].decode("utf-8", errors="replace") + file_data = part[header_end+4:] + if file_data.endswith(b"\r\n"): + file_data = file_data[:-2] + if file_data.endswith(b"--\r\n"): + file_data = file_data[:-4] + if file_data.endswith(b"--"): + file_data = file_data[:-2] + + fn_match = re.search(r'filename="([^"]+)"', header) + if not fn_match or not file_data: + continue + orig_name = fn_match.group(1) + ext = ('.' + orig_name.rsplit('.', 1)[1]) if '.' in orig_name else '' + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + # La parte descriptiva: etiqueta manual (si hay) o el nombre original. + # El PREFIJO del tipo SIEMPRE se conserva (de él depende la lógica del sistema). + if label: + desc = label + (f"_{idx+1}" if idx > 0 else "") # varios archivos → sufijo + safe_desc = re.sub(r'[^\w\-. ]', '', desc).strip().replace(' ', '_') + final_name = f"{tipo}_{ts}_{safe_desc}{ext}" + else: + safe_name = re.sub(r'[^\w\-._]', '_', orig_name) + final_name = f"{tipo}_{ts}_{safe_name}" + idx += 1 + + orden_dir = UPLOADS_DIR / re.sub(r'[^\w\-.]', '_', orden_id) + orden_dir.mkdir(exist_ok=True) + filepath = orden_dir / final_name + filepath.write_bytes(file_data) + files_saved.append({ + "name": final_name, + "original": orig_name, + "tipo": tipo, + "size": len(file_data), + "url": f"/uploads/{orden_dir.name}/{final_name}" + }) + + if files_saved: + conn = get_db() + self.log(conn, "soporte", f"Archivo subido a {orden_id}", + f"{len(files_saved)} archivo(s): {', '.join(f['original'] for f in files_saved)}") + conn.commit() + conn.close() + self.json_ok({"files": files_saved}, 201) + else: + self.json_ok({"error": "No se encontraron archivos"}, 400) + + def serve_upload(self, path): + """GET /uploads/{orden_id}/{filename}""" + clean = path.replace("..", "").lstrip("/") + filepath = STATIC_DIR / clean + if not filepath.exists() or not filepath.is_file(): + self.send_error(404) + return + try: + filepath.resolve().relative_to(UPLOADS_DIR.resolve()) + except ValueError: + self.send_error(403) + return + + mime, _ = mimetypes.guess_type(str(filepath)) + if not mime: + mime = "application/octet-stream" + + data = filepath.read_bytes() + self.send_response(200) + self.send_header("Content-Type", mime) + self.send_header("Content-Length", len(data)) + self.send_header("Content-Disposition", f'inline; filename="{filepath.name}"') + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(data) + + def delete_file(self): + """DELETE /api/files/{orden_id}/{filename}""" + parts = self.path.split("/") + if len(parts) < 5: + self.send_error(400); return + orden_id = parts[3] + filename = urllib.parse.unquote(parts[4]) + filepath = UPLOADS_DIR / re.sub(r'[^\w\-.]', '_', orden_id) / filename + if filepath.exists(): + filepath.unlink() + self.json_ok({"ok": True}) + else: + self.send_error(404) + + # ── GET ── + def api_get(self, path): + conn = get_db() + try: + # File listing + if path.startswith("/api/files/"): + orden_id = urllib.parse.unquote(path[11:]) + orden_dir = UPLOADS_DIR / re.sub(r'[^\w\-.]', '_', orden_id) + files = [] + if orden_dir.exists(): + for f in sorted(orden_dir.iterdir()): + if f.is_file(): + ext = f.suffix.lower() + is_image = ext in ('.jpg','.jpeg','.png','.gif','.webp','.bmp') + files.append({ + "name": f.name, + "size": f.stat().st_size, + "url": f"/uploads/{orden_dir.name}/{f.name}", + "is_image": is_image, + "ext": ext, + "modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat() + }) + self.json_ok(files) + return + + if path == "/api/dashboard": + self.json_ok(self.dashboard(conn)) + elif path == "/api/ventas": + self.json_ok(self.ventas_data(conn)) + elif path == "/api/entregas": + self.json_ok(self.entregas_data(conn)) + elif path.startswith("/api/oc-split/"): + # POST not GET, but routed here for path parsing + self.send_error(405); return + elif path == "/api/oc": + rows = conn.execute("SELECT * FROM oc ORDER BY id DESC").fetchall() + ocs = [] + for r in rows: + d = dict(r) + lineas = conn.execute("SELECT * FROM ordenes WHERE oc_id=? ORDER BY id", (d['id'],)).fetchall() + d['lineas'] = [dict(l) for l in lineas] + d['total_piezas'] = sum(l['cantidad'] for l in lineas) + d['n_lineas'] = len(lineas) + # Aggregate stage status + stages = [l['stage'] for l in lineas] + if all(s == 'Entregado' for s in stages): + d['progress'] = 'Entregado' + elif any(s == 'Entregado' for s in stages): + d['progress'] = 'Parcial' + else: + d['progress'] = 'En proceso' + # Sum line costs + d['costo_produccion'] = sum((l['costo_producto']+l['costo_trabajo'])*l['cantidad'] for l in lineas) + ocs.append(d) + self.json_ok(ocs) + elif path.startswith("/api/oc/") and path.count("/") == 3: + oc_pk = int(path.split("/")[3]) + row = conn.execute("SELECT * FROM oc WHERE id=?", (oc_pk,)).fetchone() + if not row: + self.send_error(404); return + d = dict(row) + lineas = conn.execute("SELECT * FROM ordenes WHERE oc_id=? ORDER BY id", (d['id'],)).fetchall() + d['lineas'] = [dict(l) for l in lineas] + self.json_ok(d) + elif path == "/api/ordenes": + rows = conn.execute("SELECT * FROM ordenes ORDER BY id").fetchall() + self.json_ok([dict(r) for r in rows]) + elif path == "/api/inventario": + self.json_ok(self.get_inventario(conn)) + elif path == "/api/productos": + rows = conn.execute("SELECT * FROM productos ORDER BY id").fetchall() + self.json_ok([dict(r) for r in rows]) + elif path == "/api/modelos": + rows = conn.execute("SELECT * FROM modelos WHERE activo=1 ORDER BY nombre").fetchall() + self.json_ok([dict(r) for r in rows]) + elif path == "/api/materiales": + rows = conn.execute("SELECT * FROM materiales WHERE activo=1 ORDER BY nombre").fetchall() + self.json_ok([dict(r) for r in rows]) + elif path == "/api/trabajos": + rows = conn.execute("SELECT * FROM trabajos WHERE activo=1 ORDER BY nombre").fetchall() + self.json_ok([dict(r) for r in rows]) + elif path == "/api/clientes": + rows = conn.execute("SELECT * FROM clientes WHERE activo=1 ORDER BY nombre").fetchall() + self.json_ok([dict(r) for r in rows]) + elif path == "/api/compras": + rows = conn.execute("SELECT * FROM compras ORDER BY id DESC").fetchall() + self.json_ok([dict(r) for r in rows]) + elif path == "/api/tareas": + rows = conn.execute("SELECT * FROM tareas ORDER BY id").fetchall() + self.json_ok([dict(r) for r in rows]) + elif path == "/api/file-counts": + # Returns: { entity_id: { count, first_image_url } } for ALL upload dirs in ONE call + # first_image PREFIERE fotos de producto/avance y EXCLUYE documentos (factura/recibo/etc.) + IMG_EXT = ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp') + DOC_PREFIXES = ('factura', 'recibo', 'comprobante', 'soporte', 'contrato', 'propuesta') + PHOTO_PREFIXES = ('foto_', 'mockup') + result = {} + if UPLOADS_DIR.exists(): + for d in UPLOADS_DIR.iterdir(): + if not d.is_dir(): + continue + files = [f for f in d.iterdir() if f.is_file()] + if not files: + continue + imgs = sorted([f for f in files if f.suffix.lower() in IMG_EXT], key=lambda f: f.name) + # 1) Preferir fotos de producto/avance (prefijo foto_/mockup) + photo = next((f for f in imgs if f.name.lower().startswith(PHOTO_PREFIXES)), None) + # 2) Si no hay, cualquier imagen que NO sea documento + if not photo: + photo = next((f for f in imgs if not f.name.lower().startswith(DOC_PREFIXES)), None) + first_img = f"/uploads/{d.name}/{photo.name}" if photo else None + result[d.name] = { + "count": len(files), + "first_image": first_img + } + self.json_ok(result) + elif path == "/api/proyectos": + rows = conn.execute(""" + SELECT p.*, + (SELECT COUNT(*) FROM ordenes o WHERE o.proyecto_id=p.id) AS pedidos_count + FROM proyectos p + WHERE activo=1 + ORDER BY ultimo_uso DESC, veces_usado DESC, id DESC + """).fetchall() + self.json_ok([dict(r) for r in rows]) + elif path == "/api/propuestas": + rows = conn.execute("SELECT * FROM propuestas ORDER BY id DESC").fetchall() + self.json_ok([dict(r) for r in rows]) + elif path == "/api/catalogos": + rows = conn.execute("SELECT * FROM catalogos ORDER BY id DESC").fetchall() + self.json_ok([dict(r) for r in rows]) + elif path == "/api/backups": + backups_dir = Path(__file__).parent / "backups" + items = [] + if backups_dir.exists(): + for d in sorted(backups_dir.iterdir(), reverse=True): + if d.is_dir(): + db = d / "art4hotel.db" + up = d / "uploads.tar.gz" + items.append({ + "name": d.name, + "fecha": d.name.split("_")[0], + "hora": d.name.split("_")[1] if "_" in d.name else "", + "db_size": db.stat().st_size if db.exists() else 0, + "uploads_size": up.stat().st_size if up.exists() else 0, + }) + self.json_ok(items) + elif path == "/api/bitacora": + rows = conn.execute("SELECT * FROM bitacora ORDER BY fecha DESC, id DESC LIMIT 100").fetchall() + self.json_ok([dict(r) for r in rows]) + else: + self.send_error(404) + finally: + conn.close() + + def get_inventario(self, conn): + rows = conn.execute("SELECT * FROM inventario ORDER BY id").fetchall() + result = [] + for r in rows: + d = dict(r) + total_ord = conn.execute( + "SELECT COALESCE(SUM(cantidad),0) FROM ordenes WHERE sku=? AND stage NOT IN ('Entregado','Cancelado')", + (d['sku'],) + ).fetchone()[0] + d['total_ordenado'] = total_ord + d['stock_disponible'] = d['stock_inicial'] - total_ord + result.append(d) + return result + + def ventas_data(self, conn): + """Dashboard de ventas: ciclos, productos, clientes top con comparativos mensuales""" + now = datetime.now() + mes_actual = now.strftime("%Y-%m") + # Mes pasado + if now.month == 1: + mes_pasado = f"{now.year-1}-12" + else: + mes_pasado = f"{now.year}-{now.month-1:02d}" + # Mismo mes año pasado + mes_anio_pasado = f"{now.year-1}-{now.month:02d}" + + def rev_for_month(mes): + r = conn.execute(""" + SELECT COUNT(*) as ordenes, COALESCE(SUM(cantidad),0) as piezas, + COALESCE(SUM(precio_factura),0) as facturado, + COALESCE(SUM((costo_producto+costo_trabajo)*cantidad + costo_logistica),0) as costo + FROM ordenes WHERE stage='Entregado' AND fecha_entrega LIKE ? + """, (f"{mes}%",)).fetchone() + d = dict(r) + d['utilidad'] = d['facturado'] - d['costo'] + d['margen'] = round(d['utilidad']/d['facturado']*100,1) if d['facturado'] > 0 else 0 + return d + + # ── Comparativo: mes actual vs mes pasado vs año pasado mismo mes ── + comparativo = { + 'actual': {**rev_for_month(mes_actual), 'mes': mes_actual}, + 'mes_pasado': {**rev_for_month(mes_pasado), 'mes': mes_pasado}, + 'anio_pasado': {**rev_for_month(mes_anio_pasado), 'mes': mes_anio_pasado}, + } + + # ── Tiempos de ciclo (fecha_inicio → fecha_entrega) ── + cycle_rows = conn.execute(""" + SELECT orden_id, cliente, producto, tipo_trabajo, fecha_inicio, fecha_entrega, + julianday(fecha_entrega) - julianday(fecha_inicio) as dias + FROM ordenes + WHERE stage='Entregado' + AND fecha_inicio != '' AND fecha_entrega != '' + AND julianday(fecha_entrega) >= julianday(fecha_inicio) + ORDER BY dias DESC + """).fetchall() + cycle_list = [{'dias': int(r['dias']), 'orden_id': r['orden_id'], 'cliente': r['cliente'], + 'producto': r['producto'], 'tipo_trabajo': r['tipo_trabajo']} for r in cycle_rows] + dias_arr = sorted([c['dias'] for c in cycle_list]) + def pct(arr, p): + if not arr: return 0 + k = int(len(arr) * p / 100) + return arr[min(k, len(arr)-1)] + cycle_summary = { + 'n': len(dias_arr), + 'avg': round(sum(dias_arr)/len(dias_arr),1) if dias_arr else 0, + 'median': pct(dias_arr, 50), + 'p90': pct(dias_arr, 90), + 'min': dias_arr[0] if dias_arr else 0, + 'max': dias_arr[-1] if dias_arr else 0, + } + # Top 5 más lentos (para identificar cuellos de botella) + cycle_top_lentos = cycle_list[:5] + # Por tipo_trabajo + from collections import defaultdict + ct_by_trabajo = defaultdict(list) + for c in cycle_list: + if c['tipo_trabajo']: + ct_by_trabajo[c['tipo_trabajo']].append(c['dias']) + cycle_by_trabajo = {t: {'n': len(v), 'avg': round(sum(v)/len(v),1), 'median': pct(sorted(v), 50)} + for t, v in ct_by_trabajo.items()} + + # ── Productos: cantidad vendida, precio unitario avg/min/max ── + productos_pricing = [] + for r in conn.execute(""" + SELECT producto, COUNT(*) as n_pedidos, SUM(cantidad) as total_pzas, + AVG(CASE WHEN cantidad>0 AND precio_factura>0 THEN precio_factura*1.0/cantidad END) as precio_unit_avg, + MIN(CASE WHEN cantidad>0 AND precio_factura>0 THEN precio_factura*1.0/cantidad END) as precio_unit_min, + MAX(CASE WHEN cantidad>0 AND precio_factura>0 THEN precio_factura*1.0/cantidad END) as precio_unit_max, + AVG(CASE WHEN costo_producto>0 THEN costo_producto END) as costo_base_avg + FROM ordenes + WHERE stage='Entregado' AND producto != '' + GROUP BY producto ORDER BY total_pzas DESC + """): + d = dict(r) + productos_pricing.append({ + 'producto': d['producto'], + 'n_pedidos': d['n_pedidos'], + 'total_pzas': d['total_pzas'] or 0, + 'precio_unit_avg': round(d['precio_unit_avg'] or 0, 2), + 'precio_unit_min': round(d['precio_unit_min'] or 0, 2), + 'precio_unit_max': round(d['precio_unit_max'] or 0, 2), + 'costo_base_avg': round(d['costo_base_avg'] or 0, 2), + }) + + # ── Top clientes con comparativo mensual ── + def cli_rev_for_month(cli, mes): + r = conn.execute(""" + SELECT COALESCE(SUM(precio_factura),0) as fact, + COALESCE(SUM(cantidad),0) as pzas, + COUNT(*) as ord + FROM ordenes WHERE stage='Entregado' AND cliente=? AND fecha_entrega LIKE ? + """, (cli, f"{mes}%")).fetchone() + return dict(r) + + # Top 10 clientes por facturación histórica + top_clis_rows = conn.execute(""" + SELECT cliente, COALESCE(SUM(precio_factura),0) as total_fact, + COUNT(*) as n_ordenes, COALESCE(SUM(cantidad),0) as total_pzas + FROM ordenes WHERE stage='Entregado' AND cliente != '' + GROUP BY cliente ORDER BY total_fact DESC LIMIT 10 + """).fetchall() + + top_clientes = [] + for r in top_clis_rows: + cli = r['cliente'] + top_clientes.append({ + 'cliente': cli, + 'total_facturado': r['total_fact'], + 'total_pzas': r['total_pzas'], + 'n_ordenes': r['n_ordenes'], + 'actual': cli_rev_for_month(cli, mes_actual), + 'mes_pasado': cli_rev_for_month(cli, mes_pasado), + 'anio_pasado': cli_rev_for_month(cli, mes_anio_pasado), + }) + + # ── Tipo de trabajo (lo mantenemos como referencia rápida) ── + trabajo_stats = {} + for r in conn.execute(""" + SELECT tipo_trabajo, COUNT(*) as ordenes, SUM(cantidad) as piezas + FROM ordenes WHERE tipo_trabajo != '' AND stage='Entregado' + GROUP BY tipo_trabajo ORDER BY piezas DESC + """): + trabajo_stats[r['tipo_trabajo']] = dict(r) + + # Totales históricos + total_facturado = conn.execute( + "SELECT COALESCE(SUM(precio_factura),0) FROM ordenes WHERE stage='Entregado'" + ).fetchone()[0] + total_costo = conn.execute( + "SELECT COALESCE(SUM((costo_producto+costo_trabajo)*cantidad + costo_logistica),0) FROM ordenes WHERE stage='Entregado'" + ).fetchone()[0] + + return { + 'mes_actual': mes_actual, + 'mes_pasado': mes_pasado, + 'mes_anio_pasado': mes_anio_pasado, + 'comparativo': comparativo, + 'cycle': { + 'summary': cycle_summary, + 'top_lentos': cycle_top_lentos, + 'by_trabajo': cycle_by_trabajo, + }, + 'productos_pricing': productos_pricing, + 'top_clientes': top_clientes, + 'trabajo_stats': trabajo_stats, + 'total_facturado': total_facturado, + 'total_costo': total_costo, + 'total_utilidad': total_facturado - total_costo, + 'margen': round((total_facturado - total_costo) / total_facturado * 100, 1) if total_facturado > 0 else 0, + } + + def entregas_data(self, conn): + """Post-entrega: OCs entregadas + ordenes sueltas entregadas""" + # Get all delivered orders + rows = conn.execute(""" + SELECT o.*, oc.oc_id as oc_folio, oc.precio_factura as oc_factura, + oc.costo_logistica as oc_logistica, oc.factura_num as oc_factura_num, + oc.fecha_entrega as oc_fecha_entrega, oc.recibio as oc_recibio, + oc.condiciones_pago as oc_condiciones, oc.status as oc_status, + oc.notas as oc_notas, oc.iva_pct as oc_iva_pct, + oc.otros_gastos as oc_otros_gastos, oc.otros_gastos_desc as oc_otros_gastos_desc + FROM ordenes o + LEFT JOIN oc ON o.oc_id = oc.id + WHERE o.stage = 'Entregado' + ORDER BY + CASE WHEN o.fecha_entrega != '' THEN o.fecha_entrega ELSE '0000-00-00' END DESC, + o.id DESC + """).fetchall() + return [dict(r) for r in rows] + + # ── POST / PUT ── + def api_write(self, method, path): + parts = path.split("/") + table = parts[2] if len(parts) >= 3 else None + item_id = int(parts[3]) if len(parts) >= 4 else None + data = self.body() + + if table not in TABLES: + self.send_error(404); return + + conn = get_db() + try: + cfg = TABLES[table] + if method == "POST": + # Guard against duplicate orden_id (shared upload folders cause photo mix-ups) + if table == 'ordenes' and data.get('orden_id'): + existing = conn.execute("SELECT id FROM ordenes WHERE orden_id=?", (data['orden_id'],)).fetchone() + if existing: + self.json_ok({"error": f"orden_id '{data['orden_id']}' ya existe"}, 409); return + cols, vals = [], [] + for f in cfg['fields']: + if f in data: + v = data[f] + if f in cfg.get('int_fields',[]): v = int(v or 0) + if f in cfg.get('float_fields',[]): v = float(v or 0) + if f in cfg.get('nullable_fields',[]) and (v == 0 or v == '' or v is None): v = None + cols.append(f); vals.append(v) + placeholders = ','.join(['?']*len(cols)) + cur = conn.execute(f"INSERT INTO {table} ({','.join(cols)}) VALUES ({placeholders})", vals) + conn.commit() + if table == 'ordenes': + self.log(conn,"orden",f"Orden {data.get('orden_id','')} creada",f"{data.get('cliente','')} - {data.get('producto','')}") + self.json_ok({"id": cur.lastrowid}, 201) + + elif method == "PUT" and item_id: + sets, vals = [], [] + for f in cfg['fields']: + if f in data: + v = data[f] + if f in cfg.get('int_fields',[]): v = int(v or 0) + if f in cfg.get('float_fields',[]): v = float(v or 0) + if f in cfg.get('nullable_fields',[]) and (v == 0 or v == '' or v is None): v = None + sets.append(f"{f}=?"); vals.append(v) + if sets: + # Only add updated_at if the table has that column + cols_in_table = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()} + if 'updated_at' in cols_in_table: + sets.append("updated_at=datetime('now','localtime')") + conn.execute(f"UPDATE {table} SET {','.join(sets)} WHERE id=?", vals+[item_id]) + conn.commit() + if table == 'ordenes' and 'stage' in data: + self.log(conn,"movimiento",f"Orden movida a {data['stage']}",f"ID #{item_id}") + self.json_ok({"ok": True}) + finally: + conn.close() + + # ── DELETE ── + def api_delete(self, path): + parts = path.split("/") + table = parts[2] if len(parts)>=3 else None + item_id = int(parts[3]) if len(parts)>=4 else None + if table not in TABLES or not item_id: + self.send_error(404); return + conn = get_db() + try: + # When deleting an OC, unlink all its orders first + if table == 'oc': + conn.execute("UPDATE ordenes SET oc_id=NULL, updated_at=datetime('now','localtime') WHERE oc_id=?", (item_id,)) + conn.execute(f"DELETE FROM {table} WHERE id=?", (item_id,)) + conn.commit() + self.json_ok({"ok": True}) + finally: + conn.close() + + def log(self, conn, tipo, titulo, desc): + conn.execute("INSERT INTO bitacora (tipo,titulo,descripcion) VALUES (?,?,?)", (tipo,titulo,desc)) + + def dashboard(self, conn): + stages = {} + for r in conn.execute("SELECT stage, COUNT(*) as n, SUM(cantidad) as pzas FROM ordenes GROUP BY stage"): + stages[r['stage']] = {'ordenes': r['n'], 'piezas': r['pzas'] or 0} + + clientes = {} + for r in conn.execute("SELECT cliente, COUNT(*) as n, SUM(cantidad) as pzas FROM ordenes WHERE stage NOT IN ('Entregado','Cancelado') GROUP BY cliente ORDER BY pzas DESC"): + clientes[r['cliente']] = {'ordenes': r['n'], 'piezas': r['pzas'] or 0} + + trabajos = {} + for r in conn.execute("SELECT tipo_trabajo, COUNT(*) as n FROM ordenes WHERE stage NOT IN ('Entregado','Cancelado') AND tipo_trabajo != '' GROUP BY tipo_trabajo"): + trabajos[r['tipo_trabajo']] = r['n'] + + inv = self.get_inventario(conn) + alertas_stock = [i for i in inv if i['stock_disponible'] <= i['punto_reorden']] + + tareas_pend = conn.execute("SELECT COUNT(*) FROM tareas WHERE stage NOT IN ('completada')").fetchone()[0] + + total_ordenes = conn.execute("SELECT COUNT(*) FROM ordenes").fetchone()[0] + ordenes_activas = conn.execute("SELECT COUNT(*) FROM ordenes WHERE stage NOT IN ('Entregado','Cancelado')").fetchone()[0] + piezas_activas = conn.execute("SELECT COALESCE(SUM(cantidad),0) FROM ordenes WHERE stage NOT IN ('Entregado','Cancelado')").fetchone()[0] + piezas_entregadas = conn.execute("SELECT COALESCE(SUM(cantidad),0) FROM ordenes WHERE stage='Entregado'").fetchone()[0] + + # Entregas pendientes (En Almacen) + en_almacen = conn.execute("SELECT COUNT(*) FROM ordenes WHERE stage='En Almacen'").fetchone()[0] + en_vehiculo = conn.execute("SELECT COUNT(*) FROM ordenes WHERE stage='En Vehiculo'").fetchone()[0] + + return { + 'stages': stages, + 'clientes_activos': clientes, + 'tipos_trabajo': trabajos, + 'alertas_stock': [{'sku':a['sku'],'nombre':a['nombre'],'stock_disponible':a['stock_disponible'],'punto_reorden':a['punto_reorden']} for a in alertas_stock], + 'total_ordenes': total_ordenes, + 'ordenes_activas': ordenes_activas, + 'piezas_activas': piezas_activas, + 'piezas_entregadas': piezas_entregadas, + 'tareas_pendientes': tareas_pend, + 'en_almacen': en_almacen, + 'en_vehiculo': en_vehiculo, + } + + def log_message(self, fmt, *args): + if "/api/" in str(args): super().log_message(fmt, *args) + + +if __name__ == "__main__": + init_db() + print(f"\n Art4Hotel Hub v2 - http://localhost:{PORT}") + print(f" DB: {DB_PATH.name}") + print(f" Ctrl+C para detener\n") + srv = http.server.HTTPServer(("", PORT), HubHandler) + try: srv.serve_forever() + except KeyboardInterrupt: print("\nDetenido."); srv.server_close()