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