""" 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()