CoolFace
Apppublic

disenosla2pc/generador-l2pc

sourceHugging Facemitupdated 4d agoView on Hugging Face
0likes
database.py1491 linesDownload Raw Back to app
1# app/database.py2 3import gspread4from google.oauth2.service_account import Credentials5import logging6import os7import json8import traceback9import pandas as pd10import io11from googleapiclient.discovery import build12from googleapiclient.http import MediaIoBaseUpload13from . import extractor # Asegúrate de que esta línea esté presente14from datetime import datetime15import pytz16 17def registrar_auditoria(usuario, rol, modulo, accion, detalles):18    """Registra una acción en la hoja Historial_Auditoria con hora CDMX."""19    spreadsheet = _get_spreadsheet()20    if not spreadsheet: return False21    try:22        try:23            ws = spreadsheet.worksheet("Historial_Auditoria")24        except gspread.WorksheetNotFound:25            ws = spreadsheet.add_worksheet(title="Historial_Auditoria", rows=1000, cols=6)26            ws.append_row(["Fecha_Hora", "Usuario", "Rol", "Modulo", "Accion", "Detalles"])27 28        tz_cdmx = pytz.timezone('America/Mexico_City')29        ahora = datetime.now(tz_cdmx).strftime('%Y-%m-%d %H:%M:%S')30        ws.append_row([ahora, usuario, rol, modulo, accion, detalles])31        logging.info(f"Auditoría registrada: {usuario} -> {accion} en {modulo}")32        return True33    except Exception as e:34        logging.error(f"Error registrando auditoría: {e}")35        return False36 37def obtener_auditoria_db():38    """Obtiene todo el historial de auditoria."""39    spreadsheet = _get_spreadsheet()40    if not spreadsheet: return []41    try:42        ws = spreadsheet.worksheet("Historial_Auditoria")43        return ws.get_all_records()44    except Exception as e:45        logging.error(f"Error obteniendo auditoria: {e}")46        return []47 48# Lógica para recrear el JSON de credenciales en la nube49ruta_credenciales = 'config/credentials.json'50if not os.path.exists(ruta_credenciales):51    google_creds = os.environ.get("G_CREDENTIALS")52    if google_creds:53        os.makedirs('config', exist_ok=True)54        with open(ruta_credenciales, 'w') as f:55            f.write(google_creds)56            57# --- CONFIGURACIÓN ---58SCOPES = ["https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/drive"]59CREDS_FILE = 'config/credentials.json'60# app/database.py61 62# ... (cerca de la línea 13) ...63ID_BD_DISENOS = "1E04DImzdqavAyIvKZ-oUcMViZtzmy3nw2V3S2FcUIbg"64 65# --- ¡CORRECCIÓN AQUÍ! ---66# Añadir "Inventario_PV" a la lista, como solicitaste.67# He quitado "Inventario_Productos" que yo había añadido por error.68HOJAS_INVENTARIO = ["Inventario_Laptops", "Inventario_CPU", "Inventario_AIO", "Inventario_SERV", "Inventario_COMBO", "Inventario_PV", "OTROS", "Catalogo"]69HOJA_USUARIOS = "Config_Usuarios"70HOJA_CATALOGO_WEB = "Sucursales"71HOJA_BORRADORES = "Borradores_Catalogo"72HOJA_CATALOGO_WEB_STOCK = "Catalogo Web"73HOJA_API_UNIVERSAL = "API_Catalogo_Universal"74HOJA_PROMOCIONES_GLOBALES = "Promociones_Globales"75DRIVE_ROOT_FOLDER = "root"76 77def actualizar_catalogo_web_sicar(datos_limpios):78    """79    Actualiza la hoja de stock para la web desde el reporte de Sicar.80    Sobrescribe todo excepto los encabezados.81    """82    spreadsheet = _get_spreadsheet()83    if not spreadsheet: return {"success": False, "error": "No se pudo conectar a Google Sheets"}84 85    try:86        import sucursales87        lista_sucursales = sucursales.load_sucursales()88        headers = ["Clave"] + [s['nombre'] for s in lista_sucursales] + ["Total_Disponible", "Observaciones"]89        90        try:91            ws = spreadsheet.worksheet(HOJA_CATALOGO_WEB_STOCK)92        except gspread.WorksheetNotFound:93            ws = spreadsheet.add_worksheet(title=HOJA_CATALOGO_WEB_STOCK, rows=1000, cols=len(headers))94            ws.append_row(headers)95        96        # Limpiar datos anteriores (sobrescribir manteniendo encabezados)97        ws.clear()98        ws.append_row(headers)99 100        if datos_limpios:101            ws.append_rows(datos_limpios, value_input_option='USER_ENTERED')102        103        return {"success": True, "count": len(datos_limpios)}104    except Exception as e:105        logging.error(f"Error actualizando Catalogo Web Stock: {e}")106        return {"success": False, "error": str(e)}107 108# --- HOJAS DE CONFIGURACIÓN CALCULADORA ---109HOJA_CALC_SERVICIOS = "Config_Calc_Servicios"110HOJA_CALC_PRODUCTOS = "Config_Calc_Productos"111HOJA_CALC_ESCALAS = "Config_Calc_Escalas"112# --- FIN DE LA CORRECCIÓN ---113 114HOJAS_PROCESADORES = {"Intel": "Procesadores_Intel", "AMD": "Procesadores_AMD"}115 116import math117 118def limpiar_nan_json(data):119    """120    Recorre un diccionario o lista y reemplaza valores NaN o Infinity por None121    o cadenas vacías para que sea serializable a JSON estándar.122    """123    if isinstance(data, dict):124        return {k: limpiar_nan_json(v) for k, v in data.items()}125    elif isinstance(data, list):126        return [limpiar_nan_json(i) for i in data]127    elif isinstance(data, float):128        if math.isnan(data) or math.isinf(data):129            return "" # Reemplazar con cadena vacía para el frontend130    return data131 132# --- FUNCIONES AUXILIARES ---133def get_col_a1(n):134    """Convierte un índice de columna (1-based) a letras A1 (1=A, 26=Z, 27=AA)."""135    res = ""136    while n > 0:137        n, rem = divmod(n - 1, 26)138        res = chr(65 + rem) + res139    return res140 141# --- FUNCIONES DE CONEXIÓN ---142 143def get_drive_credentials():144    """Obtiene credenciales de Usuario (OAuth2) para Drive si el token existe, 145    de lo contrario cae al Service Account."""146    token_path = os.path.join('config', 'token_drive.json')147    # El token_drive.json tiene el scope 'auth/drive'148    scopes = ["https://www.googleapis.com/auth/drive"]149    150    if os.path.exists(token_path):151        try:152            from google.oauth2.credentials import Credentials153            from google.auth.transport.requests import Request154            creds = Credentials.from_authorized_user_file(token_path, scopes)155            if creds and creds.expired and creds.refresh_token:156                creds.refresh(Request())157            logging.info("SISTEMA: Usando Token de Usuario (OAuth2) para Google Drive.")158            return creds159        except Exception as e:160            logging.error(f"SISTEMA: Error al usar token_drive.json: {e}. Cayendo a Service Account.")161    162    # Fallback al service account163    return _get_google_credentials()164 165def get_drive_service():166    """Obtiene el servicio de Google Drive con las mejores credenciales disponibles."""167    creds = get_drive_credentials()168    if creds:169        from googleapiclient.discovery import build170        return build('drive', 'v3', credentials=creds, cache_discovery=False)171    return None172 173def _get_google_credentials():174    scopes = ["https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/drive"]175    if "K_SERVICE" in os.environ:176        try:177            from google.cloud import secretmanager178            project_id = os.environ.get("GCP_PROJECT", "generador-fondos-app")179            secret_name = f"projects/{project_id}/secrets/l2pc-gcp-creds/versions/latest"180            client = secretmanager.SecretManagerServiceClient()181            response = client.access_secret_version(request={"name": secret_name})182            creds_json = json.loads(response.payload.data.decode("UTF-8"))183            creds = Credentials.from_service_account_info(creds_json, scopes=scopes)184            logging.info("Credenciales cargadas desde Secret Manager (Cloud).")185            return creds186        except Exception as e:187            logging.critical(f"Error CRÍTICO al cargar credenciales desde Secret Manager: {e}")188            return None189    else:190        try:191            creds = Credentials.from_service_account_file(CREDS_FILE, scopes=scopes)192            logging.info("Credenciales cargadas desde archivo local (Desarrollo).")193            return creds194        except Exception as e:195            logging.error(f"Error de autenticación con Google: {e}")196            return None197 198def _get_gspread_client():199    creds = _get_google_credentials()200    if creds:201        return gspread.authorize(creds)202    return None203 204def _get_spreadsheet():205    client = _get_gspread_client()206    if client:207        try:208            return client.open_by_key(ID_BD_DISENOS)209        except gspread.SpreadsheetNotFound:210            logging.error(f"Error Crítico: No se encontró el Spreadsheet con ID '{ID_BD_DISENOS}'.")211    return None212 213def obtener_usuarios_db():214    """Obtiene la lista de usuarios desde la hoja Config_Usuarios."""215    spreadsheet = _get_spreadsheet()216    if not spreadsheet: return []217    try:218        try:219            ws = spreadsheet.worksheet(HOJA_USUARIOS)220            221            # Verificar si existe la columna sucursal (para compatibilidad hacia atras)222            headers = ws.row_values(1)223            if "sucursal" not in headers:224                # Actualizar la hoja para agregar la columna sucursal225                ws.update_cell(1, 4, "sucursal")226                logging.info("Columna 'sucursal' añadida a Config_Usuarios.")227                228        except gspread.WorksheetNotFound:229            # Inicializar hoja si no existe230            ws = spreadsheet.add_worksheet(title=HOJA_USUARIOS, rows=100, cols=4)231            ws.append_row(["usuario", "password_hash", "rol", "sucursal"])232            # Usuario por defecto solicitado por el user233            from werkzeug.security import generate_password_hash234            ws.append_row(["superadmin", generate_password_hash("TheL2PC2033"), "superadmin", "General"])235            logging.info("Hoja Config_Usuarios creada e inicializada.")236        237        return ws.get_all_records()238    except Exception as e:239        logging.error(f"Error al obtener usuarios: {e}")240        return []241 242def guardar_usuario_db(usuario, password_hash, rol, sucursal="General"):243    """Guarda o actualiza un usuario en la DB."""244    spreadsheet = _get_spreadsheet()245    if not spreadsheet: return False246    try:247        ws = spreadsheet.worksheet(HOJA_USUARIOS)248        data = ws.get_all_records()249        250        # Buscar si ya existe251        fila_idx = -1252        for idx, row in enumerate(data):253            if row['usuario'] == usuario:254                fila_idx = idx + 2 # +1 header, +1 1-based255                break256        257        if fila_idx > 0:258            ws.update_cell(fila_idx, 2, password_hash)259            ws.update_cell(fila_idx, 3, rol)260            ws.update_cell(fila_idx, 4, sucursal)261        else:262            ws.append_row([usuario, password_hash, rol, sucursal])263        return True264    except Exception as e:265        logging.error(f"Error al guardar usuario: {e}")266        return False267 268def editar_usuario_db(usuario_original, usuario_nuevo, password_hash_nueva, rol_nuevo, sucursal_nueva):269    """Edita un usuario existente (cambia nombre, pass, rol o sucursal)."""270    if usuario_original == 'superadmin' and usuario_nuevo != 'superadmin':271        return False # No se puede renombrar al superadmin272    spreadsheet = _get_spreadsheet()273    if not spreadsheet: return False274    try:275        ws = spreadsheet.worksheet(HOJA_USUARIOS)276        data = ws.get_all_records()277        278        fila_idx = -1279        for idx, row in enumerate(data):280            if str(row.get('usuario', '')).lower() == str(usuario_original).lower():281                fila_idx = idx + 2282                break283                284        if fila_idx > 0:285            # Si el usuario nuevo es diferente al original, verificar que no exista ya286            if usuario_nuevo != usuario_original:287                for row in data:288                    if str(row['usuario']) == usuario_nuevo:289                        return False # El nuevo nombre ya existe290            291            # Obtener datos existentes si no se proveen nuevos292            pass_final = password_hash_nueva if password_hash_nueva else str(data[fila_idx-2].get('password_hash', data[fila_idx-2].get('password', '')))293            rol_final = rol_nuevo if rol_nuevo else str(data[fila_idx-2].get('rol', 'empleado'))294            sucursal_final = sucursal_nueva if sucursal_nueva else str(data[fila_idx-2].get('sucursal', 'General'))295            296            ws.update(f"A{fila_idx}:D{fila_idx}", [[usuario_nuevo, pass_final, rol_final, sucursal_final]])297            return True298        return False299    except Exception as e:300        logging.error(f"Error al editar usuario: {e}")301        return False302 303 304def eliminar_usuario_db(usuario):305    """Elimina un usuario de la DB (solo admins, no superadmin)."""306    if usuario == 'superadmin': return False307    spreadsheet = _get_spreadsheet()308    if not spreadsheet: return False309    try:310        ws = spreadsheet.worksheet(HOJA_USUARIOS)311        data = ws.get_all_records()312        for idx, row in enumerate(data):313            if str(row.get('usuario', '')).lower() == str(usuario).lower():314                ws.delete_rows(idx + 2)315                return True316        return False317    except Exception as e:318        logging.error(f"Error al eliminar usuario: {e}")319        return False320 321# --- LÓGICA PRINCIPAL DE CARGA DE CATÁLOGO ---322 323def procesar_catalogo_subido(file_path, mapeo_usuario):324    """325    Procesa el catálogo subido implementando la 'Regla de Oro' idéntica a Apps Script.326    1. Lee el archivo (Excel o CSV) y unifica todas las filas.327    2. Itera fila por fila y clasifica el destino según el prefijo del CÓDIGO.328    3. En Hojas de Inventario: Si existe -> Actualiza Precio/Stock. Si no -> Agrega Nuevo.329    4. En Hoja Catalogo: Reemplaza todo.330    """331    logging.info(f"Iniciando procesamiento de catálogo desde: {file_path}")332    333    spreadsheet = _get_spreadsheet()334    if not spreadsheet:335        return {"errores": ["No se pudo conectar con Google Sheets."]}336 337    try:338        # Instanciamos el extractor con el mapeo del usuario339        data_extractor = extractor.ExtractorDatos(mapeo_usuario)340        341        # 1. LEER ARCHIVO (Soporte robusto para Excel y CSV)342        dfs = {}343        try:344            # Intentar leer como Excel345            dfs = pd.read_excel(file_path, sheet_name=None, dtype=str)346            logging.info(f"Archivo leído como Excel. Hojas: {list(dfs.keys())}")347        except Exception:348            try:349                # Fallback: Intentar leer como CSV350                df_csv = pd.read_csv(file_path, dtype=str)351                dfs = {'HojaUnica': df_csv}352                logging.info("Archivo leído como CSV.")353            except Exception as e_csv:354                return {"errores": [f"No se pudo leer el archivo. Asegúrate de que sea un Excel (.xlsx) o CSV válido. Error: {e_csv}"]}355 356        resumen_total = {'total_filas': 0, 'nuevos': 0, 'actualizados': 0, 'errores': []}357        358        # 2. APLANAR DATOS (Ignorar nombres de hojas del archivo subido)359        todas_filas_crudas = []360        for _, df in dfs.items():361            df = df.fillna('') # Reemplazar NaN con string vacío362            for _, row in df.iterrows():363                todas_filas_crudas.append(row.to_dict())364        365        resumen_total['total_filas'] = len(todas_filas_crudas)366        logging.info(f"Total de productos a procesar: {len(todas_filas_crudas)}")367 368        # Contenedores para clasificar los datos por hoja de destino369        # Estructura: { 'NombreHoja': [ { 'procesado': {...}, 'original': {...} }, ... ] }370        datos_por_hoja = {h: [] for h in HOJAS_INVENTARIO}371        datos_catalogo_limpios = [] # Para la hoja 'Catalogo' (Reemplazo total)372 373        # 3. CLASIFICACIÓN (Router por SKU)374        for fila in todas_filas_crudas:375            # Obtener CÓDIGO usando el mapeo376            codigo_raw = data_extractor._get_val(fila, 'CODIGO').strip().upper()377            378            if not codigo_raw: 379                continue # Saltar filas sin código380 381            # A) Datos Crudos para la hoja 'Catalogo' (siempre se guardan tal cual)382            datos_catalogo_entry = {383                'CODIGO': codigo_raw,384                'DESCRIPCION': data_extractor._get_val(fila, 'DESCRIPCION'),385                'DEPARTAMENTO': data_extractor._get_val(fila, 'DEPARTAMENTO'),386                'CATEGORIA': data_extractor._get_val(fila, 'CATEGORIA'),387                'PRECIO_NORMAL': data_extractor._get_val(fila, 'PRECIO_NORMAL'),388                'PRECIO_OFERTA': data_extractor._get_val(fila, 'PRECIO_OFERTA'),389                'EXISTENCIA': data_extractor._get_val(fila, 'EXISTENCIA')390            }391            datos_catalogo_limpios.append(datos_catalogo_entry)392 393            # B) Determinar Hoja Destino y Procesar Datos394            hoja_destino = data_extractor.determinar_hoja_destino(fila)395            datos_procesados = None396 397            try:398                if hoja_destino == 'Inventario_Laptops' or \399                   hoja_destino == 'Inventario_CPU' or \400                   hoja_destino == 'Inventario_SERV' or \401                   hoja_destino == 'Inventario_AIO':402                    datos_procesados = data_extractor.procesar_fila_generica(fila)403                elif hoja_destino == 'Inventario_COMBO':404                    datos_procesados = data_extractor.procesar_fila_combo(fila)405                elif hoja_destino == 'Inventario_PV':406                    datos_procesados = data_extractor.procesar_fila_pv(fila)407                else:408                    # Para OTROS, usamos los datos básicos mapeados a las columnas de esa hoja409                    datos_procesados = datos_catalogo_entry 410 411                if hoja_destino and datos_procesados:412                    datos_por_hoja[hoja_destino].append({413                        'procesado': datos_procesados,414                        'original': datos_catalogo_entry 415                    })416            except Exception as e:417                logging.error(f"Error procesando fila '{codigo_raw}': {e}")418                resumen_total['errores'].append(f"Error procesando {codigo_raw}: {e}")419 420        # 4. EJECUCIÓN DE OPERACIONES EN SHEETS421        422        # --- A) Hoja 'Catalogo': REEMPLAZO TOTAL (Clear & Set) ---423        try:424            ws_catalogo = spreadsheet.worksheet('Catalogo')425            ws_catalogo.clear()426            # Encabezados fijos para Catalogo427            headers_cat = ['CODIGO','DESCRIPCION','DEPARTAMENTO','CATEGORIA','PRECIO_NORMAL','PRECIO_OFERTA','EXISTENCIA']428            ws_catalogo.append_row(headers_cat)429            430            filas_cat_batch = []431            for d in datos_catalogo_limpios:432                filas_cat_batch.append([d.get(h, '') for h in headers_cat])433            434            if filas_cat_batch:435                ws_catalogo.append_rows(filas_cat_batch, value_input_option='USER_ENTERED')436            logging.info(f"Hoja 'Catalogo' regenerada con {len(filas_cat_batch)} registros.")437        except Exception as e:438            logging.error(f"Error actualizando hoja Catalogo: {e}")439            resumen_total['errores'].append(f"Error crítico en hoja Catalogo: {e}")440 441        # --- B) Hojas de Inventario: REGLA DE ORO (Update vs Append) ---442        for nombre_hoja, lista_items in datos_por_hoja.items():443            if not lista_items or nombre_hoja == 'Catalogo': continue444            445            logging.info(f"Sincronizando '{nombre_hoja}' ({len(lista_items)} items detectados)...")446            447            try:448                try:449                    ws = spreadsheet.worksheet(nombre_hoja)450                except gspread.exceptions.WorksheetNotFound:451                    logging.warning(f"Hoja '{nombre_hoja}' no existe en el Sheet. Se omitirán estos productos.")452                    continue453 454                # Leer estado actual de la hoja455                registros_actuales = ws.get_all_records()456                headers_hoja = ws.row_values(1)457                458                # Mapa de IDs existentes: { 'CODIGO': NumeroFila }459                # 'OTROS' usa la columna CODIGO, las demás usan ID_Inventario460                key_id_hoja = 'CODIGO' if nombre_hoja == 'OTROS' else 'ID_Inventario'461                462                mapa_ids = {}463                # +2 porque get_all_records es 0-indexed y la hoja tiene header (fila 1)464                for idx, record in enumerate(registros_actuales):465                    id_val = None466                    # Búsqueda insensible a mayúsculas de la clave del ID467                    for k, v in record.items():468                        if k.upper() == key_id_hoja.upper():469                            id_val = v470                            break471                    if id_val:472                        mapa_ids[str(id_val).strip().upper()] = idx + 2473 474                # Identificar columnas clave para actualización parcial y enriquecimiento475                try:476                    headers_upper = [h.upper() for h in headers_hoja]477                    col_idx_pn = headers_upper.index('PRECIO_NORMAL') + 1478                    col_idx_po = headers_upper.index('PRECIO_OFERTA') + 1479                    col_idx_ex = headers_upper.index('EXISTENCIA') + 1480                    481                    # Columnas para Enriquecimiento (Solo si están vacías)482                    col_idx_proc = headers_upper.index('PROCESADOR') + 1 if 'PROCESADOR' in headers_upper else None483                    col_idx_ram = headers_upper.index('RAM') + 1 if 'RAM' in headers_upper else None484                    col_idx_alm = headers_upper.index('ALMACENAMIENTO') + 1 if 'ALMACENAMIENTO' in headers_upper else None485                    col_idx_almext = headers_upper.index('ALMACENAMIENTO_EXTRA') + 1 if 'ALMACENAMIENTO_EXTRA' in headers_upper else None486                    col_idx_ff = headers_upper.index('FACTOR_FORMA') + 1 if 'FACTOR_FORMA' in headers_upper else None487                    488                except ValueError:489                    logging.warning(f"Faltan columnas críticas en '{nombre_hoja}'. No se pueden actualizar precios.")490                    col_idx_pn = col_idx_po = col_idx_ex = None491                    col_idx_proc = col_idx_ram = col_idx_alm = col_idx_almext = None492 493                batch_updates = [] # Lista para batch_update (celdas individuales)494                filas_nuevas = []  # Lista para append_rows (filas completas)495 496                # IDs específicos para diagnóstico solicitado por el usuario497                DEBUG_IDS = [498                    'CPUG35', 'CPUG36', 'CPUG37', 'CPUG38', 'CPUG39', 'CPUG40', 'CPUG41', 'CPUG42', 'CPUG43', 'CPUG44',499                    'LAP913', 'LAP911', 'LAP515', 'LAP368'500                ]501 502                for item in lista_items:503                    datos_proc = item['procesado'] # Datos parseados (para nuevos y enriquecimiento)504                    datos_orig = item['original']  # Datos crudos (para precios y stock)505                    506                    # Obtener ID del item procesado507                    id_item = str(datos_proc.get('ID_Inventario') or datos_proc.get('CODIGO', '')).strip().upper()508                    es_debug = id_item in DEBUG_IDS509                    510                    if id_item in mapa_ids:511                        # --- CASO 1: PRODUCTO EXISTE -> ACTUALIZAR PRECIOS (SIEMPRE) + ENRIQUECER (SI ESTA VACIO) ---512                        row_num = mapa_ids[id_item]513                        current_record = registros_actuales[row_num - 2] # El registro actual en la hoja514                        515                        if es_debug:516                            logging.info(f"🔍 DEBUG ID {id_item} (Fila {row_num})")517                            logging.info(f"   - Desc Original: {datos_orig.get('DESCRIPCION', 'N/A')}")518                            logging.info(f"   - Extracción Raw: {json.dumps(datos_proc, indent=1)}")519 520                        # 1.1 Actualización Obligatoria (Precio y Stock)521                        if col_idx_pn and col_idx_po and col_idx_ex:522                            pn = datos_orig.get('PRECIO_NORMAL', '')523                            po = datos_orig.get('PRECIO_OFERTA', '')524                            ex = datos_orig.get('EXISTENCIA', '')525                            526                            batch_updates.append({'range': f"{get_col_a1(col_idx_pn)}{row_num}", 'values': [[pn]]})527                            batch_updates.append({'range': f"{get_col_a1(col_idx_po)}{row_num}", 'values': [[po]]})528                            batch_updates.append({'range': f"{get_col_a1(col_idx_ex)}{row_num}", 'values': [[ex]]})529 530                        # 1.2 Enriquecimiento Inteligente (Solo si la celda está vacía)531                        cols_enrich = {532                            'Procesador': col_idx_proc,533                            'RAM': col_idx_ram,534                            'Almacenamiento': col_idx_alm,535                            'Almacenamiento_EXTRA': col_idx_almext,536                            'Factor_Forma': col_idx_ff,537                            'Marca': headers_upper.index('MARCA') + 1 if 'MARCA' in headers_upper else None,538                            'Modelo': headers_upper.index('MODELO') + 1 if 'MODELO' in headers_upper else None539                        }540                        541                        for key, col_idx in cols_enrich.items():542                            if col_idx:543                                # Obtener el valor actual de la hoja para esa columna544                                header_name = headers_hoja[col_idx - 1]545                                val_actual_en_sheet = str(current_record.get(header_name, '')).strip()546                                val_nuevo = str(datos_proc.get(key, '')).strip()547                                548                                if es_debug:549                                    logging.info(f"   - Col [{header_name}] Actual en Sheet: '{val_actual_en_sheet}' | Nuevo Extraído: '{val_nuevo}'")550 551                                # REGLA ESPECIAL L2PC: Factor_Forma siempre se actualiza si es CPU552                                es_factor_forma = (key == 'Factor_Forma' and nombre_hoja == 'Inventario_CPU')553 554                                # Si está vacío en la hoja (o es factor forma de CPU), intentamos llenarlo con lo nuevo555                                if not val_actual_en_sheet or val_actual_en_sheet.upper() == 'NAN' or es_factor_forma:556                                    if val_nuevo and val_nuevo.upper() != 'N/A' and val_nuevo != val_actual_en_sheet:557                                        batch_updates.append({'range': f"{get_col_a1(col_idx)}{row_num}", 'values': [[val_nuevo]]})558                                        if es_debug:559                                            logging.info(f"   - ✅ Programando actualización para {header_name} -> {val_nuevo}")560                            561                        resumen_total['actualizados'] += 1562                    else:563                        # --- CASO 2: PRODUCTO NUEVO -> AGREGAR FILA COMPLETA ---564                        row_data = []565                        for h in headers_hoja:566                            # Intentar sacar el dato procesado, si no está, buscar en original (para OTROS), sino vacío567                            val = datos_proc.get(h, '')568                            if nombre_hoja == 'OTROS' and not val:569                                val = datos_proc.get(h.upper(), '')570                            row_data.append(val)571                        filas_nuevas.append(row_data)572                        resumen_total['nuevos'] += 1573                574                # Ejecutar operaciones en lote575                if batch_updates:576                    ws.batch_update(batch_updates, value_input_option='USER_ENTERED')577                    logging.info(f"Actualizados precios/stock de {len(batch_updates)//3} productos en '{nombre_hoja}'.")578                579                if filas_nuevas:580                    ws.append_rows(filas_nuevas, value_input_option='USER_ENTERED')581                    logging.info(f"Agregados {len(filas_nuevas)} productos nuevos a '{nombre_hoja}'.")582 583            except Exception as e:584                error_msg = f"Error procesando hoja '{nombre_hoja}': {str(e)}"585                logging.error(error_msg, exc_info=True)586                resumen_total['errores'].append(error_msg)587 588        return resumen_total589 590    except Exception as e:591        error_details = traceback.format_exc()592        logging.error(f"Error crítico en procesar_catalogo_subido: {error_details}")593        return {"errores": [f"Error general del servidor: {e}"]}594 595 596def buscar_producto_por_id(id_producto):597    spreadsheet = _get_spreadsheet()598    if not spreadsheet: 599        logging.error("No se pudo conectar a Google Spreadsheet.")600        return None601    602    # --- ¡LOGGING AÑADIDO! ---603    # Limpiamos el ID buscado UNA VEZ, al inicio.604    id_buscado = id_producto.strip().upper()605    logging.info(f"--- INICIO BÚSQUEDA (Producto) --- ID Solicitado: '{id_producto}', ID Limpio Buscado: '{id_buscado}'")606    logging.info(f"Buscando en las hojas: {HOJAS_INVENTARIO}")607    # --- FIN LOGGING AÑADIDO ---608 609    for nombre_hoja in HOJAS_INVENTARIO:610        try:611            # --- ¡LOGGING AÑADIDO! ---612            logging.info(f"Intentando abrir hoja: '{nombre_hoja}'...")613            # --- FIN LOGGING AÑADIDO ---614            worksheet = spreadsheet.worksheet(nombre_hoja)615            records = worksheet.get_all_records()616            617            # --- ¡LOGGING AÑADIDO! ---618            logging.info(f"  -> ÉXITO: Hoja '{nombre_hoja}' leída. {len(records)} registros encontrados.")619            logging.info(f"  -> Buscando coincidencia en la columna 'ID_Inventario'...")620            # --- FIN LOGGING AÑADIDO ---621            622            if len(records) > 0:623                logging.info(f"  -> (Muestra de IDs en '{nombre_hoja}'):")624                for i, record in enumerate(records[:5]):625                    id_muestra = str(record.get('ID_Inventario', 'N/A')).strip()626                    logging.info(f"    -> Fila {i+2}: '{id_muestra}'")627                if len(records) > 5:628                     logging.info(f"    -> ... (y {len(records)-5} registros más)")629 630            for record in records:631                # --- ¡CORRECCIÓN .strip() APLICADA! ---632                id_en_db = str(record.get('ID_Inventario', '')).strip().upper()633                634                if id_en_db == id_buscado:635                    # --- ¡LOGGING DE ÉXITO! ---636                    logging.info(f"  -> ¡COINCIDENCIA ENCONTRADA! En hoja: '{nombre_hoja}'.")637                    logging.info(f"--- FIN BÚSQUEDA (ÉXITO) ---")638                    # --- FIN LOGGING ---639                    640                    processor_name = record.get('Procesador')641                    if processor_name:642                        proc_details = buscar_procesador(processor_name)643                        if proc_details:644                            record.update(proc_details)645                    return record646                    647        except gspread.exceptions.WorksheetNotFound:648            logging.warning(f"  -> ADVERTENCIA: La hoja '{nombre_hoja}' no existe. Saltando...")649        except Exception as e:650            logging.warning(f"  -> ERROR: No se pudo leer la hoja '{nombre_hoja}': {e}")651            continue652    653    # --- ¡LOGGING DE FRACASO! ---654    logging.warning(f"Producto '{id_buscado}' no fue encontrado en NINGUNA hoja revisada.")655    logging.warning(f"--- FIN BÚSQUEDA (FRACASO) ---")656    # --- FIN LOGGING ---657    return None658 659def obtener_producto_por_id(id_producto, nombre_hoja):660    """661    Busca un producto por ID en una hoja específica de forma directa.662    Ideal para cuando ya se conoce o sospecha el destino (ej. LAP -> Inventario_Laptops).663    """664    spreadsheet = _get_spreadsheet()665    if not spreadsheet: return None666    try:667        ws = spreadsheet.worksheet(nombre_hoja)668        records = ws.get_all_records()669        id_buscado = id_producto.strip().upper()670        671        for record in records:672            if str(record.get('ID_Inventario', '')).strip().upper() == id_buscado:673                # Enriquecer con datos de procesador si aplica674                processor_name = record.get('Procesador')675                if processor_name:676                    proc_details = buscar_procesador(processor_name)677                    if proc_details: record.update(proc_details)678                return record679    except:680        pass681    return None682 683def buscar_combo_por_id(combo_id):684    """685    @summary: Busca un combo por ID y fusiona los detalles del CPU principal686             añadiendo el prefijo 'CPU_' a las claves del CPU para evitar colisiones.687    @param combo_id: El ID del combo a buscar (ej: "COMBOI7").688    @returns: Diccionario con datos del combo + datos prefijados del CPU, o None.689    """690    spreadsheet = _get_spreadsheet() # <-- 1ª Lectura (Open)691    if not spreadsheet: return None692    try:693        worksheet = spreadsheet.worksheet("Inventario_COMBO")694        records = worksheet.get_all_records() # <-- 2ª Lectura (Read Combo)695        combo_record = None # Cambiado a combo_record para claridad696        for record in records:697            if str(record.get('ID_Inventario', '')).strip().upper() == combo_id.strip().upper():698                combo_record = record699                logging.info(f"Combo '{combo_id}' encontrado.")700                break701 702        if not combo_record:703            logging.warning(f"Combo '{combo_id}' no encontrado en 'Inventario_COMBO'.")704            return None705 706        cpu_id = combo_record.get('ID_Equipo_Principal')707        cpu_details = None # Definir fuera del 'if'708        709        if cpu_id:710            # --- ¡INICIO DE LA SOLUCIÓN! ---711            # Determinamos en qué hoja buscar basado en el prefijo (SERV -> Inventario_SERV, default -> Inventario_CPU)712            nombre_hoja_equipo = "Inventario_SERV" if str(cpu_id).upper().startswith("SERV") else "Inventario_CPU"713            logging.info(f"Buscando detalles para equipo principal: '{cpu_id}' en '{nombre_hoja_equipo}' (Optimizado)")714            715            try:716                worksheet_cpu = spreadsheet.worksheet(nombre_hoja_equipo)717                records_cpu = worksheet_cpu.get_all_records() # <-- 3ª Lectura (Read CPU/SERV)718                719                id_buscado_cpu = cpu_id.strip().upper()720                for cpu_record in records_cpu:721                    if str(cpu_record.get('ID_Inventario', '')).strip().upper() == id_buscado_cpu:722                        cpu_details = cpu_record723                        logging.info(f"Detalles para equipo '{cpu_id}' encontrados en '{nombre_hoja_equipo}'.")724                        725                        # ¡IMPORTANTE! Buscar también los detalles del procesador726                        processor_name_cpu = cpu_details.get('Procesador')727                        if processor_name_cpu:728                            proc_details_cpu = buscar_procesador(processor_name_cpu)729                            if proc_details_cpu:730                                cpu_details.update(proc_details_cpu)731                        break732 733            except gspread.exceptions.WorksheetNotFound:734                logging.error(f"Error crítico: Hoja '{nombre_hoja_equipo}' no encontrada durante la búsqueda optimizada.")735            except Exception as e:736                logging.error(f"Error en búsqueda optimizada de equipo: {e}")737            # --- FIN DE LA SOLUCIÓN ---738 739            if cpu_details:740                logging.info(f"Detalles para el CPU '{cpu_id}' fusionados.")741                # --- ¡LÓGICA DE FUSIÓN CORREGIDA! ---742                # Añade cada detalle del CPU con un prefijo 'CPU_'743                for key, value in cpu_details.items():744                    # No prefijamos el ID_Inventario del CPU, ya lo tenemos en 'ID_Equipo_Principal'745                    if key == 'ID_Inventario': 746                        continue747                    748                    # Renombra las claves del CPU (ej: 'Marca' -> 'CPU_Marca')749                    combo_record[f"CPU_{key}"] = value750                # --- FIN DE LA LÓGICA DE FUSIÓN ---751            else:752                logging.warning(f"No se encontraron detalles para el CPU principal '{cpu_id}'.")753        else:754             logging.warning(f"Combo '{combo_id}' no tiene un 'ID_Equipo_Principal' especificado.")755 756        return combo_record757 758    except gspread.exceptions.WorksheetNotFound:759        logging.error("Error crítico: No se encontró la hoja 'Inventario_COMBO'.")760        return None761    except Exception as e:762        error_details = traceback.format_exc()763        logging.error(f"Error al buscar en 'Inventario_COMBO' o al fusionar CPU: {e}\n{error_details}")764        return None765    766def obtener_inventario_completo(nombre_hoja):767    spreadsheet = _get_spreadsheet()768    if not spreadsheet: return None769    try:770        worksheet = spreadsheet.worksheet(nombre_hoja)771        all_values = worksheet.get_all_values()772        if not all_values: return {"headers": [], "records": []}773        headers = all_values[0]774        records = [dict(zip(headers, row)) for row in all_values[1:]]775        logging.info(f"Hoja '{nombre_hoja}': {len(records)} registros leídos.")776        return {"headers": headers, "records": records}777    except Exception as e:778        logging.error(f"Error al obtener inventario de '{nombre_hoja}': {e}")779        return None780 781def buscar_procesador(processor_name):782    spreadsheet = _get_spreadsheet()783    if not spreadsheet: return None784    # Asegurar que el nombre sea string para evitar errores con valores numéricos en las celdas785    search_name = str(processor_name).strip().upper()786    for marca, nombre_hoja in HOJAS_PROCESADORES.items():787        try:788            worksheet = spreadsheet.worksheet(nombre_hoja)789            records = worksheet.get_all_records()790            for record in records:791                # Aseguramos que el valor sea string para evitar errores si es un número en la hoja792                if str(record.get('PROCESADOR', '')).strip().upper() == search_name:793                    logging.info(f"ÉXITO: Detalles para '{processor_name}' encontrados en '{nombre_hoja}'.")794                    return {'Cache': record.get('CACHE'), 'GHz': record.get('GHZ')}795        except Exception as e:796            logging.error(f"Error al buscar procesador en '{nombre_hoja}': {e}")797    logging.warning(f"Detalles para '{processor_name}' NO encontrados.")798    return None799 800def guardar_o_actualizar_procesador(proc_data):801    spreadsheet = _get_spreadsheet()802    if not spreadsheet: return False803    804    # Esta importación debe estar dentro de la función para evitar importaciones circulares805    from app.processing import GeneradorImagen806    proc_data_copy = proc_data.copy()807    if "MARCA" not in proc_data_copy:808        # Pasamos None porque no necesitamos la base de datos para esta operación específica809        temp_gen = GeneradorImagen(None) 810        proc_data_copy["MARCA"] = temp_gen._clasificar_procesador(proc_data_copy.get("PROCESADOR", ""))811 812    nombre_hoja = HOJAS_PROCESADORES.get(proc_data_copy.get("MARCA"))813    if not nombre_hoja: return False814    815    try:816        worksheet = spreadsheet.worksheet(nombre_hoja)817        records = worksheet.get_all_records()818        search_name = str(proc_data_copy.get("PROCESADOR", "")).strip().upper()819        820        for i, record in enumerate(records):821            # Aseguramos que el valor sea string para evitar errores si es un número en la hoja822            if str(record.get('PROCESADOR', '')).strip().upper() == search_name:823                row_to_update = i + 2824                worksheet.update_cell(row_to_update, 2, proc_data_copy.get('CACHE', ''))825                worksheet.update_cell(row_to_update, 3, proc_data_copy.get('GHZ', ''))826                logging.info(f"Procesador '{proc_data_copy['PROCESADOR']}' ACTUALIZADO en '{nombre_hoja}'.")827                return True828 829        new_row = [proc_data_copy.get("PROCESADOR", ""), proc_data_copy.get("CACHE", ""), proc_data_copy.get("GHZ", "")]830        worksheet.append_row(new_row, value_input_option='USER_ENTERED')831        logging.info(f"Procesador '{proc_data_copy['PROCESADOR']}' CREADO en '{nombre_hoja}'.")832        return True833    except Exception as e:834        logging.error(f"Error al guardar/actualizar procesador en '{nombre_hoja}': {e}")835        return False836 837def actualizar_producto(id_producto, product_data):838    proc_nombre = product_data.get('Procesador')839    if proc_nombre and product_data.get('Cache') and product_data.get('GHz'):840        guardar_o_actualizar_procesador({'PROCESADOR': proc_nombre, 'CACHE': product_data.get('Cache'), 'GHZ': product_data.get('GHz')})841 842    spreadsheet = _get_spreadsheet()843    if not spreadsheet: return False, {}844    # Dentro de actualizar_producto845    tipo_map = {'LAP': 'Inventario_Laptops', 'ALL': 'Inventario_AIO', 'CPU': 'Inventario_CPU', 'SERV': 'Inventario_SERV', 'COMBO': 'Inventario_COMBO', 'PV': 'Inventario_PV'} # <-- Añadir PV846    nombre_hoja = tipo_map.get(id_producto[:4].upper(), tipo_map.get(id_producto[:3].upper(), tipo_map.get(id_producto[:2].upper(), 'OTROS'))) # Asegura que tome prefijos de 2 letras847    try:848        worksheet = spreadsheet.worksheet(nombre_hoja)849        headers = worksheet.row_values(1)850        cell = worksheet.find(id_producto)851        cambios_dict = {}852        if cell:853            current_values = worksheet.row_values(cell.row)854            current_record = dict(zip(headers, current_values))855            856            for k, v_new in product_data.items():857                if k not in headers: continue858                v_old = current_record.get(k, "")859                if str(v_old).strip() != str(v_new).strip():860                    cambios_dict[k] = {"ant": str(v_old).strip(), "nue": str(v_new).strip()}861                    862            current_record.update(product_data)863            fila_para_actualizar = [current_record.get(h, "") for h in headers]864            worksheet.update(f'A{cell.row}', [fila_para_actualizar], value_input_option='USER_ENTERED')865            logging.info(f"Producto '{id_producto}' actualizado (con fusión) en '{nombre_hoja}'.")866        else:867            fila_para_actualizar = [product_data.get(h, "") for h in headers]868            worksheet.append_row(fila_para_actualizar, value_input_option='USER_ENTERED')869            logging.info(f"Producto nuevo '{id_producto}' añadido a '{nombre_hoja}'.")870            cambios_dict = {"NUEVO_REGISTRO": {"ant": "NO", "nue": "SI"}}871        return True, cambios_dict872    except Exception as e:873        logging.error(f"Error al actualizar/crear producto '{id_producto}': {e}")874        return False, {}875 876def actualizar_datos_procesador(id_producto, nuevos_datos):877    spreadsheet = _get_spreadsheet()878    if not spreadsheet: return False879    for nombre_hoja in HOJAS_INVENTARIO:880        try:881            worksheet = spreadsheet.worksheet(nombre_hoja)882            cell = worksheet.find(id_producto)883            if cell:884                headers = worksheet.row_values(1)885                for key, value in nuevos_datos.items():886                    if key in headers:887                        col = headers.index(key) + 1888                        worksheet.update_cell(cell.row, col, str(value))889                return True890        except gspread.CellNotFound:891            continue892        except Exception as e:893            logging.error(f"Error al actualizar la hoja '{nombre_hoja}': {e}")894    return False895 896def actualizar_combo_y_cpu(full_data):897    """898    @summary: Actualiza/Crea el registro del Combo en 'Inventario_COMBO' y899             actualiza/crea el registro del CPU asociado en 'Inventario_CPU'.900             Maneja el renombramiento de IDs usando claves '_Original'.901    @param full_data: Diccionario con TODOS los datos del formulario (combo + cpu).902    @returns: True si ambas operaciones (o las aplicables) fueron exitosas, False si alguna falló.903    """904    spreadsheet = _get_spreadsheet()905    if not spreadsheet: return False906 907    # --- IDs para BUSCAR ---908    combo_id_original = full_data.get('ID_Inventario_Original')909    id_para_buscar_combo = combo_id_original if combo_id_original else full_data.get('ID_Inventario')910    911    cpu_id_original = full_data.get('ID_Equipo_Principal_Original')912    id_para_buscar_cpu = cpu_id_original if cpu_id_original else full_data.get('ID_Equipo_Principal')913 914    # --- IDs NUEVOS (para guardar) ---915    combo_id_nuevo = full_data.get('ID_Inventario')916    cpu_id_nuevo = full_data.get('ID_Equipo_Principal')917 918    if not id_para_buscar_combo or not cpu_id_nuevo: # El ID del CPU original puede estar vacío si es nuevo919        logging.error(f"actualizar_combo_y_cpu: Faltan IDs. ID Combo (Nuevo): {combo_id_nuevo}, ID CPU (Nuevo): {cpu_id_nuevo}")920        return False921    if not id_para_buscar_cpu and not cpu_id_original: # Si no hay ID de CPU (ni original ni nuevo), usamos el nuevo para buscar922        id_para_buscar_cpu = cpu_id_nuevo923 924 925    # 1. Separar datos para Combo y CPU926    # --- ¡CAMBIO CLAVE! Mapeo explícito desde los datos del JS ---927    datos_combo = {928        'ID_Inventario': combo_id_nuevo,929        'ID_Equipo_Principal': cpu_id_nuevo,930        'Monitor': full_data.get('Monitor'),931        'Kit_TyM': full_data.get('Kit_TyM'),932        'Web_Cam': full_data.get('Web_Cam'),933        'Bocinas': full_data.get('Bocinas'),934        'Regulador': full_data.get('Regulador'),935        'Precio_Normal': full_data.get('Combo_Precio_Normal'), # <-- Clave renombrada936        'Precio_Oferta': full_data.get('Combo_Precio_Oferta'), # <-- Clave renombrada937        'Existencia': full_data.get('Combo_Existencia'), # <-- Clave renombrada938        'Descripcion_Original': full_data.get('Descripcion_Original')939    }940    941    datos_cpu = {942        'ID_Inventario': cpu_id_nuevo,943        'Marca': full_data.get('Marca'),944        'Modelo': full_data.get('Modelo'),945        'Factor_Forma': full_data.get('Factor_Forma'),946        'Procesador': full_data.get('Procesador'),947        'Cache': full_data.get('Cache'),948        'GHz': full_data.get('GHz'),949        'RAM': full_data.get('RAM'),950        'Almacenamiento': full_data.get('Almacenamiento'),951        'Almacenamiento_EXTRA': full_data.get('Almacenamiento_EXTRA'),952        'Tarjeta_Grafica': full_data.get('Tarjeta_Grafica'),953        'WiFi': full_data.get('WiFi'),954        'Precio_Normal': full_data.get('Precio_Normal'), # <-- Clave base (del CPU)955        'Precio_Oferta': full_data.get('Precio_Oferta'), # <-- Clave base (del CPU)956        'Existencia': full_data.get('Existencia') # <-- Clave base (del CPU)957    }958    # --- FIN DEL CAMBIO ---959 960    # 2. Guardar/Actualizar Combo961    success_combo = False962    try:963        worksheet_combo = spreadsheet.worksheet("Inventario_COMBO")964        headers_combo = worksheet_combo.row_values(1)965        cell_combo = None966        try:967            cell_combo = worksheet_combo.find(id_para_buscar_combo)968        except gspread.exceptions.CellNotFound:969            pass 970 971        if cell_combo: 972            fila_para_actualizar = []973            for i, h in enumerate(headers_combo):974                if h in datos_combo:975                    fila_para_actualizar.append(datos_combo[h])976                else:977                    fila_para_actualizar.append(worksheet_combo.cell(cell_combo.row, i+1).value)978            979            worksheet_combo.update(f'A{cell_combo.row}', [fila_para_actualizar], value_input_option='USER_ENTERED')980            logging.info(f"Combo '{id_para_buscar_combo}' actualizado a '{combo_id_nuevo}' en 'Inventario_COMBO'.")981        else: 982            fila_para_actualizar = [datos_combo.get(h, "") for h in headers_combo]983            worksheet_combo.append_row(fila_para_actualizar, value_input_option='USER_ENTERED')984            logging.info(f"Nuevo Combo '{combo_id_nuevo}' añadido a 'Inventario_COMBO'.")985        success_combo = True986    except Exception as e:987        logging.error(f"Error al guardar/actualizar Combo '{id_para_buscar_combo}': {e}", exc_info=True)988        success_combo = False989 990    # 3. Guardar/Actualizar Equipo (CPU o SERV)991    success_cpu = False992    try:993        # Determinamos la hoja destino basada en el ID del CPU/Servidor994        nombre_hoja_equipo = "Inventario_SERV" if str(cpu_id_nuevo).upper().startswith("SERV") else "Inventario_CPU"995        logging.info(f"Guardando datos de equipo en '{nombre_hoja_equipo}' para ID '{cpu_id_nuevo}'.")996        997        worksheet_cpu = spreadsheet.worksheet(nombre_hoja_equipo)998        headers_cpu = worksheet_cpu.row_values(1)999        cell_cpu = None1000        try:1001            cell_cpu = worksheet_cpu.find(id_para_buscar_cpu)1002        except gspread.exceptions.CellNotFound:1003            pass1004 1005        if cell_cpu:1006            fila_para_actualizar_cpu = []1007            for i, h in enumerate(headers_cpu):1008                if h in datos_cpu:1009                    fila_para_actualizar_cpu.append(datos_cpu[h])1010                else:1011                    fila_para_actualizar_cpu.append(worksheet_cpu.cell(cell_cpu.row, i+1).value)1012 1013            worksheet_cpu.update(f'A{cell_cpu.row}', [fila_para_actualizar_cpu], value_input_option='USER_ENTERED')1014            logging.info(f"Equipo '{id_para_buscar_cpu}' actualizado a '{cpu_id_nuevo}' en '{nombre_hoja_equipo}'.")1015        else:1016            fila_para_actualizar_cpu = [datos_cpu.get(h, "") for h in headers_cpu]1017            worksheet_cpu.append_row(fila_para_actualizar_cpu, value_input_option='USER_ENTERED')1018            logging.info(f"Nuevo Equipo '{cpu_id_nuevo}' añadido a '{nombre_hoja_equipo}'.")1019        success_cpu = True1020    except Exception as e:1021        logging.error(f"Error al guardar/actualizar Equipo '{id_para_buscar_cpu}' en '{nombre_hoja_equipo}': {e}", exc_info=True)1022        success_cpu = False1023 1024    return success_combo and success_cpu1025 1026def obtener_config_calculadora():1027    """1028    Obtiene la configuración de la calculadora desde Google Sheets.1029    Si las hojas no existen, las crea con valores por defecto.1030    """1031    spreadsheet = _get_spreadsheet()1032    if not spreadsheet: return None1033 1034    config = {1035        "servicios": [],1036        "productos": [],1037        "escalas": []1038    }1039 1040    try:1041        # 1. SERVICIOS1042        try:1043            ws_serv = spreadsheet.worksheet(HOJA_CALC_SERVICIOS)1044        except gspread.WorksheetNotFound:1045            ws_serv = spreadsheet.add_worksheet(title=HOJA_CALC_SERVICIOS, rows=50, cols=6)1046            headers = ["Categoria", "Nombre", "Costo_Base", "Costo_Riesgo", "Nota_Cliente", "Tiempo_Entrega"]1047            ws_serv.append_row(headers)1048            # Datos iniciales (Legacy)1049            defaults = [1050                ["Servicio", "Formateo (W7, W10, W11)", "450", "0", "Asegurar que el respaldo se haya hecho correctamente.", "1-2 días"],1051                ["Servicio", "Formateo (Windows XP)", "1000", "0", "Sistema antiguo, verificar drivers.", "2-3 días"],1052                ["Servicio", "Mantenimiento Preventivo", "0", "0", "Limpieza física y cambio de pasta térmica.", "Inmediato"],1053                ["Servicio", "Instalación Programa Individual ($250)", "250", "0", "Verificar compatibilidad.", "Inmediato"],1054                ["Servicio", "Instalación Suite (Adobe/Corel) ($500)", "500", "0", "Software pesado.", "Inmediato"],1055                ["Servicio", "Abarrotes PV - Instalación Completa ($1000)", "1000", "0", "Incluye capacitación.", "2-3 días"],1056                ["Servicio", "Abarrotes PV - Instalación Rápida ($500)", "500", "0", "Solo instalación básica.", "1-2 días"],1057                ["Servicio", "Eleventa Monocaja", "1800", "0", "Licencia + Instalación.", "1-2 días"],1058                ["Servicio", "Eleventa Multicaja", "3700", "0", "Server + 1 Terminal.", "2-3 días"]1059            ]1060            ws_serv.append_rows(defaults)1061        config["servicios"] = ws_serv.get_all_records()1062 1063        # 2. PRODUCTOS1064        try:1065            ws_prod = spreadsheet.worksheet(HOJA_CALC_PRODUCTOS)1066        except gspread.WorksheetNotFound:1067            ws_prod = spreadsheet.add_worksheet(title=HOJA_CALC_PRODUCTOS, rows=50, cols=5)1068            headers = ["Nombre", "Labor_Base", "Multiplicador_Costo", "Riesgo_Aplica", "Tiempo_Entrega"]1069            ws_prod.append_row(headers)1070            defaults = [1071                ["Pantalla Laptop", "300", "2.0", "SI", "1-2 días"],1072                ["Teclado Sobreponer", "500", "2.0", "SI", "1-2 días"],1073                ["Teclado Membrana", "1000", "2.0", "SI", "2-3 días"],1074                ["Batería Interna", "300", "2.0", "SI", "1-2 días"],1075                ["Batería Externa", "0", "2.0", "NO", "Inmediato"],1076                ["Cargador sobre pedido", "0", "Variable", "NO", "1-2 días"],1077                ["Carcasa / Bisagras", "0", "2.0", "NO", "2-3 días"],1078                ["Memoria RAM Upgrade", "200", "2.0", "NO", "Inmediato"],1079                ["Disco Sólido SSD Upgrade", "300", "2.0", "NO", "1-2 días"]1080            ]1081            ws_prod.append_rows(defaults)1082        config["productos"] = ws_prod.get_all_records()1083 1084        # 3. ESCALAS1085        try:1086            ws_esc = spreadsheet.worksheet(HOJA_CALC_ESCALAS)1087        except gspread.WorksheetNotFound:1088            ws_esc = spreadsheet.add_worksheet(title=HOJA_CALC_ESCALAS, rows=20, cols=4)1089            headers = ["Min_Valor", "Max_Valor", "Cargo_Riesgo", "Labor_Mantenimiento"]1090            ws_esc.append_row(headers)1091            defaults = [1092                ["0", "7999", "0", "450"],1093                ["8000", "9999", "300", "800"],1094                ["10000", "14999", "300", "1000"],1095                ["15000", "20000", "500", "1200"]1096            ]1097            ws_esc.append_rows(defaults)1098        config["escalas"] = ws_esc.get_all_records()1099 1100        return limpiar_nan_json(config)1101 1102    except Exception as e:1103        logging.error(f"Error al obtener config de calculadora: {e}")1104        return None1105 1106 1107def crear_carpeta_drive(drive_service, nombre, parent_id):1108    query = f"name='{nombre}' and mimeType='application/vnd.google-apps.folder' and '{parent_id}' in parents and trashed=false"1109    res = drive_service.files().list(q=query, spaces='drive', fields='files(id, name)', supportsAllDrives=True, includeItemsFromAllDrives=True).execute()1110    archivos = res.get('files', [])1111    if archivos:1112        return archivos[0]['id']1113    1114    file_metadata = {1115        'name': nombre,1116        'mimeType': 'application/vnd.google-apps.folder',1117        'parents': [parent_id]1118    }1119    carpeta = drive_service.files().create(body=file_metadata, fields='id', supportsAllDrives=True).execute()1120    1121    permiso = {'type': 'anyone', 'role': 'reader'}1122    drive_service.permissions().create(fileId=carpeta.get('id'), body=permiso).execute()1123    return carpeta.get('id')1124 1125def subir_imagen_drive(categoria, archivo_bytes, mime_type, nombre_archivo):1126    creds = _get_google_credentials()1127    if not creds: return None1128    try:1129        drive_service = get_drive_service()1130        folder_id = crear_carpeta_drive(drive_service, categoria, DRIVE_ROOT_FOLDER)1131        logging.info(f"Subiendo imagen '{nombre_archivo}' a carpeta ID: {folder_id} (Categoría: {categoria})")1132        1133        media = MediaIoBaseUpload(io.BytesIO(archivo_bytes), mimetype=mime_type, resumable=True)1134        file_metadata = {1135            'name': nombre_archivo,1136            'parents': [folder_id]1137        }1138        archivo_subido = drive_service.files().create(body=file_metadata, media_body=media, fields='id', supportsAllDrives=True).execute()1139        return archivo_subido.get('id')1140    except Exception as e:1141        logging.error(f"Error subiendo imagen a Drive: {e}")1142        return None1143 1144def publicar_catalogo_web_y_drive(registros, files_dict):1145    spreadsheet = _get_spreadsheet()1146    if not spreadsheet: return {"error": "No se pudo conectar a Google Sheets"}1147    1148    try:1149        try:1150            ws = spreadsheet.worksheet(HOJA_CATALOGO_WEB)1151        except gspread.WorksheetNotFound:1152            ws = spreadsheet.add_worksheet(title=HOJA_CATALOGO_WEB, rows=1000, cols=16)1153            encabezados = ["ID_Equipo", "Sucursal", "Categoria", "Marca", "Modelo", "Procesador", "RAM", "Almacenamiento", "Graficos", "Pantalla", "Extras", "Precio_Base", "Precio_Oferta", "Vigencia_Oferta", "Estado", "Imagenes_URL"]1154            ws.append_row(encabezados)1155 1156        encabezados = ws.row_values(1)1157        if not encabezados:1158            encabezados = ["ID_Equipo", "Sucursal", "Categoria", "Marca", "Modelo", "Procesador", "RAM", "Almacenamiento", "Graficos", "Pantalla", "Extras", "Precio_Base", "Precio_Oferta", "Vigencia_Oferta", "Estado", "Imagenes_URL"]1159            ws.append_row(encabezados)1160            1161        registros_actuales = ws.get_all_records()1162        mapa_ids = {}1163        for idx, r in enumerate(registros_actuales):1164            if 'ID_Equipo' in r:1165                mapa_ids[str(r['ID_Equipo']).upper()] = idx + 21166 1167        batch_updates = []1168        filas_nuevas = []1169        1170        for reg in registros:1171            id_equipo = str(reg.get('ID_Equipo', '')).upper()1172            categoria = reg.get('Categoria', 'Otros')1173            1174            partes_imgs = []1175            for vista in ['F', 'LS', 'RS', 'B', 'C']:1176                key = f"{id_equipo}_{vista}"1177                if key in files_dict:1178                    bites, mime_type, filename = files_dict[key]1179                    sucursal = reg.get('Sucursal', 'Sin Sucursal')1180                    id_drive = subir_imagen_drive_v2(sucursal, categoria, id_equipo, bites, mime_type, filename)1181                    if id_drive:1182                        partes_imgs.append(f"{vista}:{id_drive}")1183            1184            cadena_imgs = " | ".join(partes_imgs)1185            if cadena_imgs:1186                reg['Imagenes_URL'] = cadena_imgs1187            elif 'Imagenes_URL' not in reg:1188                reg['Imagenes_URL'] = "" 1189            1190            fila_data = [reg.get(h, '') for h in encabezados]1191            1192            if id_equipo in mapa_ids:1193                row_idx = mapa_ids[id_equipo]1194                if not cadena_imgs:1195                    try:1196                        img_idx = encabezados.index("Imagenes_URL")1197                        fila_data[img_idx] = registros_actuales[row_idx - 2].get("Imagenes_URL", "")1198                    except ValueError:1199                        pass1200                

Showing the first 1,200 of 1491 lines. Download the file for the rest.