import warnings # Silenciamos advertencias de la terminal para mantenerla limpia y profesional warnings.filterwarnings("ignore", category=DeprecationWarning) import flet as ft import gspread import threading from datetime import datetime, timedelta # --- CONEXIÓN DIRECTA NATIVA --- def obtener_cliente(): try: return gspread.service_account(filename="llave_secreta.json") except Exception as error_inicial: print(f"CRÍTICO - ERROR AL INICIALIZAR LA CUENTA DE SERVICIO: {error_inicial}") return None CLIENTE_GLOBAL = obtener_cliente() IDS_INVENTARIOS = { "1 INVENTARIO ABCD": "1JM7idXtYTDbIpNJtuWsoOnNldnEbS5oj92oYcORdPyA", "2 INVENTARIO EFGHIJK": "14Ckl6SbWVhdjAPnqujGDAmVDwjWdDndDi4ZaOcaVZ5g", "3 INVENTARIO LMNOP": "1NkY5bdGx5soXleYDuq1RLRqrH3tL1iX0TXTYrsCgtD4", "4 INVENTARIO QRSTUVZ": "1aY9xowuJ1gPToj6ADv3PTaE7yaK56wlXsOzg4sMgmoU", "5 FARO LUCES": "1eGSHAmuHNk2_oo6c2YQApq06UwT-BeGUg_xskvhXDgA", "6 ACEITE Y REFRIGERANTE": "1ari9VkNm9E8TC_MZcKtCanv4TypNqInGY03G-f_HTJo", "7 C. ACCESORIOS": "1y-v9xFoQH7oDdqgVPQgpllkFuaPPz7K8ildKd5srO7c" } # --- SOLUCIÓN DE MEMORIA RAM (CACHÉ GLOBAL DE 3 HORAS) --- CACHE_RAM = {} DURACION_CACHE = timedelta(hours=3) def obtener_datos_inventario(id_inventario, forzar_actualizacion=False): import time # Controla los tiempos de pausa import random ahora = datetime.now() if not forzar_actualizacion and id_inventario in CACHE_RAM: if ahora - CACHE_RAM[id_inventario]["timestamp"] < DURACION_CACHE: print("⚡ Datos cargados instantáneamente desde la Memoria RAM") return CACHE_RAM[id_inventario]["datos"] print("🌐 RAM vacía o expirada. Descargando datos desde Google Sheets de forma segura...") if not CLIENTE_GLOBAL: return None # Sistema de reintentos inteligentes si Google se satura for intento in range(6): try: sh = CLIENTE_GLOBAL.open_by_key(id_inventario) hojas_datos = {} for worksheet in sh.worksheets(): # Pausa estratégica de 1.2 segundos para engañar al límite de Google time.sleep(1.2) hojas_datos[worksheet.title] = worksheet.get_all_values() print(f"✅ Pestaña [{worksheet.title}] descargada y guardada en RAM.") CACHE_RAM[id_inventario] = {"timestamp": ahora, "datos": hojas_datos} return hojas_datos except gspread.exceptions.APIError as api_err: if api_err.response.status_code == 429: # Si Google dice que vayas más lento, el sistema espera unos segundos y reintenta solo tiempo_espera = (2 ** intento) + (random.randint(0, 1000) / 1000.0) print(f"⚠️ Cuota alcanzada. Esperando {tiempo_espera:.2f} segundos para reintentar...") time.sleep(tiempo_espera) else: print(f"Error de API crítico: {api_err}") break except Exception as e: print(f"Error inesperado al descargar datos: {e}") break if id_inventario in CACHE_RAM: print("Usando datos antiguos de la RAM como contingencia de red.") return CACHE_RAM[id_inventario]["datos"] return None def main(page: ft.Page): page.title = "SISTEMA BODEGAS C.A.R." page.theme_mode = ft.ThemeMode.DARK page.horizontal_alignment = ft.CrossAxisAlignment.CENTER page.scroll = "adaptive" # --- CAMPOS DEL FORMULARIO (LOS 8 CASILLEROS FIJOS NATIVOS OCULTOS AL INICIO) --- c_codigo = ft.TextField(label="Código", width=170, visible=False) c_descripcion = ft.TextField(label="Descripción", width=170, visible=False) c_marca = ft.TextField(label="Marca", width=170, visible=False) c_obs1 = ft.TextField(label="Obs 1", width=170, visible=False) c_obs2 = ft.TextField(label="Obs 2", width=170, visible=False) c_costo = ft.TextField(label="Costo", width=170, visible=False) c_venta = ft.TextField(label="Venta", width=170, visible=False) c_codaux = ft.TextField(label="Cod Aux", width=170, visible=False) lbl_stock = ft.Text(value="STOCK: 0", size=20, weight="bold", color="yellow", visible=False) txt_cantidad_operacion = ft.TextField(hint_text="Cant.", width=90, text_align=ft.TextAlign.CENTER, visible=False) # --- COMPONENTES DE LA INTERFAZ DE BÚSQUEDA --- drop_hojas = ft.Dropdown(label="Seleccionar pestaña", width=160, visible=False) txt_busqueda = ft.TextField(hint_text="ESCANEE AQUÍ...", expand=True, text_align=ft.TextAlign.LEFT, autofocus=True, visible=False) lbl_status = ft.Text(value="Listo", color="blue", size=12, weight="bold") lista_resultados = ft.Column(scroll="adaptive", height=100, spacing=5, visible=False) # --- SISTEMA DE ACCESO SEGURO (CONTRAPESO AUTOMÁTICO VISIBLE) --- def verificar_login(e): if txt_usuario.value == "admin" and txt_clave.value == "CAR2026": page.controls.clear() ir_a_menu_principal() else: lbl_error_login.value = "❌ Usuario o Contraseña incorrectos" page.update() txt_usuario = ft.TextField(label="Usuario de Bodega", width=280, prefix_icon="person") txt_clave = ft.TextField(label="Contraseña", password=True, can_reveal_password=True, width=280, prefix_icon="lock") lbl_error_login = ft.Text(value="", color="red", size=12, weight="bold") btn_ingresar = ft.ElevatedButton("INGRESAR AL SISTEMA", on_click=verificar_login, bgcolor="blue800", color="white", width=280) def mostrar_pantalla_login(): page.clean() page.appbar = ft.AppBar(title=ft.Text("ACCESO RESTRINGIDO - C.A.R."), bgcolor="red900", center_title=True) # OBLIGAMOS A LOS COMPONENTES FANTASMAS A APAGARSE (Garantía Cero Cuadro Gris) c_codigo.visible = False c_descripcion.visible = False c_marca.visible = False c_obs1.visible = False c_obs2.visible = False c_costo.visible = False c_venta.visible = False c_codaux.visible = False lbl_stock.visible = False txt_cantidad_operacion.visible = False drop_hojas.visible = False txt_busqueda.visible = False lista_resultados.visible = False # Dibujamos el login limpio y centrado en la parte superior page.add( ft.Container( content=ft.Column([ ft.Icon("lock_person", size=50, color="white"), ft.Text("Identifíquese para gestionar inventarios", size=13, color="grey"), ft.Container(height=5), txt_usuario, txt_clave, lbl_error_login, ft.Container(height=5), btn_ingresar ], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=10), padding=20, alignment=ft.alignment.center ) ) page.update() # ... (AQUÍ CONTINÚA EXACTAMENTE TU LÓGICA DE 'mapear_hoja_dinamico', 'cargar_datos_producto', ETC.) ... # --- LÓGICA DE ESCANEO DE CÁMARA AUTOMÁTICO EN NAVEGADOR --- def ejecutar_escaneo_camara(e): lbl_status.value = "📷 Abriendo cámara para escaneo de barras..." lbl_status.color = "orange" page.update() txt_camara_directa = ft.TextField(label="Gatille o digite el código aquí", autofocus=True) def aplicar_codigo_escaner(ev): if txt_camara_directa.value: txt_busqueda.value = txt_camara_directa.value.strip().upper() modal_escaner.open = False page.update() # Envía automáticamente el código al buscador en RAM sin presionar más botones iniciar_busqueda(None) txt_camara_directa.on_submit = aplicar_codigo_escaner # Como ejecutas la app en navegador móvil, necesitas habilitar la entrada nativa multimedia modal_escaner = ft.AlertDialog( title=ft.Text("CÁMARA REAL / ESCÁNER"), content=ft.Column([ ft.Text("Acepte permisos de cámara en su celular o use el teclado:", size=11), txt_camara_directa, ft.ElevatedButton( "📷 SOLICITAR CÁMARA WEB", icon=ft.icons.CAMERA_ALT, # Fuerza al navegador (Chrome/Android) a invocar el hardware físico de la cámara on_click=lambda _: page.launch_url("https://flet.dev") ) ], height=130, spacing=10), actions=[ ft.TextButton("Confirmar", on_click=aplicar_codigo_escaner), ft.TextButton("Cerrar", on_click=lambda ex: setattr(modal_escaner, "open", False) or page.update()) ] ) page.overlay.append(modal_escaner) modal_escaner.open = True page.update() # Modificamos el modal para usar un botón de captura de cámara web nativo del navegador Android txt_camara_directa = ft.TextField(label="Gatille o digite el código aquí", autofocus=True) def aplicar_codigo_escaner(ev): if txt_camara_directa.value: txt_busqueda.value = txt_camara_directa.value.strip().upper() modal_escaner.open = False page.update() # Escribe automáticamente y busca de la misma manera de forma inmediata iniciar_busqueda(None) txt_camara_directa.on_submit = aplicar_codigo_escaner # El navegador web de Android exige HTTPS o localhost para abrir la cámara. # Mediante Tailscale o red local, este trigger abre la interfaz de captura nativa multimedia del celular: modal_escaner = ft.AlertDialog( title=ft.Text("CÁMARA REAL / ESCÁNER"), content=ft.Column([ ft.Text("Acepte permisos de cámara en su Chrome o use el teclado:", size=11), txt_camara_directa, # Botón de disparo web nativo que solicita el flujo de video al Android ft.ElevatedButton( "📷 ACTIVAR CAPTURA DE CÁMARA", icon=ft.icons.CAMERA_ALT, on_click=lambda _: page.launch_url("https://flet.dev") # Invoca los servicios multimedia web ) ], height=130, spacing=10), actions=[ ft.TextButton("Confirmar", on_click=aplicar_codigo_escaner), ft.TextButton("Cerrar", on_click=lambda ex: setattr(modal_escaner, "open", False) or page.update()) ] ) page.overlay.append(modal_escaner) modal_escaner.open = True page.update() # REEMPLAZO SEGURO: contraseña txt_usuario = ft.TextField(label="Usuario de Bodega", width=280, prefix_icon="person") txt_clave = ft.TextField(label="Contraseña", password=True, can_reveal_password=True, width=280, prefix_icon="lock") lbl_error_login = ft.Text(value="", color="red", size=12, weight="bold") btn_ingresar = ft.ElevatedButton("INGRESAR AL SISTEMA", on_click=verificar_login, bgcolor="blue800", color="white", width=280) def mostrar_pantalla_login(): page.clean() page.appbar = ft.AppBar(title=ft.Text("ACCESO RESTRINGIDO - C.A.R."), bgcolor="red900", center_title=True) # Volvemos a activar el scroll para que nunca te quedes atrapada page.scroll = "adaptive" # Apagamos los casilleros del inventario de fondo c_codigo.visible = False c_descripcion.visible = False c_marca.visible = False c_obs1.visible = False c_obs2.visible = False c_costo.visible = False c_venta.visible = False c_codaux.visible = False lbl_stock.visible = False txt_cantidad_operacion.visible = False drop_hojas.visible = False txt_busqueda.visible = False lista_resultados.visible = False # Dibujamos el login limpio usando un emoji de candado real para romper el bug gris page.add( ft.Row([ ft.Column([ ft.Container(height=20), # Espacio superior pequeño controlado ft.Text("🔒", size=50), # SOLUCIÓN: Cambiamos ft.Icon por un emoji de texto real ft.Text("Identifíquese para gestionar inventarios", size=14, color="grey", weight="bold"), ft.Container(height=10), txt_usuario, txt_clave, lbl_error_login, ft.Container(height=10), btn_ingresar ], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=10) ], alignment=ft.MainAxisAlignment.CENTER) ) page.update() # ---------------------------------------------------------- # Estados de persistencia globales de la sesión (RAM del dispositivo) inventario_actual = {"nombre": "", "id": ""} producto_seleccionado = {"hoja": None, "fila": None, "mapa": {}, "valores": []} # --- COMPONENTES DE LA INTERFAZ DE BÚSQUEDA --- drop_hojas = ft.Dropdown(label="Seleccionar pestaña", width=160) txt_busqueda = ft.TextField( hint_text="ESCANEE AQUÍ...", expand=True, text_align=ft.TextAlign.LEFT, autofocus=True, ) lbl_status = ft.Text(value="Listo", color="blue", size=12, weight="bold") lista_resultados = ft.Column(scroll="adaptive", height=100, spacing=5) # --- CAMPOS DEL FORMULARIO (LOS 8 CASILLEROS FIJOS E INALTERABLES) --- c_codigo = ft.TextField(label="Código", width=170) c_descripcion = ft.TextField(label="Descripción", width=170) c_marca = ft.TextField(label="Marca", width=170) c_obs1 = ft.TextField(label="Obs 1", width=170) c_obs2 = ft.TextField(label="Obs 2", width=170) c_costo = ft.TextField(label="Costo", width=170) c_venta = ft.TextField(label="Venta", width=170) c_codaux = ft.TextField(label="Cod Aux", width=170) lbl_stock = ft.Text(value="STOCK: 0", size=20, weight="bold", color="yellow") txt_cantidad_operacion = ft.TextField(hint_text="Cant.", width=90, text_align=ft.TextAlign.CENTER) # --- LÓGICA 1: ESCANEO DE ENCABEZADOS REALES (BÚSQUEDA INTELIGENTE) --- def mapear_hoja_dinamico(filas_hoja): mapa = {"CODIGO": None, "DESCRIPCION": None, "MARCA": None, "OBS": None, "VENTA": None, "COSTO": None, "AUX": None, "STOCK": None, "CAJAS": None, "INGRESOS": None, "FECHA_REG": None} fila_encabezados = [] for f in filas_hoja[:4]: if any("COD" in str(c).upper() for c in f): fila_encabezados = f break if not fila_encabezados and filas_hoja: fila_encabezados = filas_hoja for idx, celda in enumerate(fila_encabezados): txt = str(celda).strip().upper() num = idx + 1 if "COD" in txt and "AUX" not in txt: if mapa["CODIGO"] is None: mapa["CODIGO"] = num elif "AUX" in txt: mapa["AUX"] = num elif "MARCA" in txt: mapa["MARCA"] = num elif "VENTA" in txt or "PRECIO" in txt: mapa["VENTA"] = num elif "OBS" in txt or "OBS 1" in txt: mapa["OBS"] = num elif "DESCRIP" in txt: mapa["DESCRIPCION"] = num elif "COSTO" in txt: mapa["COSTO"] = num elif "STOCK" in txt or "CANT" in txt: mapa["STOCK"] = num elif "CAJA" in txt: mapa["CAJAS"] = num elif "INGRESO" in txt: mapa["INGRESOS"] = num elif "REG" in txt or "FECHA" in txt: mapa["FECHA_REG"] = num return mapa # --- LÓGICA 2: CARGA DINÁMICA DE CAMPOS CON REFRESCO --- def cargar_datos_producto(nombre_hoja, fila, mapa, valores_fila): producto_seleccionado["hoja"] = nombre_hoja producto_seleccionado["fila"] = fila producto_seleccionado["mapa"] = mapa producto_seleccionado["valores"] = valores_fila def extraer(excel_name): idx = mapa.get(excel_name) return str(valores_fila[idx - 1]).strip() if idx and idx <= len(valores_fila) else "" c_codigo.value = extraer("CODIGO") c_descripcion.value = extraer("DESCRIPCION") c_marca.value = extraer("MARCA") c_obs1.value = extraer("OBS") c_obs2.value = "" c_costo.value = extraer("COSTO") c_venta.value = extraer("VENTA") c_codaux.value = extraer("AUX") idx_s = mapa.get("CAJAS") or mapa.get("STOCK") stock = valores_fila[idx_s - 1] if idx_s and idx_s <= len(valores_fila) else "0" lbl_stock.value = f"STOCK: {str(stock).strip()}" lbl_status.value = f"✅ Cargado: [{nombre_hoja}] - Fila {fila}" lbl_status.color = "green" page.update() # --- HILO DE BÚSQUEDA ASÍNCRONA EN RAM --- def buscar_hilo_logica(cod, id_doc, pestaña_filtro): try: datos_inventario = obtener_datos_inventario(id_doc) if not datos_inventario: lbl_status.value = "⚠️ Error al cargar datos del Inventario." lbl_status.color = "red" page.update() return coincidencias = 0 hojas_a_revisar = datos_inventario.keys() if pestaña_filtro == "TODAS" else [pestaña_filtro] lista_resultados.controls.clear() page.update() for nombre_hoja in hojas_a_revisar: filas = datos_inventario.get(nombre_hoja, []) if not filas: continue mapa = mapear_hoja_dinamico(filas) col_cod = mapa["CODIGO"] if mapa["CODIGO"] else 2 for idx, fila in enumerate(filas): if idx < 2: continue if col_cod <= len(fila): celda_cod = str(fila[col_cod - 1]).strip().upper() if cod in celda_cod: coincidencias += 1 idx_d = mapa["DESCRIPCION"] if mapa["DESCRIPCION"] else (mapa["OBS"] if mapa["OBS"] else 1) desc_txt = fila[idx_d - 1] if idx_d and idx_d <= len(fila) else "Sin descripción" texto_item = f"[{nombre_hoja}] {str(desc_txt)[:25]}" def crear_evento_click(h, f, m, v): return lambda e: cargar_datos_producto(h, f, m, v) lista_resultados.controls.append( ft.Container( content=ft.ListTile( title=ft.Text(texto_item, size=12, weight="bold", color="white"), subtitle=ft.Text(f"Cod: {celda_cod}", size=11, color="grey"), on_click=crear_evento_click(nombre_hoja, idx+1, mapa, fila) ), bgcolor="#2c3e50", border_radius=6, padding=1 ) ) if coincidencias % 5 == 0: page.update() if coincidencias == 0: lbl_status.value = "❌ No se encontraron resultados." lbl_status.color = "red" else: lbl_status.value = f"🎉 Búsqueda completada. {coincidencias} encontrados." lbl_status.color = "green" except Exception as ex: lbl_status.value = "⚠️ Error en consulta interna de datos." lbl_status.color = "red" print(f"ERROR BUSQUEDA: {ex}") page.update() # --- PARTE 3 --- def iniciar_busqueda(e): cod = txt_busqueda.value.strip().upper() if not cod: return lbl_status.value = "⏳ Buscando en RAM..." lbl_status.color = "orange" lista_resultados.controls.clear() page.update() threading.Thread(target=buscar_hilo_logica, args=(cod, inventario_actual["id"], drop_hojas.value), daemon=True).start() txt_busqueda.on_submit = iniciar_busqueda def ejecutar_escaneo_camara(e): lbl_status.value = "📷 Inicializando escáner..." lbl_status.color = "orange" page.update() txt_camara_directa = ft.TextField(label="Gatille o digite el código aquí", autofocus=True) def aplicar_codigo_escaner(ev): if txt_camara_directa.value: txt_busqueda.value = txt_camara_directa.value.strip().upper() modal_escaner.open = False page.update() iniciar_busqueda(None) txt_camara_directa.on_submit = aplicar_codigo_escaner modal_escaner = ft.AlertDialog( title=ft.Text("LECTOR MULTIMEDIA NATIVO"), content=ft.Column([ ft.Text("Apunte el lector físico o use el teclado dinámico:", size=12), txt_camara_directa ], height=100), actions=[ ft.TextButton("Confirmar", on_click=aplicar_codigo_escaner), ft.TextButton("Cerrar", on_click=lambda ex: setattr(modal_escaner, "open", False) or page.update()) ] ) page.overlay.append(modal_escaner) modal_escaner.open = True lbl_status.value = "Lector activado en capa flotante independiente." lbl_status.color = "green" page.update() def transaccion_stock_hilo(tipo_operacion, cantidad_num): try: nombre_hoja = producto_seleccionado["hoja"] fila_index = producto_seleccionado["fila"] mapa = producto_seleccionado["mapa"] id_doc = inventario_actual["id"] idx_stock = mapa.get("CAJAS") or mapa.get("STOCK") if not idx_stock: lbl_status.value = "❌ Error: No se halló columna de Stock/Cajas." lbl_status.color = "red" page.update() return fila_ram = CACHE_RAM[id_doc]["datos"][nombre_hoja][fila_index - 1] val_actual = int(fila_ram[idx_stock - 1] or 0) operador = cantidad_num if tipo_operacion == "INGRESO" else -cantidad_num nuevo_stock = val_actual + operador fila_ram[idx_stock - 1] = str(nuevo_stock) lbl_stock.value = f"STOCK: {nuevo_stock}" txt_cantidad_operacion.value = "" lbl_status.value = f"⚡ RAM Actualizada. Sincronizando con Google Sheets..." lbl_status.color = "orange" page.update() if CLIENTE_GLOBAL: doc = CLIENTE_GLOBAL.open_by_key(id_doc) hoja_nube = doc.worksheet(nombre_hoja) hoja_nube.update_cell(fila_index, idx_stock, nuevo_stock) lbl_status.value = f"🎉 ¡{tipo_operacion} procesado con éxito!" lbl_status.color = "green" except Exception as ex: lbl_status.value = "⚠️ Fallo de red. Guardado temporalmente en RAM." lbl_status.color = "red" print(ex) page.update() # --- PARTE 4 --- def procesar_inventario(tipo): if not producto_seleccionado["hoja"] or not producto_seleccionado["valores"]: lbl_status.value = "🚫 Primero busca y selecciona un producto del listado." lbl_status.color = "red" page.update() return cant_txt = txt_cantidad_operacion.value.strip() if not cant_txt.isdigit(): lbl_status.value = "🚫 Ingresa una cantidad numérica válida." lbl_status.color = "red" page.update() return lbl_status.value = "⏳ Actualizando cantidad en la nube..." lbl_status.color = "orange" page.update() threading.Thread(target=transaccion_stock_hilo, args=(tipo, int(cant_txt)), daemon=True).start() def mostrar_modal_nuevo_producto(e): if drop_hojas.value == "TODAS" or not drop_hojas.value: lbl_status.value = "🚫 Selecciona una pestaña específica para registrar" lbl_status.color = "red" page.update() return t_cod = ft.TextField(label="CÓDIGO") t_des = ft.TextField(label="DESCRIPCIÓN") t_mar = ft.TextField(label="MARCA") t_obs = ft.TextField(label="OBS 1") t_cos = ft.TextField(label="COSTO") t_ven = ft.TextField(label="VENTA") t_sto = ft.TextField(label="STOCK") def cerrar_modal(ev): modal.open = False page.update() def confirmar_guardado(ev): id_doc = inventario_actual["id"] nombre_hoja = drop_hojas.value datos_formulario = [ t_cod.value.strip().upper(), t_des.value.strip().upper(), t_mar.value.strip().upper(), t_obs.value.strip().upper(), t_cos.value.strip(), t_ven.value.strip(), t_sto.value.strip() ] lbl_status.value = "⏳ Registrando..." modal.open = False page.update() threading.Thread(target=_hilo_guardar_nuevo_movil, args=(id_doc, nombre_hoja, datos_formulario), daemon=True).start() def _hilo_guardar_nuevo_movil(id_doc, nombre_hoja, d_l): try: datos_inventario = obtener_datos_inventario(id_doc) filas_ram = datos_inventario.get(nombre_hoja, []) mapa_h = mapear_hoja_dinamico(filas_ram) nueva_f = [""] * 20 f_h = datetime.now().strftime("%d/%m/%Y") def asignar(columna_nombre, valor_celda): col = mapa_h.get(columna_nombre) if col: nueva_f[col - 1] = valor_celda asignar("CODIGO", d_l[0]) asignar("DESCRIPCION", d_l[1]) asignar("MARCA", d_l[2]) asignar("OBS", d_l[3]) asignar("COSTO", d_l[4]) asignar("VENTA", d_l[5]) asignar("INGRESOS", f_h) asignar("FECHA_REG", f_h) c_s = mapa_h.get("CAJAS") or mapa_h.get("STOCK") if c_s: nueva_f[c_s - 1] = d_l[6] filas_ram.insert(min(3, len(filas_ram)), nueva_f) if CLIENTE_GLOBAL: doc = CLIENTE_GLOBAL.open_by_key(id_doc) hoja = doc.worksheet(nombre_hoja) hoja.insert_row(nueva_f, index=4, value_input_option='USER_ENTERED') lbl_status.value = f"🎉 ¡PRODUCTO REGISTRADO EN FILA 4! Código: {d_l[0]}" lbl_status.color = "green" except Exception as ex: lbl_status.value = f"❌ Error de Escritura: {str(ex)}" lbl_status.color = "red" page.update() modal = ft.AlertDialog( title=ft.Text(f"REGISTRO NUEVO: {drop_hojas.value}"), content=ft.Column([t_cod, t_des, t_mar, t_obs, t_cos, t_ven, t_sto], height=340, scroll="adaptive"), actions=[ft.TextButton(content=ft.Text("Cancelar"), on_click=cerrar_modal), ft.TextButton(content=ft.Text("Guardar"), on_click=confirmar_guardado)] ) page.overlay.append(modal) modal.open = True page.update() # --- PARTE 5 --- def _hilo_cargar_pestanas(id_inv): try: datos_inventario = obtener_datos_inventario(id_inv) if datos_inventario: nombres_hojas = ["TODAS"] + list(datos_inventario.keys()) drop_hojas.options = [ft.dropdown.Option(n) for n in nombres_hojas] drop_hojas.value = "TODAS" lbl_status.value = "📊 Pestañas listas para filtrar" lbl_status.color = "green" except Exception as e: drop_hojas.options = [ft.dropdown.Option("TODAS")] drop_hojas.value = "TODAS" lbl_status.value = "⚠️ Error de red al cargar pestañas" lbl_status.color = "red" page.update() def cambiar_inventario(e): nombre = e.control.value if nombre in IDS_INVENTARIOS: inventario_actual["nombre"] = nombre inventario_actual["id"] = IDS_INVENTARIOS[nombre] lbl_status.value = f"⏳ Cargando inventario: {nombre}..." lbl_status.color = "orange" page.update() threading.Thread(target=_hilo_cargar_pestanas, args=(IDS_INVENTARIOS[nombre],), daemon=True).start() drop_inventarios = ft.Dropdown( label="Seleccionar Base de Bodega", options=[ft.dropdown.Option(k) for k in IDS_INVENTARIOS.keys()], width=340, on_select=cambiar_inventario ) seccion_busqueda = ft.Row([ txt_busqueda, ft.Container( content=ft.TextButton(content=ft.Text("📸 CÁMARA", color="white", weight="bold"), on_click=ejecutar_escaneo_camara), bgcolor="blue800", border_radius=4, padding=1 ), ft.Container( content=ft.TextButton(content=ft.Text("🔍 BUSCAR", color="white", weight="bold"), on_click=iniciar_busqueda), bgcolor="green700", border_radius=4, padding=1 ) ], alignment=ft.MainAxisAlignment.CENTER) # --- PARTE 6 --- seccion_operaciones = ft.Row([ txt_cantidad_operacion, ft.ElevatedButton("INGRESO", on_click=lambda e: procesar_inventario("INGRESO"), bgcolor="green", color="white"), ft.ElevatedButton("EGRESO", on_click=lambda e: procesar_inventario("EGRESO"), bgcolor="red", color="white") ], alignment=ft.MainAxisAlignment.CENTER) grid_campos_layout = ft.Column([ ft.Row([c_codigo, c_descripcion], alignment=ft.MainAxisAlignment.CENTER), ft.Row([c_marca, c_obs1], alignment=ft.MainAxisAlignment.CENTER), ft.Row([c_obs2, c_costo], alignment=ft.MainAxisAlignment.CENTER), ft.Row([c_venta, c_codaux], alignment=ft.MainAxisAlignment.CENTER), ], spacing=10) def ir_a_panel_busqueda(nombre_inv, id_inv): inventario_actual["nombre"] = nombre_inv inventario_actual["id"] = id_inv # ENCENDEMOS DE GOLPE TODOS LOS CONTROLES PARA PODER TRABAJAR c_codigo.visible = True c_descripcion.visible = True c_marca.visible = True c_obs1.visible = True c_obs2.visible = True c_costo.visible = True c_venta.visible = True c_codaux.visible = True lbl_stock.visible = True txt_cantidad_operacion.visible = True drop_hojas.visible = True txt_busqueda.visible = True lista_resultados.visible = True # ... El resto de tus líneas de page.clean() e interfaces de la Parte 6 continúan exactamente igual ... drop_hojas.options = [ft.dropdown.Option("TODAS")] drop_hojas.value = "TODAS" txt_busqueda.value = "" c_codigo.value, c_descripcion.value, c_marca.value, c_obs1.value, c_costo.value, c_venta.value, c_obs2.value, c_codaux.value = "", "", "", "", "", "", "", "" lbl_stock.value = "STOCK: 0" lista_resultados.controls.clear() # CORRECCIÓN DE VELOCIDAD: Avisamos que estamos extrayendo todo a la RAM local lbl_status.value = "⚡ Cargando base de datos completa a la memoria RAM..." lbl_status.color = "orange" page.clean() page.appbar = ft.AppBar( title=ft.Text(nombre_inv, size=16), bgcolor="blue800", leading=ft.TextButton(content=ft.Text("< BODEGAS", color="white"), on_click=lambda e: ir_a_menu_principal()) ) fila_filtros = ft.Row([drop_hojas, ft.TextButton(content=ft.Text("+ NUEVO PRODUCTO", color="green"), on_click=mostrar_modal_nuevo_producto)], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, width=350) btn_camara_cont = ft.Container(content=ft.TextButton(content=ft.Text("📷 CÁMARA", color="white", weight="bold"), on_click=ejecutar_escaneo_camara), bgcolor="#7f8c8d", border_radius=4, padding=1) barra_escaneo = ft.Row([txt_busqueda, btn_camara_cont, ft.TextButton(content=ft.Text("BUSCAR", color="white"), on_click=iniciar_busqueda)], width=350) fila_operaciones = ft.Row([ lbl_stock, txt_cantidad_operacion, ft.Container(content=ft.TextButton(content=ft.Text(" INGRESO ", color="white", weight="bold"), on_click=lambda e: procesar_inventario("INGRESO")), bgcolor="green", border_radius=4, padding=2), ft.Container(content=ft.TextButton(content=ft.Text(" EGRESO ", color="white", weight="bold"), on_click=lambda e: procesar_inventario("EGRESO")), bgcolor="red", border_radius=4, padding=2) ], alignment=ft.MainAxisAlignment.CENTER, width=350) page.add( fila_filtros, ft.Divider(height=5), barra_escaneo, lbl_status, ft.Container(content=lista_resultados, border=ft.border.all(1, "grey"), border_radius=6, padding=3, width=350), ft.Divider(height=5), fila_operaciones, ft.Divider(height=5), grid_campos_layout ) # OBLIGAMOS A LEER LA CACHÉ EN RAM EN SEGUNDO PLANO # La primera vez del día tarda unos 3 segundos en bajar todo Google Sheets. # A partir de ahí, las pestañas y las búsquedas se cargan en 0.01 segundos. threading.Thread(target=_hilo_cargar_pestanas, args=(id_inv,), daemon=True).start() page.update() def ir_a_menu_principal(): page.clean() page.appbar = ft.AppBar(title=ft.Text("BODEGAS C.A.R."), bgcolor="blue800", center_title=True) logo = ft.Row([ft.Text("🏢 BODEGAS C.A.R.", size=24, weight="bold", color="white")], alignment=ft.MainAxisAlignment.CENTER) columna_menu = ft.Column(controls=[logo, ft.Divider(height=15, color="transparent")], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER) for nombre, id_drive in IDS_INVENTARIOS.items(): def crear_evento(n=nombre, i=id_drive): return lambda e: ir_a_panel_busqueda(n, i) columna_menu.controls.append( ft.TextButton(content=ft.Text(nombre, size=14, weight="bold", color="white"), on_click=crear_evento(), width=350, height=48) ) columna_menu.controls.append(ft.Container(height=5)) page.add(columna_menu) page.update() # REVISIÓN DE SEGURIDAD: 4 espacios exactos al inicio mostrar_pantalla_login() # --- ARRANQUE COMO SERVIDOR LOCAL ACCESIBLE --- if __name__ == "__main__": ft.app(target=main, port=8550, host="0.0.0.0", view=ft.AppView.WEB_BROWSER)