Files
art4hotel-hub/server.py
consultoria-as c2ae140078 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 <noreply@anthropic.com>
2026-06-09 00:10:07 -07:00

1578 lines
79 KiB
Python

"""
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 = """<!DOCTYPE html>
<html lang="es"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Art4Hotel Hub — Acceso</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Outfit:wght@400;500;600;700&family=Playfair+Display:ital@1&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Outfit',sans-serif;background:linear-gradient(135deg,#3D4A33,#5C6B4F);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;color:#2C2C2C}
.card{background:#FAF7F0;border-radius:16px;box-shadow:0 24px 60px rgba(0,0,0,.3);width:100%;max-width:380px;padding:40px 34px;text-align:center}
.logo{font-size:30px;margin-bottom:6px}
.logo .a{font-family:'Outfit';font-weight:600;color:#3D4A33;letter-spacing:1px}
.logo .f{font-family:'Playfair Display';font-style:italic;color:#6B4F3C}
.tag{font-family:'DM Sans';font-size:9px;color:#8A8075;letter-spacing:3px;text-transform:uppercase;margin-bottom:26px}
h1{font-family:'Playfair Display';font-style:italic;font-size:22px;color:#3D4A33;font-weight:400;margin-bottom:6px}
.sub{font-size:12px;color:#8A8075;margin-bottom:22px}
label{display:block;text-align:left;font-size:10px;color:#8A8075;text-transform:uppercase;letter-spacing:1px;font-weight:600;margin:12px 0 5px}
input{width:100%;padding:12px 14px;border:1px solid #D4C5A9;border-radius:8px;font-family:inherit;font-size:15px;background:#fff;outline:none}
input:focus{border-color:#5C6B4F}
button{width:100%;margin-top:22px;padding:13px;background:#5C6B4F;color:#fff;border:none;border-radius:8px;font-family:inherit;font-size:14px;font-weight:600;letter-spacing:.5px;text-transform:uppercase;cursor:pointer;transition:.15s}
button:hover{background:#3D4A33}
.err{color:#c0392b;font-size:12px;margin-top:14px;min-height:16px}
</style></head><body>
<div class="card">
<div class="logo"><span class="a">art</span> <span class="f">4</span> <span class="a">hotel</span></div>
<div class="tag">Hub interno</div>
<h1 id="title">Iniciar sesión</h1>
<div class="sub" id="subtitle">Acceso al sistema de operaciones</div>
<form id="f">
<div id="nombre-fg" style="display:none"><label>Tu nombre</label><input id="nombre" autocomplete="name"></div>
<label>Usuario</label><input id="username" autocomplete="username" autocapitalize="none">
<label>Contraseña</label><input id="password" type="password" autocomplete="current-password">
<button type="submit" id="btn">Entrar</button>
<div class="err" id="err"></div>
</form>
</div>
<script>
let setup=false;
fetch('/api/needs-setup').then(r=>r.json()).then(d=>{
if(d.needs_setup){
setup=true;
document.getElementById('title').textContent='Crear cuenta de administrador';
document.getElementById('subtitle').textContent='Primera vez — define tu usuario y contraseña';
document.getElementById('nombre-fg').style.display='block';
document.getElementById('btn').textContent='Crear cuenta';
document.getElementById('password').setAttribute('autocomplete','new-password');
}
});
document.getElementById('f').onsubmit=async e=>{
e.preventDefault();
const err=document.getElementById('err'); err.textContent='';
const body={username:document.getElementById('username').value.trim().toLowerCase(),
password:document.getElementById('password').value,
nombre:document.getElementById('nombre').value.trim()};
try{
const r=await fetch(setup?'/api/setup':'/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const d=await r.json();
if(d.ok){location.href='/';}
else{err.textContent=d.error||'Error';}
}catch(_){err.textContent='Error de conexión';}
};
</script></body></html>"""
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()