disenosla2pc/generador-l2pc
0
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 