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>
1578 lines
79 KiB
Python
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()
|