Spaces:
Runtime error
Runtime error
| import streamlit as st | |
| import pandas as pd | |
| import base64 | |
| import hashlib | |
| import hmac | |
| import json | |
| import time | |
| import urllib.parse | |
| import plotly.graph_objects as go | |
| from tavily import TavilyClient | |
| from email.message import EmailMessage | |
| from datetime import datetime, timedelta | |
| import requests | |
| import os | |
| try: | |
| import truststore | |
| truststore.inject_into_ssl() | |
| except Exception: | |
| pass | |
| try: | |
| import certifi | |
| CERTIFI_CA_BUNDLE = certifi.where() | |
| os.environ.setdefault("SSL_CERT_FILE", CERTIFI_CA_BUNDLE) | |
| os.environ.setdefault("REQUESTS_CA_BUNDLE", CERTIFI_CA_BUNDLE) | |
| os.environ.setdefault("GRPC_DEFAULT_SSL_ROOTS_FILE_PATH", CERTIFI_CA_BUNDLE) | |
| except Exception: | |
| pass | |
| import re | |
| import io | |
| import unicodedata | |
| from html import escape, unescape | |
| import crypto | |
| from dotenv import load_dotenv | |
| import database as db | |
| load_dotenv() | |
| # --- 1. CONFIGURACIÓN INICIAL Y CIBERSEGURIDAD --- | |
| st.set_page_config(page_title="Proyelec Sourcing Pro", layout="wide", page_icon="💎", initial_sidebar_state="expanded") | |
| # --- CACHE DE ESTILOS PARA EVITAR PARPADEO --- | |
| def get_css(css_mtime=0): | |
| try: | |
| with open('style.css', 'r', encoding='utf-8') as f: | |
| return f'<style>{f.read()}</style>' | |
| except: | |
| return "" | |
| css_mtime = os.path.getmtime('style.css') if os.path.exists('style.css') else 0 | |
| st.markdown(get_css(css_mtime), unsafe_allow_html=True) | |
| if "logged_in" not in st.session_state: | |
| st.session_state.logged_in = False | |
| st.session_state.username = "" | |
| st.session_state.role = "" | |
| st.session_state.gemini_key = "" | |
| st.session_state.tavily_key = "" | |
| st.session_state.email_user = "" | |
| st.session_state.email_pass = "" | |
| if "procesado" not in st.session_state: | |
| st.session_state.procesado = False | |
| if "df_exportar" not in st.session_state: | |
| st.session_state.df_exportar = pd.DataFrame() | |
| if "cg" not in st.session_state: | |
| st.session_state.cg = {} | |
| def encrypt_data(text): | |
| return crypto.encrypt_data(text) | |
| def decrypt_data(text): | |
| return crypto.decrypt_data(text) | |
| def extraer_meta_nota(nota): | |
| cierre_match = re.search(r"Cierre:\s*([^|]+)", nota) | |
| rev_match = re.search(r"Última rev:\s*([^|]+)", nota) | |
| cierre = cierre_match.group(1).strip() if cierre_match else None | |
| rev = rev_match.group(1).strip() if rev_match else None | |
| return cierre, rev | |
| API_URL_BASE = os.getenv("API_URL_BASE", "http://localhost:8000/api/v1") | |
| API_HEADERS = {"X-Internal-Token": os.getenv("INTERNAL_API_TOKEN", "default-dev-token")} | |
| BRAVE_SEARCH_API_KEY = os.getenv("BRAVE_SEARCH_API_KEY", "").strip() | |
| GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash") | |
| GEMINI_FALLBACK_MODELS = [ | |
| model.strip() | |
| for model in os.getenv("GEMINI_FALLBACK_MODELS", "gemini-2.5-flash-lite").split(",") | |
| if model.strip() | |
| ] | |
| TIEMPO_BLOQUEO = 15 # minutos — debe coincidir con database.py | |
| APP_SESSION_TTL_SECONDS = int(os.getenv("APP_SESSION_TTL_SECONDS", "43200")) | |
| SESSION_SECRET = ( | |
| os.getenv("SESSION_SECRET") | |
| or os.getenv("ENCRYPTION_KEY") | |
| or os.getenv("INTERNAL_API_TOKEN") | |
| or "procura-dev-session" | |
| ).encode("utf-8") | |
| MAPA_ESTADOS_SLI = { | |
| "EVALUACIÓN": "En Evaluacion Economica", | |
| "EVALUACION": "En Evaluacion Economica", | |
| "ADJUDICADA": "Adjudicada", | |
| "CANCELADA": "No Adjudicada", | |
| "DESIERTA": "Desierta", | |
| "CERRADA": "Oferta Enviada al SLI", | |
| "ABIERTA": "En Preparacion", | |
| } | |
| def gemini_model_candidates(): | |
| models = [GEMINI_MODEL, *GEMINI_FALLBACK_MODELS] | |
| unique = [] | |
| for model in models: | |
| if model and model not in unique: | |
| unique.append(model) | |
| return unique | |
| def is_retryable_gemini_error(exc): | |
| text = str(exc or "").lower() | |
| return any( | |
| marker in text | |
| for marker in [ | |
| "503", | |
| "unavailable", | |
| "high demand", | |
| "temporarily", | |
| "overloaded", | |
| "resource exhausted", | |
| ] | |
| ) | |
| def gemini_generate_text(api_key, prompt): | |
| from google import genai | |
| client = genai.Client(api_key=str(api_key or "").strip()) | |
| last_error = None | |
| for idx, model in enumerate(gemini_model_candidates()): | |
| try: | |
| response = client.models.generate_content(model=model, contents=prompt) | |
| return str(getattr(response, "text", "") or "").strip() | |
| except Exception as exc: | |
| last_error = exc | |
| if not is_retryable_gemini_error(exc) or idx == len(gemini_model_candidates()) - 1: | |
| raise | |
| time.sleep(1 + idx) | |
| raise last_error | |
| # --- 2. BASE DE DATOS LOCAL Y PERSISTENCIA --- | |
| def init_db(): | |
| db.init_db() | |
| return True | |
| init_db() | |
| def load_historical_prices(): | |
| df_historico = db.get_historical_prices_df() | |
| return df_historico if not df_historico.empty else None | |
| def get_historico_anios_cached(): | |
| return db.get_historico_anios() | |
| def get_historico_count_cached(): | |
| return db.get_historico_count() | |
| def get_historico_licitaciones_cached(search="", anio="Todos", limit=1000): | |
| return db.get_historico_licitaciones_df(limit=limit, search=search or None, anio=anio) | |
| def normalize_history_columns(df): | |
| rename_map = {} | |
| for col in df.columns: | |
| col_norm = str(col).lower() | |
| if "licitaci" in col_norm and "hist" in col_norm: | |
| rename_map[col] = "licitacion_hist" | |
| elif ("año" in col_norm or "anio" in col_norm) and "hist" in col_norm: | |
| rename_map[col] = "anio_hist" | |
| if rename_map: | |
| df = df.rename(columns=rename_map) | |
| return df | |
| def coerce_bool(value, default=False): | |
| if isinstance(value, bool): | |
| return value | |
| if value is None or pd.isna(value): | |
| return default | |
| text = str(value).strip().lower() | |
| if text in ["true", "si", "sí", "yes", "1", "y"]: | |
| return True | |
| if text in ["false", "no", "0", "n", "none", "null", "n/a", ""]: | |
| return False | |
| return default | |
| def coerce_optional_bool(value): | |
| if isinstance(value, bool): | |
| return value | |
| if value is None or pd.isna(value): | |
| return None | |
| text = str(value).strip().lower() | |
| if text in ["true", "si", "sí", "yes", "1", "y"]: | |
| return True | |
| if text in ["false", "no", "0", "n"]: | |
| return False | |
| return None | |
| def normalize_technical_fields(df): | |
| defaults = { | |
| "requiere_propuesta_tecnica": False, | |
| "requiere_ficha_tecnica": False, | |
| "marca_modelo_requerido": None, | |
| "acepta_equivalente": None, | |
| "posible_obsolescencia": False, | |
| "evidencia_tecnica": "", | |
| } | |
| for col, default in defaults.items(): | |
| if col not in df.columns: | |
| df[col] = default | |
| for col in ["requiere_propuesta_tecnica", "requiere_ficha_tecnica", "posible_obsolescencia"]: | |
| df[col] = df[col].apply(lambda value: coerce_bool(value, default=False)) | |
| df["acepta_equivalente"] = df["acepta_equivalente"].apply(coerce_optional_bool) | |
| return df | |
| def is_meaningful_text(value): | |
| if value is None: | |
| return False | |
| try: | |
| if pd.isna(value): | |
| return False | |
| except (TypeError, ValueError): | |
| pass | |
| text = str(value).strip() | |
| return bool(text) and text.lower() not in ["no", "n/a", "na", "nan", "none", "null", "sin restricciones", "no aplica"] | |
| def bool_label(value): | |
| if value is True: | |
| return "Sí" | |
| if value is False: | |
| return "No" | |
| return "No determinado" | |
| NOT_SPECIFIED_DOC = "No especificado en los documentos adjuntos" | |
| ACP_CODE_RE = re.compile(r"\b([A-Z]{3})-([A-Z]{3})-(\d{5})\b", re.IGNORECASE) | |
| def clean_doc_value(value, default=NOT_SPECIFIED_DOC): | |
| if value is None: | |
| return default | |
| try: | |
| if pd.isna(value): | |
| return default | |
| except (TypeError, ValueError): | |
| pass | |
| text = str(value).strip() | |
| if not text or text.lower() in ["n/a", "na", "nan", "none", "null", "no aplica", "no especificado"]: | |
| return default | |
| return text | |
| def normalize_acp_code(value): | |
| text = clean_doc_value(value, default="") | |
| if not text: | |
| return "" | |
| match = ACP_CODE_RE.search(text.upper()) | |
| if not match: | |
| return "" | |
| return f"{match.group(1).upper()}-{match.group(2).upper()}-{match.group(3)}" | |
| def acp_code_match(value): | |
| code = normalize_acp_code(value) | |
| if not ACP_CODE_RE.fullmatch(code or ""): | |
| return "" | |
| return "".join(ch for ch in code.upper() if ch.isascii() and ch.isalnum()) | |
| def normalize_item_codes(df): | |
| if "codigo_articulo" in df.columns: | |
| df = df.copy() | |
| df["codigo_articulo"] = df["codigo_articulo"].apply(normalize_acp_code) | |
| return df | |
| def parse_rows_from_scope_text(value): | |
| rows = set() | |
| if value is None: | |
| return rows, False | |
| if isinstance(value, (list, tuple, set)): | |
| all_rows = False | |
| for item in value: | |
| item_text = str(item or "").strip().lower() | |
| if item_text in ["todos", "todas", "all"]: | |
| all_rows = True | |
| rows.update(re.findall(r"\d+", item_text)) | |
| return rows, all_rows | |
| text = str(value or "").strip() | |
| if not text: | |
| return rows, False | |
| if re.search(r"\b(todos|todas|global|all)\b", text, re.IGNORECASE): | |
| return rows, True | |
| scoped_matches = re.findall( | |
| r"(?:l[ií]neas?|renglones?)\s+([0-9][0-9,\s\-yY]*)", | |
| text, | |
| flags=re.IGNORECASE, | |
| ) | |
| for match in scoped_matches: | |
| rows.update(re.findall(r"\d+", match)) | |
| return rows, False | |
| def apply_proposal_scope_from_cg(df, cg): | |
| if df.empty or "requiere_propuesta_tecnica" not in df.columns: | |
| return df, [] | |
| proposal_text = clean_doc_value(cg.get("propuesta_tecnica_requerida") if isinstance(cg, dict) else "", default="") | |
| evidence_text = clean_doc_value(cg.get("evidencia_propuesta_tecnica") if isinstance(cg, dict) else "", default="") | |
| applies_value = cg.get("propuesta_tecnica_aplica_renglones") if isinstance(cg, dict) else [] | |
| applies_rows, applies_all = parse_rows_from_scope_text(applies_value) | |
| text_rows, text_all = parse_rows_from_scope_text(f"{proposal_text} {evidence_text}") | |
| applies_rows.update(text_rows) | |
| applies_all = applies_all or text_all | |
| proposal_required = str(proposal_text).strip().lower().startswith(("si", "sí")) or bool(applies_rows) or applies_all | |
| if not proposal_required: | |
| return df, [] | |
| df = df.copy() | |
| row_values = df["renglon"].astype(str).str.extract(r"(\d+)")[0].fillna("").astype(str) if "renglon" in df.columns else pd.Series("", index=df.index) | |
| existing_rows = set(row_values[row_values != ""].tolist()) | |
| if applies_all or not applies_rows: | |
| df["requiere_propuesta_tecnica"] = True | |
| missing_rows = [] | |
| else: | |
| df["requiere_propuesta_tecnica"] = row_values.isin(applies_rows) | |
| missing_rows = sorted(applies_rows - existing_rows, key=lambda x: int(x) if x.isdigit() else x) | |
| return df, missing_rows | |
| def strip_html_markup(value): | |
| text = clean_doc_value(value, default="") | |
| if not text: | |
| return "" | |
| text = unescape(text) | |
| if re.search(r"</?(div|span|b|em|small|p|br|ul|ol|li|table|tr|td|th|pre|code)\b|class=", text, re.IGNORECASE): | |
| text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE) | |
| text = re.sub(r"</?(div|p|li|tr)\b[^>]*>", "\n", text, flags=re.IGNORECASE) | |
| text = re.sub(r"<[^>]+>", " ", text) | |
| text = re.sub(r"[ \t]+", " ", text) | |
| text = re.sub(r"\n\s*\n+", "\n", text) | |
| return text.strip() | |
| def first_doc_value(source, keys, default=NOT_SPECIFIED_DOC): | |
| if not isinstance(source, dict): | |
| return default | |
| for key in keys: | |
| value = clean_doc_value(source.get(key), default="") | |
| if value: | |
| return value | |
| return default | |
| def get_local_presence_decision(cg): | |
| local_value = None | |
| local_keys = [ | |
| "requiere_presencia_local", | |
| "empresa_local_requerida", | |
| "presencia_local_requerida", | |
| "requiere_empresa_local", | |
| "requiere_representante_local", | |
| ] | |
| if isinstance(cg, dict): | |
| for key in local_keys: | |
| if key in cg: | |
| local_value = coerce_optional_bool(cg.get(key)) | |
| if local_value is not None: | |
| break | |
| if local_value is True: | |
| return "Sí", "Participar con EP", "Requisito local detectado en el pliego.", "tone-green" | |
| if local_value is False: | |
| return "No", "Participar con Proyelec", "El pliego no exige presencia local.", "tone-blue" | |
| return NOT_SPECIFIED_DOC, "Validar antes de decidir", "No se puede confirmar solo con el documento cargado.", "tone-amber" | |
| def get_api_health(): | |
| try: | |
| r = requests.get(f"{API_URL_BASE.replace('/api/v1','')}/", timeout=2, headers=API_HEADERS) | |
| return r.status_code == 200 | |
| except Exception: | |
| return False | |
| # Workspace multi | |
| def save_workspace_state(username, cg_dict, df): | |
| lic = cg_dict.get('numero_licitacion', 'Sin_Numero') | |
| db.save_workspace(username, str(lic), df.to_json(orient="records"), json.dumps(cg_dict)) | |
| get_all_workspaces_cached.clear() | |
| def load_workspace_state(username): | |
| row = db.load_workspace_state(username) | |
| if row and row[0] and row[1]: | |
| return pd.read_json(io.StringIO(row[0])), json.loads(row[1]) | |
| return None, None | |
| def load_workspace_by_licitacion(username, licitacion): | |
| row = db.load_workspace(username, licitacion) | |
| if row and row[0] and row[1]: | |
| return pd.read_json(io.StringIO(row[0])), json.loads(row[1]) | |
| return None, None | |
| def _b64url_encode(data): | |
| return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=") | |
| def _b64url_decode(value): | |
| padding = "=" * (-len(value) % 4) | |
| return base64.urlsafe_b64decode(value + padding) | |
| def create_auth_token(username): | |
| payload = { | |
| "u": username, | |
| "exp": int(time.time()) + APP_SESSION_TTL_SECONDS, | |
| } | |
| payload_raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") | |
| payload_b64 = _b64url_encode(payload_raw) | |
| signature = hmac.new(SESSION_SECRET, payload_b64.encode("utf-8"), hashlib.sha256).hexdigest() | |
| return f"{payload_b64}.{signature}" | |
| def parse_auth_token(token): | |
| try: | |
| payload_b64, signature = str(token).split(".", 1) | |
| expected = hmac.new(SESSION_SECRET, payload_b64.encode("utf-8"), hashlib.sha256).hexdigest() | |
| if not hmac.compare_digest(signature, expected): | |
| return None | |
| payload = json.loads(_b64url_decode(payload_b64).decode("utf-8")) | |
| if int(payload.get("exp", 0)) < int(time.time()): | |
| return None | |
| username = str(payload.get("u", "")).strip() | |
| return username or None | |
| except Exception: | |
| return None | |
| def get_query_param(name, default=""): | |
| value = st.query_params.get(name, default) | |
| if isinstance(value, list): | |
| return value[0] if value else default | |
| return value | |
| def clear_auth_query_param(): | |
| current_view = get_query_param("view", "") | |
| st.query_params.clear() | |
| if current_view: | |
| st.query_params["view"] = current_view | |
| def get_shared_api_keys(): | |
| """Busca un par de API keys descifrable para analistas sin llaves propias.""" | |
| conn = db.get_connection() | |
| c = conn.cursor() | |
| c.execute(""" | |
| SELECT gemini_key, tavily_key | |
| FROM users | |
| WHERE role IN ('Gerencia', 'Supervisor', 'Admin') | |
| AND (gemini_key != '' OR tavily_key != '') | |
| ORDER BY | |
| CASE role | |
| WHEN 'Gerencia' THEN 1 | |
| WHEN 'Supervisor' THEN 2 | |
| ELSE 3 | |
| END, | |
| username | |
| """) | |
| rows = c.fetchall() | |
| conn.close() | |
| for gemini_enc, tavily_enc in rows: | |
| gemini = crypto.decrypt_data(gemini_enc) if gemini_enc else "" | |
| tavily = crypto.decrypt_data(tavily_enc) if tavily_enc else "" | |
| if gemini or tavily: | |
| return gemini, tavily | |
| return "", "" | |
| def hydrate_session_from_user_data(user_data, restore_workspace=True): | |
| st.session_state.logged_in = True | |
| st.session_state.username = user_data[0] | |
| st.session_state.role = user_data[2] if user_data[2] else "Analista" | |
| st.session_state.gemini_key = user_data[3] if user_data[3] else "" | |
| st.session_state.tavily_key = user_data[4] if user_data[4] else "" | |
| st.session_state.email_user = user_data[5] if user_data[5] else "" | |
| st.session_state.email_pass = crypto.decrypt_data(user_data[6]) if len(user_data) > 6 and user_data[6] else "" | |
| if restore_workspace: | |
| df_saved, cg_saved = load_workspace_state(user_data[0]) | |
| if df_saved is not None: | |
| st.session_state.df_exportar = df_saved | |
| st.session_state.cg = cg_saved | |
| st.session_state.procesado = True | |
| def get_user_profile_for_session(username): | |
| conn = db.get_connection() | |
| c = conn.cursor() | |
| c.execute("SELECT * FROM users WHERE LOWER(username)=LOWER(%s)", (username,)) | |
| user_data = c.fetchone() | |
| conn.close() | |
| if not user_data: | |
| return None | |
| user_list = list(user_data) | |
| user_list[3] = crypto.decrypt_data(user_list[3]) if user_list[3] else "" | |
| user_list[4] = crypto.decrypt_data(user_list[4]) if user_list[4] else "" | |
| if user_list[2] == "Analista": | |
| shared_gemini, shared_tavily = get_shared_api_keys() | |
| if not user_list[3]: | |
| user_list[3] = shared_gemini | |
| if not user_list[4]: | |
| user_list[4] = shared_tavily | |
| return tuple(user_list) | |
| def restore_persistent_session(): | |
| if st.session_state.logged_in: | |
| return | |
| token = get_query_param("auth", "") | |
| username = parse_auth_token(token) | |
| if not username: | |
| return | |
| user_data = get_user_profile_for_session(username) | |
| if user_data: | |
| hydrate_session_from_user_data(user_data) | |
| def logout_user(): | |
| clear_auth_query_param() | |
| st.session_state.clear() | |
| st.rerun() | |
| def verify_login(username, password): | |
| # 1. Verificar bloqueo por intentos fallidos | |
| bloqueado, segundos = db.esta_bloqueado(username) | |
| if bloqueado: | |
| minutos = segundos // 60 | |
| segs = segundos % 60 | |
| raise ValueError(f"Cuenta bloqueada. Intenta en {minutos}m {segs}s.") | |
| user_data = db.get_user(username, password) | |
| if user_data: | |
| # Login exitoso: limpiar contador de intentos | |
| db.resetear_intentos(username) | |
| u_list = list(user_data) | |
| role = u_list[2] | |
| # Analistas heredan solo gemini_key y tavily_key si no tienen las suyas | |
| # o si sus llaves guardadas ya no se pueden descifrar. | |
| # (NO heredan email corporativo — reduce superficie de ataque) | |
| if role == "Analista": | |
| shared_gemini, shared_tavily = get_shared_api_keys() | |
| if not u_list[3]: | |
| u_list[3] = shared_gemini | |
| if not u_list[4]: | |
| u_list[4] = shared_tavily | |
| return tuple(u_list) | |
| # Login fallido: registrar intento | |
| db.registrar_intento_fallido(username) | |
| # Comprobar si acaba de alcanzar el límite | |
| bloqueado_ahora, seg_ahora = db.esta_bloqueado(username) | |
| if bloqueado_ahora: | |
| raise ValueError(f"Demasiados intentos fallidos. Cuenta bloqueada por {TIEMPO_BLOQUEO} minutos.") | |
| return None | |
| def update_user_profile(username, gemini, tavily, email, raw_email_pass): | |
| # El cifrado de gemini, tavily y email_pass ocurre dentro de db.update_user_profile() | |
| enc_pass = crypto.encrypt_data(raw_email_pass) if raw_email_pass else "" | |
| db.update_user_profile(username, gemini, tavily, email, enc_pass) | |
| get_all_users_cached.clear() | |
| def save_history(username, licitacion, items_count): | |
| db.save_history(username, licitacion, items_count) | |
| get_user_history.clear() | |
| def get_user_history(username): | |
| return db.get_user_history_df(username) | |
| def get_all_users_cached(): | |
| df = db.get_all_users() | |
| if not df.empty: | |
| if "Usuario" in df.columns and "username" not in df.columns: | |
| df["username"] = df["Usuario"] | |
| if "Nivel" in df.columns and "role" not in df.columns: | |
| df["role"] = df["Nivel"] | |
| return df | |
| def get_system_health_counts(): | |
| conn = db.get_connection() | |
| try: | |
| total_lic_monitor = pd.read_sql_query("SELECT COUNT(*) as c FROM seguimiento_licitaciones", conn).iloc[0]['c'] | |
| total_historial = pd.read_sql_query("SELECT COUNT(*) as c FROM history", conn).iloc[0]['c'] | |
| total_fichas = pd.read_sql_query("SELECT COUNT(*) as c FROM fichas_cache", conn).iloc[0]['c'] | |
| except Exception: | |
| total_lic_monitor, total_historial, total_fichas = 0, 0, 0 | |
| conn.close() | |
| return int(total_lic_monitor), int(total_historial), int(total_fichas) | |
| def get_usage_filter_options_cached(days): | |
| return db.get_usage_filter_options(days=days) | |
| def get_usage_summary_cached(days, username, module): | |
| return db.get_usage_summary(days=days, username=username, module=module) | |
| def get_api_pricing_cached(): | |
| return db.get_api_pricing_df() | |
| def get_logistics_freight_rates_cached(): | |
| return db.get_logistics_freight_rates() | |
| def get_logistics_local_rates_cached(): | |
| return db.get_logistics_local_rates() | |
| def get_logistics_forwarders_cached(): | |
| return db.get_logistics_forwarders() | |
| def get_logistics_incoterms_cached(): | |
| return db.get_logistics_incoterms() | |
| def get_logistics_calculations_cached(limit=100): | |
| return db.get_logistics_calculations(limit=limit) | |
| def clear_logistics_cache(): | |
| get_logistics_freight_rates_cached.clear() | |
| get_logistics_local_rates_cached.clear() | |
| get_logistics_forwarders_cached.clear() | |
| get_logistics_incoterms_cached.clear() | |
| get_logistics_calculations_cached.clear() | |
| def get_seguimientos_cached(): | |
| return db.get_seguimientos() | |
| def get_historial_seguimiento_cached(licitacion_id): | |
| return db.get_historial_seguimiento(int(licitacion_id)) | |
| def get_all_workspaces_cached(username, all_users=False): | |
| return db.get_all_workspaces(username, all_users=all_users) | |
| def get_radar_cached(solo_nuevas=False, solo_hoy=False): | |
| return db.get_licitaciones_radar(solo_nuevas=solo_nuevas, solo_hoy=solo_hoy) | |
| def get_radar_scans_cached(limit=10): | |
| return db.get_ultimos_escaneos(limite=limit) | |
| def get_historico_radar_cached(): | |
| df = db.get_historico_licitaciones_df(limit=8000) | |
| if df.empty: | |
| return df | |
| codigo_col = _hist_col(df, ["Código ACP", "Código ACP", "codigo_acp"]) | |
| obs_col = _hist_col(df, ["Observaciones", "observaciones"]) | |
| codigo_values = df[codigo_col].fillna("").astype(str) if codigo_col else "" | |
| obs_values = df[obs_col].fillna("").astype(str) if obs_col else "" | |
| df = df.copy() | |
| df["_radar_codigo_norm"] = codigo_values.map(_radar_norm) if codigo_col else "" | |
| df["_radar_haystack"] = (codigo_values + " " + obs_values).map(_radar_norm) if codigo_col or obs_col else "" | |
| return df | |
| RADAR_HISTORICO_GRUPOS = { | |
| "Electrico": { | |
| "terms": ["electrico", "electricidad", "cable", "conductor", "breaker", "interruptor", "sensor", "luminaria", "transformador", "panel"], | |
| "codes": ["ELT", "ELE", "CAB", "SEN", "LUM", "PWR", "SW", "BRK"], | |
| }, | |
| "Hidraulico": { | |
| "terms": ["hidraulico", "bomba", "valvula", "cilindro", "acumulador", "manguera", "manifold", "rexroth"], | |
| "codes": ["HID", "HYD", "BOM", "PMP", "VAL", "CIL", "MAN"], | |
| }, | |
| "Mecanico": { | |
| "terms": ["mecanico", "motor", "rodamiento", "acople", "correa", "engranaje", "reductor", "sello", "resorte"], | |
| "codes": ["MEC", "MOT", "ROD", "BRG", "ACO", "COR", "SEL"], | |
| }, | |
| "Instrumentacion": { | |
| "terms": ["instrumentacion", "transmisor", "medidor", "calibrador", "controlador", "plc", "modulo", "alarma"], | |
| "codes": ["INS", "PLC", "MOD", "MET", "CTR", "CAL"], | |
| }, | |
| "Refrigeracion": { | |
| "terms": ["refrigeracion", "aire", "ventilador", "fan", "compresor", "evaporador", "condensador"], | |
| "codes": ["REF", "HVAC", "FAN", "AIR", "CMP"], | |
| }, | |
| "Ferreteria": { | |
| "terms": ["materiales", "tornillo", "tuerca", "herramienta", "ferreteria", "zocalo", "vinilo", "aislar", "tuberia"], | |
| "codes": ["HAR", "MAT", "FAB", "ALM", "TUB", "VIN"], | |
| }, | |
| } | |
| def _radar_norm(value): | |
| text = "" if value is None else str(value) | |
| text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii") | |
| return re.sub(r"\s+", " ", text.lower()).strip() | |
| def _radar_has_term(text, term): | |
| return _radar_has_norm_term(_radar_norm(text), term) | |
| def _radar_has_norm_term(norm_text, term): | |
| norm_term = _radar_norm(term) | |
| if not norm_term: | |
| return False | |
| if " " in norm_term: | |
| return norm_term in norm_text | |
| return re.search(rf"(?<![a-z0-9]){re.escape(norm_term)}(?![a-z0-9])", norm_text) is not None | |
| def _hist_col(df, candidates): | |
| if df is None or df.empty: | |
| return None | |
| columns = list(df.columns) | |
| for candidate in candidates: | |
| if candidate in columns: | |
| return candidate | |
| normalized = {_radar_norm(col): col for col in columns} | |
| for candidate in candidates: | |
| cand_norm = _radar_norm(candidate) | |
| if cand_norm in normalized: | |
| return normalized[cand_norm] | |
| return None | |
| def _radar_categories(text): | |
| norm_text = _radar_norm(text) | |
| found = [] | |
| for category, hints in RADAR_HISTORICO_GRUPOS.items(): | |
| if any(_radar_has_norm_term(norm_text, term) for term in hints["terms"]) or _radar_has_norm_term(norm_text, category): | |
| found.append(category) | |
| return found | |
| def _hist_is_win(value): | |
| norm_value = _radar_norm(value) | |
| return any(marker in norm_value for marker in ["si", "proyelec", "adjudicada", "ganada", "yes"]) | |
| def _radar_object_tokens(text): | |
| stop = {"para", "con", "del", "de", "la", "el", "los", "las", "una", "uno", "por", "suministro", "servicio", "compra"} | |
| return [t for t in re.findall(r"[a-z0-9]{4,}", _radar_norm(text)) if t not in stop][:12] | |
| def build_historico_matches_for_radar(objeto, categoria, hist_df, max_rows=12): | |
| if hist_df is None or hist_df.empty: | |
| return pd.DataFrame() | |
| lic_col = _hist_col(hist_df, ["N° Licitación", "N° Licitación", "Licitación", "numero_licitacion"]) | |
| anio_col = _hist_col(hist_df, ["Año", "Año", "anio"]) | |
| codigo_col = _hist_col(hist_df, ["Código ACP", "Código ACP", "codigo_acp"]) | |
| win_col = _hist_col(hist_df, ["Adjudicada a Proyelec", "adjudicada_a_proyelec"]) | |
| obs_col = _hist_col(hist_df, ["Observaciones", "observaciones"]) | |
| precio_col = _hist_col(hist_df, ["Precio Proyelec", "precio_proyelec"]) | |
| radar_text = f"{objeto or ''} {categoria or ''}" | |
| categories = _radar_categories(radar_text) | |
| tokens = _radar_object_tokens(radar_text) | |
| code_hints = [] | |
| category_terms = [] | |
| for category in categories: | |
| code_hints.extend(RADAR_HISTORICO_GRUPOS[category]["codes"]) | |
| category_terms.extend(RADAR_HISTORICO_GRUPOS[category]["terms"]) | |
| search_terms = sorted(set(category_terms + tokens)) | |
| if not search_terms and not code_hints: | |
| return pd.DataFrame() | |
| haystack_series = hist_df["_radar_haystack"] if "_radar_haystack" in hist_df.columns else pd.Series("", index=hist_df.index) | |
| codigo_series = hist_df["_radar_codigo_norm"] if "_radar_codigo_norm" in hist_df.columns else pd.Series("", index=hist_df.index) | |
| candidate_mask = pd.Series(False, index=hist_df.index) | |
| if search_terms: | |
| term_pattern = r"(?<![a-z0-9])(?:%s)(?![a-z0-9])" % "|".join(re.escape(_radar_norm(t)) for t in search_terms if _radar_norm(t)) | |
| candidate_mask = candidate_mask | haystack_series.str.contains(term_pattern, regex=True, na=False) | |
| if code_hints: | |
| code_pattern = "|".join(re.escape(_radar_norm(h)) for h in code_hints if _radar_norm(h)) | |
| if code_pattern: | |
| candidate_mask = candidate_mask | codigo_series.str.contains(code_pattern, regex=True, na=False) | |
| candidate_df = hist_df[candidate_mask] | |
| if candidate_df.empty: | |
| return pd.DataFrame() | |
| rows = [] | |
| for _, hist in candidate_df.iterrows(): | |
| codigo = str(hist.get(codigo_col, "") if codigo_col else "") | |
| obs = str(hist.get(obs_col, "") if obs_col else "") | |
| haystack = str(hist.get("_radar_haystack", "")) or _radar_norm(f"{codigo} {obs}") | |
| codigo_norm = str(hist.get("_radar_codigo_norm", "")) or _radar_norm(codigo) | |
| score = 0 | |
| reasons = [] | |
| for hint in code_hints: | |
| hint_norm = _radar_norm(hint) | |
| if hint_norm and hint_norm in codigo_norm: | |
| score += 4 | |
| reasons.append(f"codigo {hint}") | |
| matched_terms = [term for term in category_terms if _radar_has_norm_term(haystack, term)] | |
| if matched_terms: | |
| score += min(6, len(set(matched_terms)) * 2) | |
| reasons.append("terminos: " + ", ".join(sorted(set(matched_terms))[:3])) | |
| matched_tokens = [token for token in tokens if _radar_has_norm_term(haystack, token)] | |
| if matched_tokens: | |
| score += min(4, len(set(matched_tokens))) | |
| reasons.append("objeto: " + ", ".join(sorted(set(matched_tokens))[:3])) | |
| win = _hist_is_win(hist.get(win_col, "") if win_col else "") | |
| if win and score > 0: | |
| score += 2 | |
| if score <= 0: | |
| continue | |
| rows.append({ | |
| "score_hist": score, | |
| "licitacion_hist": hist.get(lic_col, "") if lic_col else "", | |
| "anio_hist": hist.get(anio_col, "") if anio_col else "", | |
| "codigo_acp": codigo, | |
| "precio_proyelec": hist.get(precio_col, "") if precio_col else "", | |
| "adjudicada_proyelec": "Si" if win else "No/No especificado", | |
| "evidencia": "; ".join(dict.fromkeys(reasons)), | |
| "observaciones": obs, | |
| }) | |
| if not rows: | |
| return pd.DataFrame() | |
| return pd.DataFrame(rows).sort_values(["score_hist", "anio_hist"], ascending=[False, False]).head(max_rows) | |
| def enrich_radar_with_history(radar_df, hist_df): | |
| if radar_df is None or radar_df.empty: | |
| return radar_df | |
| enriched = radar_df.copy() | |
| stats = [] | |
| for _, row in enriched.iterrows(): | |
| matches = build_historico_matches_for_radar( | |
| row.get("objeto", ""), | |
| row.get("categoria", ""), | |
| hist_df, | |
| max_rows=20, | |
| ) | |
| total = len(matches) | |
| wins = int((matches["adjudicada_proyelec"] == "Si").sum()) if total else 0 | |
| last_year = None | |
| confidence = "Sin historial" | |
| if total: | |
| years = pd.to_numeric(matches["anio_hist"], errors="coerce").dropna() | |
| last_year = int(years.max()) if not years.empty else None | |
| top_score = float(matches["score_hist"].max()) | |
| confidence = "Alta" if top_score >= 8 else "Media" if top_score >= 4 else "Baja" | |
| stats.append({ | |
| "hist_participaciones": total, | |
| "hist_ganadas": wins, | |
| "hist_ultimo_anio": last_year, | |
| "hist_confianza": confidence, | |
| }) | |
| return pd.concat([enriched.reset_index(drop=True), pd.DataFrame(stats)], axis=1) | |
| def parse_radar_datetime(value): | |
| try: | |
| from sli_scraper import parse_sli_datetime | |
| return parse_sli_datetime(value) | |
| except Exception: | |
| return None | |
| def add_radar_date_columns(df): | |
| if df is None or df.empty: | |
| return df | |
| enriched = df.copy() | |
| cierre_values = enriched["fecha_cierre"].tolist() if "fecha_cierre" in enriched.columns else [] | |
| apertura_values = enriched["fecha_apertura"].tolist() if "fecha_apertura" in enriched.columns else [] | |
| enriched["_fecha_cierre_dt"] = pd.to_datetime([parse_radar_datetime(v) for v in cierre_values], errors="coerce") if cierre_values else pd.NaT | |
| enriched["_fecha_apertura_dt"] = pd.to_datetime([parse_radar_datetime(v) for v in apertura_values], errors="coerce") if apertura_values else pd.NaT | |
| enriched["_radar_vencida"] = enriched["_fecha_cierre_dt"].notna() & (enriched["_fecha_cierre_dt"] < pd.Timestamp(datetime.now())) | |
| return enriched | |
| def apply_radar_date_filter(df, column, preset="Todas", date_range=None): | |
| if df is None or df.empty or column not in df.columns or preset == "Todas": | |
| return df | |
| filtered = df.copy() | |
| dates = pd.to_datetime(filtered[column], errors="coerce") | |
| now_dt = pd.Timestamp(datetime.now()).normalize() | |
| preset = str(preset or "Todas") | |
| if preset == "Hoy": | |
| mask = dates.dt.date == now_dt.date() | |
| elif preset == "Mañana": | |
| tomorrow = now_dt + pd.Timedelta(days=1) | |
| mask = dates.dt.date == tomorrow.date() | |
| elif preset == "Últimos 3 días": | |
| mask = dates >= (now_dt - pd.Timedelta(days=3)) | |
| elif preset == "Últimos 7 días": | |
| mask = dates >= (now_dt - pd.Timedelta(days=7)) | |
| elif preset == "Próximos 3 días": | |
| limit = now_dt + pd.Timedelta(days=3) | |
| mask = (dates >= now_dt) & (dates <= limit + pd.Timedelta(days=1)) | |
| elif preset == "Próximos 7 días": | |
| limit = now_dt + pd.Timedelta(days=7) | |
| mask = (dates >= now_dt) & (dates <= limit + pd.Timedelta(days=1)) | |
| elif preset == "Este mes": | |
| mask = (dates.dt.year == now_dt.year) & (dates.dt.month == now_dt.month) | |
| elif preset == "Rango personalizado": | |
| if not date_range or len(date_range) < 2 or not date_range[0] or not date_range[1]: | |
| return filtered | |
| start_dt = pd.Timestamp(date_range[0]).normalize() | |
| end_dt = pd.Timestamp(date_range[1]).normalize() + pd.Timedelta(days=1) | |
| mask = (dates >= start_dt) & (dates < end_dt) | |
| else: | |
| return filtered | |
| return filtered[mask.fillna(False)].copy() | |
| def sort_radar_by_dates(df): | |
| if df is None or df.empty: | |
| return df | |
| sort_cols = [c for c in ["_radar_vencida", "_fecha_cierre_dt", "_fecha_apertura_dt", "numero_licitacion"] if c in df.columns] | |
| if not sort_cols: | |
| return df | |
| return df.sort_values(sort_cols, ascending=True, na_position="last").reset_index(drop=True) | |
| def sort_radar_for_view(df, sort_mode="Publicación más reciente"): | |
| if df is None or df.empty: | |
| return df | |
| sort_mode = str(sort_mode or "Publicación más reciente") | |
| if sort_mode == "Cierre más cercano": | |
| sort_cols = [c for c in ["_radar_vencida", "_fecha_cierre_dt", "_fecha_apertura_dt", "numero_licitacion"] if c in df.columns] | |
| ascending = [True, True, False, False][:len(sort_cols)] | |
| elif sort_mode == "Cierre más lejano": | |
| sort_cols = [c for c in ["_radar_vencida", "_fecha_cierre_dt", "_fecha_apertura_dt", "numero_licitacion"] if c in df.columns] | |
| ascending = [True, False, False, False][:len(sort_cols)] | |
| elif sort_mode == "Publicación más antigua": | |
| sort_cols = [c for c in ["_fecha_apertura_dt", "_fecha_cierre_dt", "numero_licitacion"] if c in df.columns] | |
| ascending = [True, True, False][:len(sort_cols)] | |
| elif sort_mode == "Score más alto": | |
| sort_cols = [c for c in ["score_interes", "_fecha_cierre_dt", "_fecha_apertura_dt", "numero_licitacion"] if c in df.columns] | |
| ascending = [False, True, False, False][:len(sort_cols)] | |
| else: | |
| sort_cols = [c for c in ["_fecha_apertura_dt", "_fecha_cierre_dt", "numero_licitacion"] if c in df.columns] | |
| ascending = [False, True, False][:len(sort_cols)] | |
| if not sort_cols: | |
| return df.reset_index(drop=True) | |
| return df.sort_values(sort_cols, ascending=ascending, na_position="last").reset_index(drop=True) | |
| def analyze_radar_opportunity(radar_row, matches_df=None, sli_data=None): | |
| sli_data = sli_data or {} | |
| matches_df = matches_df if matches_df is not None else pd.DataFrame() | |
| objeto = str( | |
| sli_data.get("descripcion") | |
| or radar_row.get("objeto", "") | |
| or "" | |
| ) | |
| objeto_norm = _radar_norm(objeto) | |
| cierre_txt = str(sli_data.get("fecha_cierre") or radar_row.get("fecha_cierre", "") or "") | |
| cierre_dt = parse_radar_datetime(cierre_txt) | |
| now = datetime.now() | |
| score = 50 | |
| razones = [] | |
| riesgos = [] | |
| pasos = [] | |
| rubro_terms = [ | |
| "repuesto", "bomba", "motor", "cable", "electrico", "hidraulico", | |
| "sensor", "valvula", "panel", "transformador", "breaker", "rodamiento", | |
| "cilindro", "acumulador", "lubricante", "castrol", "instrumentacion", | |
| ] | |
| fuera_rubro_terms = [ | |
| "software", "licencia", "survey", "capacitacion", "alquiler", "grua", | |
| "obra", "limpieza", "consultoria", "andamio", "puerta", "lona", | |
| ] | |
| rubro_hits = [term for term in rubro_terms if _radar_has_norm_term(objeto_norm, term)] | |
| fuera_hits = [term for term in fuera_rubro_terms if _radar_has_norm_term(objeto_norm, term)] | |
| if bool(radar_row.get("es_prioritaria", False)): | |
| score += 10 | |
| razones.append("Marcada como prioritaria por palabras clave del rubro.") | |
| if rubro_hits: | |
| score += min(20, 8 + len(set(rubro_hits)) * 3) | |
| razones.append("Objeto alineado al rubro Proyelec: " + ", ".join(sorted(set(rubro_hits))[:5]) + ".") | |
| else: | |
| score -= 8 | |
| riesgos.append("No se detectaron palabras fuertes del rubro Proyelec en el objeto.") | |
| if fuera_hits: | |
| score -= min(22, 8 + len(set(fuera_hits)) * 4) | |
| riesgos.append("Puede estar fuera del foco comercial habitual: " + ", ".join(sorted(set(fuera_hits))[:5]) + ".") | |
| hist_total = len(matches_df) | |
| hist_wins = int((matches_df["adjudicada_proyelec"] == "Si").sum()) if hist_total and "adjudicada_proyelec" in matches_df.columns else 0 | |
| if hist_total: | |
| score += min(18, 8 + hist_total) | |
| razones.append(f"Hay {hist_total} participaciones historicas similares.") | |
| if hist_wins: | |
| score += min(12, hist_wins * 3) | |
| razones.append(f"Proyelec ganó {hist_wins} registro(s) histórico(s) similar(es).") | |
| else: | |
| score -= 6 | |
| riesgos.append("No hay historial similar cargado; requiere validacion manual de precios y cumplimiento.") | |
| if cierre_dt: | |
| horas = (cierre_dt - now).total_seconds() / 3600 | |
| if horas < 0: | |
| score -= 60 | |
| riesgos.append("La fecha de cierre ya paso; no conviene invertir tiempo salvo que el SLI muestre extension.") | |
| elif horas <= 24: | |
| score -= 18 | |
| riesgos.append("Cierra en menos de 24 horas; alto riesgo operativo para preparar oferta completa.") | |
| elif horas <= 72: | |
| score -= 6 | |
| riesgos.append("Cierra en menos de 3 días; validar capacidad de cotizar y subir documentos.") | |
| elif horas <= 168: | |
| score += 8 | |
| razones.append("Cierra esta semana; hay ventana razonable para evaluacion rapida.") | |
| else: | |
| score += 5 | |
| razones.append("Hay tiempo suficiente para revisar RFQ, proveedores y precios.") | |
| else: | |
| score -= 5 | |
| riesgos.append("No se pudo interpretar fecha de cierre; verificar manualmente en SLI.") | |
| estatus = str(sli_data.get("estatus", "") or "").lower() | |
| if estatus: | |
| if "abierta" in estatus: | |
| score += 5 | |
| razones.append("SLI confirma estatus abierto.") | |
| elif "vencido" in estatus or "cancel" in estatus or "desierto" in estatus: | |
| score -= 40 | |
| riesgos.append(f"SLI reporta estatus no participable: {sli_data.get('estatus')}.") | |
| else: | |
| riesgos.append(f"SLI reporta estatus: {sli_data.get('estatus')}.") | |
| if (sli_data.get("resumen_acta") or {}).get("hallazgos"): | |
| score -= 5 | |
| riesgos.append("El resumen SLI contiene observaciones; revisar antes de decidir.") | |
| score = max(0, min(100, int(round(score)))) | |
| if score >= 75: | |
| decision = "Participar" | |
| color = "success" | |
| pasos.extend([ | |
| "Pasar a seguimiento y asignar responsable.", | |
| "Descargar/revisar RFQ y confirmar restricciones técnicas.", | |
| "Cruzar renglones con histórico de precios y proveedores." | |
| ]) | |
| elif score >= 50: | |
| decision = "Revisar" | |
| color = "warning" | |
| pasos.extend([ | |
| "Revisar RFQ antes de comprometer recursos.", | |
| "Confirmar disponibilidad de proveedores y documentos requeridos.", | |
| "Validar si el objeto realmente pertenece al rubro Proyelec." | |
| ]) | |
| else: | |
| decision = "Descartar" | |
| color = "error" | |
| pasos.extend([ | |
| "No invertir tiempo comercial salvo instruccion del supervisor.", | |
| "Guardar motivo de descarte si se decide cerrarla.", | |
| ]) | |
| if not razones: | |
| razones.append("La recomendación se basa en señales limitadas del Radar; requiere revisión del RFQ.") | |
| if not riesgos: | |
| riesgos.append("No se detectaron riesgos automaticos fuertes, pero falta revisar el RFQ completo.") | |
| return { | |
| "decision": decision, | |
| "color": color, | |
| "score": score, | |
| "razones": razones[:6], | |
| "riesgos": riesgos[:6], | |
| "pasos": pasos[:5], | |
| "sli": { | |
| "estatus": sli_data.get("estatus", ""), | |
| "descripcion": sli_data.get("descripcion", ""), | |
| "fecha_cierre": sli_data.get("fecha_cierre", ""), | |
| "fecha_publicacion": sli_data.get("fecha_publicacion", ""), | |
| "url": sli_data.get("url", ""), | |
| "error": sli_data.get("error", ""), | |
| } | |
| } | |
| def clear_monitor_cache(): | |
| get_seguimientos_cached.clear() | |
| get_historial_seguimiento_cached.clear() | |
| get_system_health_counts.clear() | |
| def clear_radar_cache(): | |
| get_radar_cached.clear() | |
| get_radar_scans_cached.clear() | |
| get_historico_radar_cached.clear() | |
| def obtener_correos_licitacion(licitacion): | |
| return db.get_correos_licitacion_df(licitacion) | |
| def activar_organizacion_imap(): | |
| try: | |
| items_json = st.session_state.df_exportar.to_json(orient="records") if not st.session_state.df_exportar.empty else "[]" | |
| num_lic = "".join(re.findall(r'\d+', str(st.session_state.cg.get('numero_licitacion', '')))) | |
| datos = {"username": st.session_state.username, "licitacion_activa": num_lic, "contexto_items": items_json} | |
| res = requests.post(f"{API_URL_BASE}/organizar-correos", data=datos, headers=API_HEADERS) | |
| if res.status_code == 200: | |
| json_res = res.json() | |
| if json_res.get("status") == "success": | |
| return True, json_res.get("mensaje", "Organización completada.") | |
| else: | |
| return False, json_res.get("mensaje", "Error desconocido.") | |
| else: return False, f"Error del servidor: {res.text}" | |
| except Exception as e: return False, str(e) | |
| # --- 4. LOGIN CON IDENTIDAD PROYELEC --- | |
| restore_persistent_session() | |
| if not st.session_state.logged_in: | |
| st.markdown(""" | |
| <style> | |
| [data-testid="stAppViewContainer"] { | |
| background: #080D14; | |
| } | |
| [data-testid="stSidebar"] { display: none; } | |
| [data-testid="stHeader"] { display: none; } | |
| .block-container { | |
| padding-top: 7vh !important; | |
| max-width: 980px !important; | |
| } | |
| .login-hero-card { | |
| background: linear-gradient(135deg, #0F1722 0%, #0B1119 100%); | |
| border: 1px solid #223247; | |
| border-radius: 10px; | |
| padding: 28px; | |
| min-height: 410px; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: space-between; | |
| box-shadow: 0 24px 70px rgba(0,0,0,0.28); | |
| } | |
| .login-panel-card { | |
| background: #0F151D; | |
| border: 1px solid #2A384A; | |
| border-radius: 10px; | |
| padding: 26px; | |
| margin-top: 34px; | |
| margin-bottom: 12px; | |
| box-shadow: 0 24px 70px rgba(0,0,0,0.24); | |
| } | |
| .login-kicker { | |
| color: #7CE0B7; | |
| font-size: 11px; | |
| font-weight: 800; | |
| text-transform: uppercase; | |
| letter-spacing: 0; | |
| margin-bottom: 10px; | |
| } | |
| .login-title { | |
| font-size: 32px; font-weight: 850; color: #F0F6FC; | |
| letter-spacing: 0; line-height: 1.08; margin: 12px 0 10px 0; | |
| } | |
| .login-subtitle { | |
| font-size: 14px; color: #9BA8B8; font-weight: 500; | |
| line-height: 1.55; margin-bottom: 18px; | |
| } | |
| .login-feature-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 8px; | |
| margin-top: 18px; | |
| } | |
| .login-feature { | |
| background: #0B1017; | |
| border: 1px solid #243246; | |
| border-radius: 8px; | |
| padding: 10px; | |
| color: #B8C4D2; | |
| font-size: 11.5px; | |
| font-weight: 720; | |
| line-height: 1.35; | |
| } | |
| .login-panel-title { | |
| color: #F0F6FC; | |
| font-size: 20px; | |
| font-weight: 850; | |
| margin: 0 0 6px 0; | |
| } | |
| .login-panel-copy { | |
| color: #93A0B2; | |
| font-size: 12.5px; | |
| line-height: 1.45; | |
| margin-bottom: 18px; | |
| } | |
| .login-footer { | |
| font-size: 11px; color: #6F7D8F; margin-top: 18px; text-align: center; | |
| } | |
| .login-footnote { | |
| color: #6F7D8F; | |
| font-size: 11px; | |
| line-height: 1.45; | |
| border-top: 1px solid #1C2633; | |
| padding-top: 14px; | |
| margin-top: 20px; | |
| } | |
| [data-testid="stTextInput"] input { | |
| min-height: 44px !important; | |
| background: #0B1017 !important; | |
| border: 1px solid #26384E !important; | |
| border-radius: 8px !important; | |
| color: #E9EEF5 !important; | |
| } | |
| [data-testid="stButton"] button { | |
| min-height: 44px !important; | |
| border-radius: 8px !important; | |
| font-weight: 820 !important; | |
| } | |
| @media (max-width: 900px) { | |
| .block-container { padding-top: 3vh !important; } | |
| .login-hero-card, .login-panel-card { min-height: auto; } | |
| .login-feature-grid { grid-template-columns: 1fr; } | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| login_left, login_right = st.columns([0.56, 0.44], gap="large") | |
| with login_left: | |
| try: | |
| st.image("proyelec_logo.png", width=190) | |
| except Exception: | |
| pass | |
| st.markdown(""" | |
| <div class="login-hero-card"> | |
| <div> | |
| <div class="login-kicker">Procura AI beta interna</div> | |
| <div class="login-title">Sourcing operativo para licitaciones ACP</div> | |
| <div class="login-subtitle"> | |
| Analiza pliegos, compara histórico de precios, organiza proveedores y da seguimiento comercial desde un solo panel. | |
| </div> | |
| <div class="login-feature-grid"> | |
| <div class="login-feature">RFQ y renglones con evidencia técnica</div> | |
| <div class="login-feature">Histórico Supabase para comparar precios</div> | |
| <div class="login-feature">Radar SLI para supervisores</div> | |
| <div class="login-feature">Seguimiento de licitaciones y comentarios</div> | |
| </div> | |
| </div> | |
| <div class="login-footnote">Acceso restringido. Las sesiones se restauran al refrescar y se limpian al cerrar sesion.</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with login_right: | |
| st.markdown(""" | |
| <div class="login-panel-card"> | |
| <div class="login-panel-title">Ingresar al sistema</div> | |
| <div class="login-panel-copy">Usa tu usuario corporativo. El rol define los modulos disponibles dentro del sistema.</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| user_input = st.text_input("Usuario", placeholder="Usuario corporativo", label_visibility="collapsed") | |
| pass_input = st.text_input("Contraseña", type="password", placeholder="Contraseña", label_visibility="collapsed") | |
| st.write("") | |
| if st.button("Ingresar al Sistema", type="primary", use_container_width=True): | |
| try: | |
| user_data = verify_login(user_input, pass_input) | |
| if user_data: | |
| hydrate_session_from_user_data(user_data) | |
| st.query_params["auth"] = create_auth_token(user_data[0]) | |
| st.rerun() | |
| else: | |
| st.error("Credenciales incorrectas. Intenta de nuevo.") | |
| except ValueError as e: | |
| st.error(f"{e}") | |
| st.markdown('<div class="login-footer">Solo personal autorizado Proyelec</div>', unsafe_allow_html=True) | |
| st.stop() | |
| def current_role_mode(): | |
| role = str(st.session_state.get("role", "Analista") or "Analista") | |
| username = str(st.session_state.get("username", "") or "").lower() | |
| if username == "admin" or role == "Admin": | |
| return "Admin" | |
| if role in ["Supervisor", "Gerencia", "Logística", "Logistica"]: | |
| if role == "Logistica": | |
| return "Logística" | |
| return role | |
| return "Analista" | |
| ROLE_PROFILES = { | |
| "Analista": { | |
| "title": "Flujo de Analista", | |
| "scope": "RFQ, proveedores, histórico de precios, seguimiento y workspaces propios.", | |
| "badge": "Operativo", | |
| }, | |
| "Supervisor": { | |
| "title": "Flujo de Supervisor", | |
| "scope": "Todo Analista + Radar SLI y priorización de oportunidades.", | |
| "badge": "Supervisor", | |
| }, | |
| "Gerencia": { | |
| "title": "Flujo Gerencial", | |
| "scope": "Visión operativa, Radar, histórico corporativo y configuración propia.", | |
| "badge": "Gerencia", | |
| }, | |
| "Logística": { | |
| "title": "Centro Logístico", | |
| "scope": "Tarifas, forwarders, incoterms, cálculos logísticos e histórico corporativo.", | |
| "badge": "Logística", | |
| }, | |
| "Admin": { | |
| "title": "Administración Global", | |
| "scope": "Usuarios, roles, métricas, llaves y salud del sistema.", | |
| "badge": "Admin", | |
| }, | |
| } | |
| ROLE_PERMISSIONS = { | |
| "Analista": {"dashboard", "rfq_upload", "providers", "monitor", "workspaces", "historico", "logistics_calc"}, | |
| "Supervisor": {"dashboard", "rfq_upload", "providers", "monitor", "workspaces", "historico", "radar", "logistics_calc"}, | |
| "Gerencia": {"dashboard", "rfq_upload", "providers", "monitor", "workspaces", "radar", "historico", "settings", "logistics", "logistics_calc"}, | |
| "Logística": {"logistics", "historico"}, | |
| "Admin": {"admin"}, | |
| } | |
| def role_can(permission): | |
| return permission in ROLE_PERMISSIONS.get(current_role_mode(), ROLE_PERMISSIONS["Analista"]) | |
| def get_role_profile(): | |
| return ROLE_PROFILES.get(current_role_mode(), ROLE_PROFILES["Analista"]) | |
| # --- PANEL DE ADMINISTRACION GLOBAL --- | |
| if current_role_mode() == "Admin": | |
| st.markdown("""<style>[data-testid="stSidebar"] { display: none !important; }</style>""", unsafe_allow_html=True) | |
| df_users_admin = get_all_users_cached() | |
| role_admin_series = df_users_admin["Nivel"] if "Nivel" in df_users_admin.columns else pd.Series(dtype=str) | |
| total_admin_users = len(df_users_admin) | |
| total_admin_supervisors = int((role_admin_series == "Supervisor").sum()) if not role_admin_series.empty else 0 | |
| total_admin_analysts = int((role_admin_series == "Analista").sum()) if not role_admin_series.empty else 0 | |
| total_admin_gerencia = int((role_admin_series == "Gerencia").sum()) if not role_admin_series.empty else 0 | |
| total_admin_logistica = int(role_admin_series.isin(["Logística", "Logistica"]).sum()) if not role_admin_series.empty else 0 | |
| st.markdown(""" | |
| <div class="admin-hero"> | |
| <div> | |
| <div class="page-eyebrow">Administración Global</div> | |
| <h1 class="page-title">Panel de Control</h1> | |
| <div class="page-subtitle">Usuarios, roles, llaves, métricas y salud operativa del sistema.</div> | |
| </div> | |
| <div class="admin-hero-badge">Acceso Admin</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| a1, a2, a3, a4, a5 = st.columns(5) | |
| a1.metric("Usuarios", total_admin_users) | |
| a2.metric("Analistas", total_admin_analysts) | |
| a3.metric("Supervisores", total_admin_supervisors) | |
| a4.metric("Gerencia", total_admin_gerencia) | |
| a5.metric("Logística", total_admin_logistica) | |
| admin_view = st.radio( | |
| "Vista admin", | |
| ["👥 Gestión de Personal", "📊 Estadísticas del Sistema"], | |
| horizontal=True, | |
| label_visibility="collapsed", | |
| key="admin_view", | |
| ) | |
| if admin_view == "👥 Gestión de Personal": | |
| col1, col2 = st.columns([0.6, 0.4]) | |
| with col1: | |
| st.subheader("Personal Registrado") | |
| df_users = df_users_admin.copy() | |
| if df_users.empty: | |
| st.info("No hay usuarios registrados para mostrar.") | |
| else: | |
| st.dataframe(df_users.drop(columns=["username", "role"], errors="ignore"), use_container_width=True, hide_index=True) | |
| st.markdown("---") | |
| st.subheader("Acciones Rápidas") | |
| ac1, ac2 = st.columns(2) | |
| with ac1: | |
| del_user = st.text_input("Eliminar Usuario", placeholder="Nombre de usuario") | |
| if st.button("🗑️ Eliminar Acceso", type="secondary"): | |
| if del_user == "admin": st.error("No puedes eliminar al administrador maestro.") | |
| elif del_user: | |
| if db.delete_user(del_user): | |
| get_all_users_cached.clear() | |
| st.success(f"Usuario {del_user} eliminado.") | |
| st.rerun() | |
| else: st.warning("Usuario no encontrado.") | |
| with ac2: | |
| reset_user = st.text_input("Resetear Contraseña", placeholder="Nombre de usuario") | |
| new_pass = st.text_input("Nueva contraseña", type="password") | |
| if st.button("🔄 Cambiar Contraseña", type="primary"): | |
| if reset_user and new_pass: | |
| db.reset_user_password(reset_user, new_pass) | |
| get_all_users_cached.clear() | |
| st.success("Contraseña actualizada.") | |
| st.rerun() | |
| st.markdown("---") | |
| st.subheader("Cambiar Rol de Usuario") | |
| role_col1, role_col2, role_col3 = st.columns([0.45, 0.35, 0.2]) | |
| role_users = df_users["Usuario"].tolist() if "Usuario" in df_users.columns else [] | |
| with role_col1: | |
| role_user = st.selectbox("Usuario", role_users, key="admin_role_user") | |
| current_role = "" | |
| if role_user and not df_users.empty: | |
| role_match = df_users[df_users["Usuario"] == role_user] | |
| if not role_match.empty: | |
| current_role = str(role_match.iloc[0].get("Nivel", "Analista") or "Analista") | |
| role_options = ["Analista", "Supervisor", "Gerencia", "Logística"] | |
| with role_col2: | |
| role_index = role_options.index(current_role) if current_role in role_options else 0 | |
| new_role_admin = st.selectbox("Nuevo rol", role_options, index=role_index, key="admin_new_role") | |
| with role_col3: | |
| st.write("") | |
| if st.button("Guardar Rol", type="primary", use_container_width=True): | |
| if role_user == "admin": | |
| st.error("No puedes cambiar el rol del administrador maestro.") | |
| elif role_user: | |
| db.update_user_role(role_user, new_role_admin) | |
| get_all_users_cached.clear() | |
| st.success(f"Rol de {role_user} actualizado a {new_role_admin}.") | |
| st.rerun() | |
| with col2: | |
| st.markdown(""" | |
| <div style='background:#161B22; border:1px solid #30363D; padding:20px; border-radius:12px;'> | |
| <h3 style='margin-top:0; color:#58A6FF;'>➕ Nuevo Usuario</h3> | |
| """, unsafe_allow_html=True) | |
| new_u = st.text_input("Nombre de Usuario (Login)") | |
| new_p = st.text_input("Contraseña Temporal", type="password") | |
| new_r = st.selectbox("Nivel de Acceso", ["Analista", "Supervisor", "Gerencia", "Logística"]) | |
| if st.button("Crear Cuenta", use_container_width=True, type="primary"): | |
| if new_u and new_p: | |
| if db.create_user(new_u, new_p, new_r): | |
| get_all_users_cached.clear() | |
| st.success(f"✅ Cuenta de {new_r} creada para '{new_u}'") | |
| st.rerun() | |
| else: | |
| st.error("⚠️ El usuario ya existe.") | |
| else: | |
| st.warning("Completa los campos.") | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| with st.expander("🔑 Configurar API Keys por Usuario"): | |
| target_user_options = df_users["Usuario"].tolist() if "Usuario" in df_users.columns else df_users.get("username", pd.Series(dtype=str)).tolist() | |
| target_u = st.selectbox("Seleccionar Usuario", [""] + target_user_options) | |
| if target_u: | |
| conn = db.get_connection() | |
| c = conn.cursor() | |
| c.execute("SELECT gemini_key, tavily_key, email_user, email_pass_enc FROM users WHERE username=%s", (target_u,)) | |
| curr = c.fetchone() | |
| conn.close() | |
| if curr: | |
| new_g = st.text_input("Gemini API Key", value=crypto.decrypt_data(curr[0]), type="password", key="g_key") | |
| new_t = st.text_input("Tavily API Key", value=crypto.decrypt_data(curr[1]), type="password", key="t_key") | |
| new_e = st.text_input("Correo (IMAP/SMTP)", value=curr[2], key="e_key") | |
| new_p = st.text_input("Contraseña de Correo", type="password", key="p_key", help="Solo escribe aquí si deseas cambiarla o configurarla por primera vez.") | |
| if st.button("Guardar Llaves", type="primary", use_container_width=True): | |
| # email_pass: si se escribió algo lo ciframos, si no mantenemos el actual | |
| final_p_enc = crypto.encrypt_data(new_p) if new_p else curr[3] | |
| # gemini y tavily se cifran dentro de db.update_user_profile() | |
| db.update_user_profile(target_u, new_g, new_t, new_e, final_p_enc) | |
| get_all_users_cached.clear() | |
| st.success(f"Configuración guardada para {target_u}") | |
| st.rerun() | |
| if admin_view == "📊 Estadísticas del Sistema": | |
| st.subheader("Salud del Sistema") | |
| total_lic_monitor, total_historial, total_fichas = get_system_health_counts() | |
| s1, s2, s3 = st.columns(3) | |
| s1.metric("Licitaciones en Monitor ACP", total_lic_monitor) | |
| s2.metric("Pliegos Analizados (Histórico)", total_historial) | |
| s3.metric("Fichas Técnicas Generadas", total_fichas) | |
| st.divider() | |
| st.subheader("Analitica de Consumo y Costos") | |
| try: | |
| f1, f2, f3 = st.columns(3) | |
| filter_options = get_usage_filter_options_cached(days=180) | |
| with f1: | |
| dias_metricas = st.selectbox("Periodo", [7, 30, 60, 90, 180], index=1, format_func=lambda d: f"Últimos {d} días") | |
| with f2: | |
| usuario_metricas = st.selectbox("Usuario", filter_options["users"]) | |
| with f3: | |
| modulo_metricas = st.selectbox("Módulo", filter_options["modules"]) | |
| resumen = get_usage_summary_cached(days=dias_metricas, username=usuario_metricas, module=modulo_metricas) | |
| m1, m2, m3, m4, m5, m6 = st.columns(6) | |
| m1.metric("Eventos", f"{resumen['total_events']:,}") | |
| m2.metric("Usuarios", resumen["active_users"]) | |
| m3.metric("Licitaciones", resumen["active_licitaciones"]) | |
| m4.metric("Tokens IA", f"{resumen['tokens_total']:,}") | |
| m5.metric("Costo USD", f"${resumen['estimated_cost_usd']:.4f}") | |
| m6.metric("Errores", resumen["errors"]) | |
| if resumen.get("uncosted_events", 0): | |
| st.warning(f"{resumen['uncosted_events']} evento(s) IA antiguos no tienen input/output separados y no se incluyen en el costo.") | |
| df_day = resumen["by_day"] | |
| if not df_day.empty: | |
| c1, c2 = st.columns(2) | |
| with c1: | |
| st.caption("Eventos por dia") | |
| st.bar_chart(df_day.set_index("day")["eventos"]) | |
| with c2: | |
| st.caption("Tokens por dia") | |
| if df_day["tokens"].sum() > 0: | |
| token_cols = [c for c in ["tokens_entrada", "tokens_salida"] if c in df_day.columns] | |
| st.line_chart(df_day.set_index("day")[token_cols or ["tokens"]]) | |
| else: | |
| st.info("Aun no hay tokens IA costeables en el periodo.") | |
| with st.expander("Detalle y tarifas"): | |
| st.caption("Consumo por modulo y funcion") | |
| st.dataframe(resumen["by_module"], use_container_width=True, hide_index=True) | |
| st.caption("Consumo por usuario") | |
| st.dataframe(resumen["by_user"], use_container_width=True, hide_index=True) | |
| st.caption("Tarifas usadas") | |
| st.dataframe(get_api_pricing_cached(), use_container_width=True, hide_index=True) | |
| except Exception as e: | |
| st.error(f"Error cargando métricas: {e}") | |
| st.markdown("---") | |
| if st.button("Cerrar Sesión Segura", type="primary", use_container_width=True): | |
| logout_user() | |
| st.stop() | |
| # --- 5. SIDEBAR --- | |
| def get_navigation_items(): | |
| items = [ | |
| { | |
| "group": "Inicio", | |
| "view": "🚀 Tablero de Operaciones", | |
| "key": "inicio", | |
| "label": "Inicio / RFQ", | |
| "icon": "⌂", | |
| "hint": "Dashboard, carga de RFQ y resultado activo", | |
| "permission": "dashboard", | |
| }, | |
| { | |
| "group": "Sourcing", | |
| "view": "🌐 Proveedores", | |
| "key": "proveedores", | |
| "label": "Proveedores", | |
| "icon": "◎", | |
| "hint": "Busqueda global de proveedores, precios y evidencia", | |
| "permission": "providers", | |
| }, | |
| { | |
| "group": "Operaciones", | |
| "view": "🚚 Centro Logístico", | |
| "key": "logistica", | |
| "label": "Logística", | |
| "icon": "▣", | |
| "hint": "Tarifas, forwarders, incoterms y calculadora logística", | |
| "permission": "logistics", | |
| }, | |
| { | |
| "group": "Seguimiento", | |
| "view": "🏛️ Monitor ACP", | |
| "key": "licitaciones", | |
| "label": "Licitaciones", | |
| "icon": "□", | |
| "hint": "Seguimiento, comentarios y estados de licitaciones", | |
| "permission": "monitor", | |
| }, | |
| { | |
| "group": "Supervisión", | |
| "view": "📡 Radar Supervisor", | |
| "key": "radar", | |
| "label": "Radar SLI", | |
| "icon": "◇", | |
| "hint": "Licitaciones abiertas, oportunidades y priorización", | |
| "permission": "radar", | |
| }, | |
| { | |
| "group": "Datos", | |
| "view": "📊 Historial Global", | |
| "key": "historico", | |
| "label": "Histórico", | |
| "icon": "▦", | |
| "hint": "Precios y licitaciones previas", | |
| "permission": "historico", | |
| }, | |
| { | |
| "group": "Datos", | |
| "view": "📚 Base de Conocimiento", | |
| "key": "workspaces", | |
| "label": "Workspaces", | |
| "icon": "▤", | |
| "hint": "Historial, fichas y recursos", | |
| "permission": "workspaces", | |
| }, | |
| ] | |
| return [item for item in items if role_can(item["permission"])] | |
| navigation_items = get_navigation_items() | |
| valid_views = [item["view"] for item in navigation_items] | |
| nav_by_key = {item["key"]: item for item in navigation_items} | |
| requested_view = st.query_params.get("view", "") | |
| if isinstance(requested_view, list): | |
| requested_view = requested_view[0] if requested_view else "" | |
| if requested_view in nav_by_key: | |
| st.session_state.active_view = nav_by_key[requested_view]["view"] | |
| if st.session_state.get("active_view") not in valid_views: | |
| st.session_state.active_view = valid_views[0] | |
| current_nav_item = next((item for item in navigation_items if item["view"] == st.session_state.active_view), navigation_items[0]) | |
| with st.sidebar: | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| st.markdown(""" | |
| <div class="sidebar-brand"> | |
| <div class="brand-mark">P</div> | |
| <div> | |
| <div class="brand-title">PROCURA AI</div> | |
| <div class="brand-subtitle">Sourcing Intelligence</div> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| api_online_sidebar = get_api_health() | |
| st.markdown(f""" | |
| <div class="sidebar-user-card"> | |
| <div class="sidebar-user-top"> | |
| <div class="sidebar-avatar">{escape(st.session_state.username[:1].upper() if st.session_state.username else "U")}</div> | |
| <div> | |
| <div class="sidebar-user-name">{escape(st.session_state.username)}</div> | |
| <div class="sidebar-user-role">{escape(st.session_state.role)}</div> | |
| </div> | |
| </div> | |
| <div class="sidebar-system-row"> | |
| <span>Motor IA</span> | |
| <b class="system-pill {'online' if api_online_sidebar else 'offline'}">{'Activo' if api_online_sidebar else 'Apagado'}</b> | |
| </div> | |
| <div class="sidebar-system-row"> | |
| <span>Vista actual</span> | |
| <b>{escape(current_nav_item.get('label', 'Inicio'))}</b> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| role_profile = get_role_profile() | |
| st.markdown(f""" | |
| <div class="sidebar-role-card"> | |
| <div class="role-card-top"> | |
| <span>{escape(role_profile["badge"])}</span> | |
| <b>{escape(role_profile["title"])}</b> | |
| </div> | |
| <div class="role-card-copy">{escape(role_profile["scope"])}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown('<div class="sidebar-section-label">Módulos</div>', unsafe_allow_html=True) | |
| last_group = None | |
| for item in navigation_items: | |
| if item["group"] != last_group: | |
| st.markdown(f"<div class='nav-group-label'>{escape(item['group'])}</div>", unsafe_allow_html=True) | |
| last_group = item["group"] | |
| is_active = st.session_state.active_view == item["view"] | |
| if st.button( | |
| f"{item.get('icon', '•')} {item['label']}", | |
| key=f"nav_{item['view']}", | |
| use_container_width=True, | |
| type="primary" if is_active else "secondary", | |
| help=item["hint"], | |
| ): | |
| st.session_state.active_view = item["view"] | |
| st.query_params["view"] = item["key"] | |
| st.rerun() | |
| st.divider() | |
| if role_can("rfq_upload"): | |
| st.markdown('<div class="sidebar-section-label">Nuevo RFQ</div>', unsafe_allow_html=True) | |
| st.markdown(""" | |
| <div class="sidebar-analysis-card"> | |
| <div class="analysis-card-title">Analizar pliego</div> | |
| <div class="analysis-card-copy">PDF principal y anexos.</div> | |
| <div class="sidebar-flow"> | |
| <span>1 RFQ</span> | |
| <span>2 Renglones</span> | |
| <span>3 Proveedores</span> | |
| <span>4 Seguimiento</span> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| archivos_pdf = st.file_uploader("Archivos PDF", type=["pdf"], label_visibility="collapsed", accept_multiple_files=True) | |
| if archivos_pdf: | |
| st.caption(f"{len(archivos_pdf)} archivo(s) listo(s) para analizar") | |
| if st.button("Procesar RFQ", type="primary", use_container_width=True): | |
| if not st.session_state.gemini_key: st.error("⚠️ Verifica tus API Keys en la configuración.") | |
| elif not archivos_pdf: st.error("⚠️ Falta subir al menos un documento.") | |
| else: | |
| with st.status("Conectando con Motor IA...", expanded=True) as status: | |
| try: | |
| archivos = [("archivos_pdf", (f.name, f.getvalue(), "application/pdf")) for f in archivos_pdf] | |
| datos_formulario = { | |
| "gemini_key": st.session_state.gemini_key, | |
| "username": st.session_state.username, | |
| "role": st.session_state.role | |
| } | |
| respuesta_api = requests.post(f"{API_URL_BASE}/analizar-pliego", files=archivos, data=datos_formulario, headers=API_HEADERS) | |
| if respuesta_api.status_code == 200: | |
| datos_crudos = respuesta_api.json() | |
| cg = datos_crudos.get("condiciones_generales", {}) | |
| df_exportar = normalize_item_codes(normalize_technical_fields(pd.DataFrame(datos_crudos.get("items", [])))) | |
| try: | |
| df_historico_match = load_historical_prices() | |
| if df_historico_match is None: | |
| st.warning("⚠️ El histórico de Supabase aún no tiene datos. Procesando sin precios base.") | |
| else: | |
| # El código ACP válido usa formato AAA-AAA-00000; se normaliza antes del cruce histórico. | |
| df_exportar['codigo_match'] = df_exportar['codigo_articulo'].apply(acp_code_match) | |
| df_cruzado = pd.merge(df_exportar, df_historico_match, left_on='codigo_match', right_on='codigo_match', how='left') | |
| df_cruzado = df_cruzado.drop(columns=['codigo_match']).rename(columns={'PRECIO COMPETENCIA': "precio_comp_hist", 'PRECIO PROYELEC': "precio_proy_hist"}) | |
| df_cruzado['precio_comp_hist'] = pd.to_numeric(df_cruzado['precio_comp_hist'], errors='coerce') | |
| df_cruzado['precio_proy_hist'] = pd.to_numeric(df_cruzado['precio_proy_hist'], errors='coerce') | |
| df_cruzado['margen_$'] = df_cruzado['precio_proy_hist'] - df_cruzado['precio_comp_hist'] | |
| df_exportar = normalize_history_columns(df_cruzado) | |
| except Exception as hist_e: | |
| st.warning(f"⚠️ Error consultando histórico en Supabase: {hist_e}") | |
| save_history(st.session_state.username, str(cg.get('numero_licitacion', 'Desconocida')), len(df_exportar)) | |
| save_workspace_state(st.session_state.username, cg, df_exportar) | |
| st.session_state.df_exportar, st.session_state.cg, st.session_state.procesado = df_exportar, cg, True | |
| status.update(label="✅ Análisis Completado", state="complete", expanded=False) | |
| st.toast("Analisis completado. Resultados listos.") | |
| else: status.update(label=f"❌ Error en API: {respuesta_api.text}", state="error") | |
| except Exception as e: status.update(label=f"❌ Error crítico: {e}", state="error") | |
| else: | |
| st.markdown(""" | |
| <div class="sidebar-analysis-card"> | |
| <div class="analysis-card-title">Centro Logístico</div> | |
| <div class="analysis-card-copy">Tarifas, forwarders e histórico corporativo.</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.divider() | |
| st.markdown('<div class="sidebar-section-label">Sistema</div>', unsafe_allow_html=True) | |
| st.markdown(f""" | |
| <div class="sidebar-status-card"> | |
| <div><span>API</span><b>{'Conectada' if api_online_sidebar else 'Sin respuesta'}</b></div> | |
| <div><span>Fecha</span><b>{datetime.now().strftime("%d/%m/%Y")}</b></div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if st.session_state.role == "Gerencia": | |
| with st.expander("⚙️ Configuración y Llaves", expanded=False): | |
| nueva_gemini = st.text_input("Gemini API Key", value=st.session_state.gemini_key, type="password") | |
| nueva_tavily = st.text_input("Tavily API Key", value=st.session_state.tavily_key, type="password") | |
| nuevo_email = st.text_input("Correo Proyelec", value=st.session_state.email_user) | |
| nuevo_email_pass = st.text_input("Contraseña", value=st.session_state.email_pass, type="password") | |
| if st.button("Guardar Cambios", use_container_width=True): | |
| update_user_profile(st.session_state.username, nueva_gemini, nueva_tavily, nuevo_email, nuevo_email_pass) | |
| st.session_state.gemini_key, st.session_state.tavily_key = nueva_gemini, nueva_tavily | |
| st.session_state.email_user, st.session_state.email_pass = nuevo_email, nuevo_email_pass | |
| st.toast("✅ Configuración guardada.") | |
| else: | |
| st.markdown(""" | |
| <div style='background:#0D1117;border:1px solid #1E2A3A;border-radius:8px; | |
| padding:10px 14px;font-size:12px;color:#484f58;margin-bottom:8px;'> | |
| ⚙️ Configuración administrada por el equipo autorizado | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if st.button("🚪 Cerrar Sesión", use_container_width=True): | |
| logout_user() | |
| # --- 7. RENDERIZADO VISUAL PRINCIPAL --- | |
| # Colores y emojis por estado ACP | |
| ESTADO_CONFIG = { | |
| "En Preparacion": ("🔵", "#1E3A5F", "#58A6FF"), | |
| "Oferta Enviada al SLI": ("🟡", "#3D2E00", "#E3B341"), | |
| "Cumple Tecnicamente": ("🟢", "#1A3A1A", "#3FB950"), | |
| "No Cumple Tecnicamente": ("🔴", "#3A1A1A", "#F85149"), | |
| "En Evaluacion Economica":("🟠", "#3A2A00", "#F0883E"), | |
| "Adjudicada": ("🏆", "#1A3A2A", "#10B981"), | |
| "No Adjudicada": ("❌", "#2A1A1A", "#6E7681"), | |
| "Desierta": ("🚫", "#2A2A2A", "#484F58"), | |
| } | |
| # Redefinicion visual: badges sobrios para el tablero operativo. | |
| ESTADO_CONFIG.update({ | |
| "En Preparacion": ("", "#101A2A", "#4F9CF9"), | |
| "Oferta Enviada al SLI": ("", "#261F0B", "#F6B44B"), | |
| "Cumple Tecnicamente": ("", "#0E241A", "#31C48D"), | |
| "No Cumple Tecnicamente": ("", "#2A1111", "#EF5B5B"), | |
| "En Evaluacion Economica":("", "#2A1D0F", "#F08A3C"), | |
| "Adjudicada": ("", "#0E241A", "#31C48D"), | |
| "No Adjudicada": ("", "#171C24", "#748091"), | |
| "Desierta": ("", "#171C24", "#748091"), | |
| }) | |
| active_view = st.session_state.active_view | |
| def render_page_header(eyebrow, title, subtitle, meta_items=None): | |
| meta_html = "" | |
| if meta_items: | |
| meta_html = "<div class='page-meta'>" + "".join( | |
| f"<span>{escape(str(item))}</span>" for item in meta_items if item is not None | |
| ) + "</div>" | |
| st.markdown(f""" | |
| <div class="module-header"> | |
| <div> | |
| <div class="page-eyebrow">{escape(str(eyebrow))}</div> | |
| <h1 class="page-title">{escape(str(title))}</h1> | |
| <div class="page-subtitle">{escape(str(subtitle))}</div> | |
| </div> | |
| {meta_html} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def render_summary_strip(items): | |
| if not items: | |
| return | |
| cols = st.columns(len(items)) | |
| for col, item in zip(cols, items): | |
| label = escape(str(item.get("label", ""))) | |
| value = escape(str(item.get("value", ""))) | |
| tone = escape(str(item.get("tone", "blue"))) | |
| with col: | |
| st.markdown(f""" | |
| <div class="summary-card tone-{tone}"> | |
| <div class="summary-label">{label}</div> | |
| <div class="summary-value">{value}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def render_empty_state(title, body): | |
| st.markdown(f""" | |
| <div class="empty-state"> | |
| <div class="empty-title">{escape(str(title))}</div> | |
| <div class="empty-body">{escape(str(body))}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def render_notice_panel(title, body, tone="blue"): | |
| st.markdown(f""" | |
| <div class="notice-panel tone-{escape(str(tone))}"> | |
| <div class="notice-title">{escape(str(title))}</div> | |
| <div class="notice-body">{escape(str(body))}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def render_table_toolbar(title, subtitle="", meta_items=None): | |
| meta_html = "" | |
| if meta_items: | |
| meta_html = "<div class='table-toolbar-meta'>" + "".join( | |
| f"<span>{escape(str(item))}</span>" for item in meta_items if item is not None | |
| ) + "</div>" | |
| st.markdown(f""" | |
| <div class="table-toolbar"> | |
| <div> | |
| <div class="table-toolbar-title">{escape(str(title))}</div> | |
| <div class="table-toolbar-subtitle">{escape(str(subtitle))}</div> | |
| </div> | |
| {meta_html} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def render_access_snapshot(): | |
| items = [ | |
| ("RFQ", role_can("rfq_upload")), | |
| ("Proveedores", role_can("providers")), | |
| ("Histórico", role_can("historico")), | |
| ("Seguimiento", role_can("monitor")), | |
| ("Radar SLI", role_can("radar")), | |
| ("Admin", role_can("admin")), | |
| ] | |
| items_html = "".join( | |
| f"<div class='access-item {'is-on' if enabled else 'is-off'}'><span>{escape(label)}</span><b>{'Activo' if enabled else 'No asignado'}</b></div>" | |
| for label, enabled in items | |
| ) | |
| st.markdown(f""" | |
| <div class="access-grid"> | |
| {items_html} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def build_checklist_html(items): | |
| rows = [] | |
| for item in items: | |
| label = escape(str(item.get("label", ""))) | |
| value = escape(str(item.get("value", ""))) | |
| state = escape(str(item.get("state", "neutral"))) | |
| rows.append( | |
| f'<div class="checklist-row state-{state}"><span class="check-dot"></span>' | |
| f'<div><b>{label}</b><em>{value}</em></div></div>' | |
| ) | |
| return "<div class='checklist-block'>" + "".join(rows) + "</div>" | |
| def render_analysis_state(cg, df, missing_rows, role_mode): | |
| total_rows = len(df) | |
| valid_codes = int(df["codigo_articulo"].apply(lambda value: bool(ACP_CODE_RE.fullmatch(str(value or "")))).sum()) if "codigo_articulo" in df.columns and total_rows else 0 | |
| proposal_count = int(df["requiere_propuesta_tecnica"].fillna(False).sum()) if "requiere_propuesta_tecnica" in df.columns else 0 | |
| attachment_count = int(df["requiere_ficha_tecnica"].fillna(False).sum()) if "requiere_ficha_tecnica" in df.columns else 0 | |
| has_history = bool( | |
| ("precio_comp_hist" in df.columns and df["precio_comp_hist"].notna().any()) | |
| or ("precio_proy_hist" in df.columns and df["precio_proy_hist"].notna().any()) | |
| ) | |
| contact_parts = [ | |
| first_doc_value(cg, ["persona_encargada_licitacion", "persona_encargada", "agente_de_compras"], default=""), | |
| first_doc_value(cg, ["correo_encargado_licitacion", "correo_encargado", "correo_contacto"], default=""), | |
| first_doc_value(cg, ["telefono_encargado_licitacion", "telefono_encargado", "telefono_contacto"], default=""), | |
| ] | |
| contact_count = sum(1 for part in contact_parts if is_meaningful_text(part)) | |
| _, participation, _, _ = get_local_presence_decision(cg) | |
| role_summary = { | |
| "Analista": "RFQ, proveedores, histórico y seguimiento", | |
| "Supervisor": "Analista + Radar SLI y seguimiento operativo", | |
| "Gerencia": "Supervisor + vista gerencial e histórico corporativo", | |
| "Admin": "Usuarios, roles, métricas y salud del sistema", | |
| }.get(role_mode, "Flujo operativo") | |
| state_items = [ | |
| { | |
| "label": "Pliego leído", | |
| "value": first_doc_value(cg, ["numero_licitacion"], default="Número no especificado"), | |
| "state": "ok" if is_meaningful_text(first_doc_value(cg, ["numero_licitacion"], default="")) else "warn", | |
| }, | |
| { | |
| "label": "Códigos ACP", | |
| "value": f"{valid_codes}/{total_rows} validados" if total_rows else "Sin renglones", | |
| "state": "ok" if total_rows and valid_codes == total_rows else "warn", | |
| }, | |
| { | |
| "label": "Histórico consultado", | |
| "value": "Con referencia de costos" if has_history else "Sin match histórico", | |
| "state": "ok" if has_history else "neutral", | |
| }, | |
| { | |
| "label": "Propuesta técnica", | |
| "value": f"Requerida en {proposal_count} renglón(es)" if proposal_count else "No detectada por renglón", | |
| "state": "ok" if proposal_count else "neutral", | |
| }, | |
| { | |
| "label": "Ficha/catálogo adjunto", | |
| "value": f"Pedido en {attachment_count} renglón(es)" if attachment_count else "No pedido aparte", | |
| "state": "warn" if attachment_count else "neutral", | |
| }, | |
| { | |
| "label": "Contacto ACP", | |
| "value": f"{contact_count}/3 datos detectados", | |
| "state": "ok" if contact_count >= 2 else "warn", | |
| }, | |
| { | |
| "label": "Empresa sugerida", | |
| "value": participation, | |
| "state": "ok" if "Participar" in participation else "warn", | |
| }, | |
| { | |
| "label": f"Vista {role_mode}", | |
| "value": role_summary, | |
| "state": "ok", | |
| }, | |
| ] | |
| if missing_rows: | |
| state_items.append({ | |
| "label": "Renglón faltante", | |
| "value": f"Evidencia menciona línea(s) {', '.join(missing_rows)}", | |
| "state": "warn", | |
| }) | |
| render_table_toolbar( | |
| "Estado del análisis", | |
| "Checklist operativo para analista, supervisor y gerencia antes de cotizar o dar seguimiento.", | |
| [role_mode, "Beta Etapa 1"], | |
| ) | |
| st.markdown(f"<div class='analysis-state-panel'>{build_checklist_html(state_items)}</div>", unsafe_allow_html=True) | |
| def spec_text_to_checklist_html(text, max_items=14): | |
| raw_text = strip_html_markup(text) | |
| if not raw_text: | |
| return build_checklist_html([{"label": "Especificación", "value": "Sin descripción técnica detectada.", "state": "neutral"}]) | |
| lines = [] | |
| for raw_line in raw_text.replace("\r", "\n").split("\n"): | |
| line = raw_line.strip() | |
| line = re.sub(r"^[-*•\s]+", "", line).strip() | |
| line = line.replace("[ ]", "").replace("[x]", "").replace("[X]", "").replace("☐", "").replace("☑", "").strip() | |
| if line and line.lower() not in ["sin descripcion", "sin descripción"]: | |
| lines.append(line) | |
| if not lines: | |
| lines = [raw_text] | |
| items = [] | |
| for line in lines[:max_items]: | |
| label, value = "Requisito", line | |
| if ":" in line and len(line.split(":", 1)[0]) <= 48: | |
| label, value = line.split(":", 1) | |
| label = label.strip() or "Requisito" | |
| value = value.strip() or "No especificado" | |
| items.append({"label": label, "value": value, "state": "neutral"}) | |
| if len(lines) > max_items: | |
| items.append({"label": "Detalle adicional", "value": f"{len(lines) - max_items} puntos mas en el texto original.", "state": "neutral"}) | |
| return build_checklist_html(items) | |
| def build_rfq_email_html(subject, body, meta): | |
| meta_rows = "".join( | |
| f""" | |
| <tr> | |
| <td style="padding:8px 10px;border-bottom:1px solid #D8DEE8;color:#64748B;font-size:12px;">{escape(str(label))}</td> | |
| <td style="padding:8px 10px;border-bottom:1px solid #D8DEE8;color:#0F172A;font-size:12px;font-weight:700;">{escape(str(value or 'N/A'))}</td> | |
| </tr> | |
| """ | |
| for label, value in meta | |
| ) | |
| body_safe = escape(str(body or "")) | |
| return f"""<!doctype html> | |
| <html> | |
| <body style="margin:0;padding:0;background:#F4F7FB;font-family:Arial,Helvetica,sans-serif;color:#0F172A;"> | |
| <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#F4F7FB;padding:24px 0;"> | |
| <tr> | |
| <td align="center"> | |
| <table role="presentation" width="760" cellspacing="0" cellpadding="0" style="width:760px;max-width:94%;background:#FFFFFF;border:1px solid #D8DEE8;border-radius:10px;overflow:hidden;"> | |
| <tr> | |
| <td style="background:#0B1320;padding:22px 26px;"> | |
| <div style="color:#7CE0B7;font-size:11px;font-weight:700;text-transform:uppercase;">Proyelec International</div> | |
| <div style="color:#FFFFFF;font-size:22px;font-weight:800;margin-top:6px;">Request for Quotation</div> | |
| <div style="color:#A8B3C5;font-size:13px;margin-top:6px;">{escape(str(subject or 'Request for Quotation'))}</div> | |
| </td> | |
| </tr> | |
| <tr> | |
| <td style="padding:18px 26px;"> | |
| <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border:1px solid #D8DEE8;border-radius:8px;border-collapse:separate;overflow:hidden;margin-bottom:18px;"> | |
| {meta_rows} | |
| </table> | |
| <pre style="white-space:pre-wrap;font-family:Arial,Helvetica,sans-serif;font-size:13.5px;line-height:1.58;color:#172033;margin:0;">{body_safe}</pre> | |
| </td> | |
| </tr> | |
| <tr> | |
| <td style="background:#F8FAFC;border-top:1px solid #D8DEE8;padding:14px 26px;color:#64748B;font-size:12px;"> | |
| Generated by Procura AI. Please validate technical compliance, lead time, payment terms and supplier reliability before issuing a purchase decision. | |
| </td> | |
| </tr> | |
| </table> | |
| </td> | |
| </tr> | |
| </table> | |
| </body> | |
| </html>""" | |
| def render_rfq_email_preview(subject, body, meta): | |
| meta_html = "".join( | |
| f"<div><span>{escape(str(label))}</span><b>{escape(str(value or 'N/A'))}</b></div>" | |
| for label, value in meta | |
| ) | |
| body_preview = escape(str(body or "RFQ pendiente")).replace("\n", "<br>") | |
| st.markdown(f""" | |
| <div class="rfq-email-preview"> | |
| <div class="rfq-email-header"> | |
| <div> | |
| <div class="rfq-email-kicker">Proyelec International</div> | |
| <div class="rfq-email-title">Request for Quotation</div> | |
| <div class="rfq-email-subject">{escape(str(subject or 'Request for Quotation'))}</div> | |
| </div> | |
| <div class="rfq-email-badge">Preview</div> | |
| </div> | |
| <div class="rfq-email-meta">{meta_html}</div> | |
| <div class="rfq-email-body">{body_preview}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def search_brave_providers(query, count=8): | |
| if not BRAVE_SEARCH_API_KEY: | |
| return [] | |
| try: | |
| response = requests.get( | |
| "https://api.search.brave.com/res/v1/web/search", | |
| headers={"X-Subscription-Token": BRAVE_SEARCH_API_KEY}, | |
| params={"q": query, "count": count, "search_lang": "en"}, | |
| timeout=15, | |
| ) | |
| response.raise_for_status() | |
| data = response.json() | |
| web_results = data.get("web", {}).get("results", []) | |
| return [ | |
| { | |
| "title": item.get("title", ""), | |
| "content": item.get("description", ""), | |
| "url": item.get("url", ""), | |
| } | |
| for item in web_results | |
| ] | |
| except Exception as exc: | |
| st.warning(f"Brave Search no respondio: {exc}") | |
| return [] | |
| def score_provider_result(result, query): | |
| title = str(result.get("title", "") or "") | |
| content = str(result.get("content", "") or "") | |
| url = str(result.get("url", "") or "") | |
| haystack = _radar_norm(f"{title} {content} {url}") | |
| query_tokens = _radar_object_tokens(query) | |
| score = 45 | |
| reasons = [] | |
| positive_terms = { | |
| "supplier": 8, | |
| "distributor": 8, | |
| "manufacturer": 7, | |
| "authorized": 8, | |
| "stock": 6, | |
| "price": 6, | |
| "pricing": 6, | |
| "datasheet": 7, | |
| "catalog": 5, | |
| "industrial": 4, | |
| "quote": 4, | |
| } | |
| risk_terms = { | |
| "used": 8, | |
| "refurbished": 8, | |
| "surplus": 6, | |
| "ebay": 6, | |
| "blog": 5, | |
| "forum": 5, | |
| "pdf": 2, | |
| } | |
| for term, weight in positive_terms.items(): | |
| if _radar_has_norm_term(haystack, term): | |
| score += weight | |
| reasons.append(term) | |
| matched_tokens = [token for token in query_tokens if _radar_has_norm_term(haystack, token)] | |
| if matched_tokens: | |
| score += min(18, len(set(matched_tokens)) * 4) | |
| reasons.append("match tecnico") | |
| for term, weight in risk_terms.items(): | |
| if _radar_has_norm_term(haystack, term): | |
| score -= weight | |
| score = max(0, min(100, score)) | |
| if score >= 78: | |
| risk = "Bajo" | |
| elif score >= 58: | |
| risk = "Medio" | |
| else: | |
| risk = "Alto" | |
| return score, risk, ", ".join(dict.fromkeys(reasons)) or "Evidencia limitada" | |
| def sourcing_row_context(row): | |
| return { | |
| "renglon": clean_doc_value(row.get("renglon", ""), default=""), | |
| "codigo_acp": clean_doc_value(row.get("codigo_articulo", ""), default=""), | |
| "descripcion": clean_doc_value(row.get("ficha_tecnica_completa", ""), default=""), | |
| "busqueda_sugerida": clean_doc_value(row.get("termino_de_busqueda_corto", ""), default=""), | |
| "cantidad": clean_doc_value(row.get("cantidad", ""), default=""), | |
| "unidad": clean_doc_value(row.get("unidad_de_medida", ""), default=""), | |
| "marca_modelo": clean_doc_value(row.get("marca_modelo_requerido", ""), default=""), | |
| "acepta_equivalente": bool(row.get("acepta_equivalente")) if row.get("acepta_equivalente") is not None else None, | |
| "requiere_propuesta_tecnica": bool(row.get("requiere_propuesta_tecnica", False)), | |
| "requiere_ficha_tecnica": bool(row.get("requiere_ficha_tecnica", False)), | |
| "evidencia_tecnica": clean_doc_value(row.get("evidencia_tecnica", ""), default=""), | |
| } | |
| def sourcing_base_query_from_context(ctx): | |
| parts = [ | |
| ctx.get("busqueda_sugerida"), | |
| ctx.get("marca_modelo"), | |
| ctx.get("codigo_acp"), | |
| ] | |
| if not any(is_meaningful_text(p) for p in parts): | |
| parts.append(ctx.get("descripcion", "")[:180]) | |
| return " ".join(dict.fromkeys([str(p).strip() for p in parts if is_meaningful_text(p)])) | |
| def sourcing_query_variants(ctx, custom_prompt="", depth="Profunda"): | |
| base = sourcing_base_query_from_context(ctx) | |
| brand = ctx.get("marca_modelo", "") | |
| equiv = "equivalent replacement compatible" if ctx.get("acepta_equivalente") is not False else "exact brand model authorized distributor" | |
| hidden = "regional distributor stockist surplus new old stock industrial supplier low price" | |
| prompt_terms = str(custom_prompt or "")[:220] | |
| raw = [ | |
| f"{base} manufacturer distributor stock price datasheet", | |
| f"{base} {brand} authorized distributor quote stock", | |
| f"{base} {equiv} global supplier industrial", | |
| f"{base} {hidden}", | |
| f"{base} OEM aftermarket exporter wholesale {prompt_terms}", | |
| ] | |
| if depth == "Profunda": | |
| raw.extend([ | |
| f"{base} site:.com contact us industrial supply", | |
| f"{base} \"request quote\" \"in stock\" distributor", | |
| ]) | |
| variants = [] | |
| seen = set() | |
| for query in raw: | |
| clean = re.sub(r"\s+", " ", query).strip() | |
| if clean and clean.lower() not in seen: | |
| seen.add(clean.lower()) | |
| variants.append(clean) | |
| return variants[:7] | |
| def collect_sourcing_evidence(ctx, custom_prompt="", target_results=30, depth="Profunda"): | |
| queries = sourcing_query_variants(ctx, custom_prompt=custom_prompt, depth=depth) | |
| evidence = [] | |
| per_query = 6 if depth == "Profunda" else 4 | |
| for query in queries: | |
| if len(evidence) >= target_results: | |
| break | |
| if st.session_state.get("tavily_key"): | |
| try: | |
| client = TavilyClient(api_key=st.session_state.tavily_key) | |
| res = client.search( | |
| query=query, | |
| search_depth="advanced" if depth == "Profunda" else "basic", | |
| max_results=per_query, | |
| ) | |
| for item in res.get("results", []) or []: | |
| evidence.append({ | |
| "source": "Tavily", | |
| "query": query, | |
| "title": item.get("title", ""), | |
| "content": item.get("content", ""), | |
| "url": item.get("url", ""), | |
| }) | |
| except Exception as exc: | |
| st.warning(f"Tavily no respondió para una ruta de sourcing: {exc}") | |
| if BRAVE_SEARCH_API_KEY and len(evidence) < target_results: | |
| for item in search_brave_providers(query, count=per_query): | |
| evidence.append({ | |
| "source": "Brave", | |
| "query": query, | |
| "title": item.get("title", ""), | |
| "content": item.get("content", ""), | |
| "url": item.get("url", ""), | |
| }) | |
| unique = [] | |
| seen_urls = set() | |
| for item in evidence: | |
| url = str(item.get("url", "") or "").strip() | |
| if not url or url in seen_urls: | |
| continue | |
| seen_urls.add(url) | |
| unique.append(item) | |
| if len(unique) >= target_results: | |
| break | |
| return unique | |
| def build_sourcing_ai_prompt(ctx, evidence, custom_prompt="", target_count=10): | |
| evidence_payload = [ | |
| { | |
| "idx": idx + 1, | |
| "source": item.get("source", ""), | |
| "title": item.get("title", ""), | |
| "snippet": item.get("content", "")[:700], | |
| "url": item.get("url", ""), | |
| } | |
| for idx, item in enumerate(evidence[:40]) | |
| ] | |
| return f""" | |
| Actúa como Especialista Senior de Sourcing Global Industrial. | |
| Objetivo fijo: | |
| - Encontrar {target_count} proveedores reales para el renglón. | |
| - Prioridad 1: cumplimiento técnico. | |
| - Prioridad 2: probabilidad de buen precio. | |
| - Alcance global sin restricción geográfica. | |
| - Favorece proveedores long-tail: distribuidores regionales, stockistas industriales, fabricantes pequeños, exportadores técnicos y proveedores menos obvios, siempre que tengan evidencia real. | |
| - Evita empresas fantasma, intermediarios dudosos, blogs, marketplaces sin evidencia técnica, páginas sin contacto claro y resultados que no vendan el producto. | |
| - No inventes precios, stock, certificaciones ni datos de contacto. Usa solo la evidencia entregada. | |
| Renglón: | |
| {json.dumps(ctx, ensure_ascii=False, indent=2)} | |
| Instrucción personalizada del usuario: | |
| {custom_prompt or "Priorizar precio bajo, cumplimiento técnico y proveedores reales poco saturados."} | |
| Evidencia web disponible: | |
| {json.dumps(evidence_payload, ensure_ascii=False, indent=2)} | |
| Devuelve SOLO JSON válido con esta estructura: | |
| {{ | |
| "resumen_busqueda": "1 frase breve", | |
| "proveedores": [ | |
| {{ | |
| "renglon": "{ctx.get('renglon', '')}", | |
| "proveedor": "Nombre de empresa o resultado", | |
| "pais_region": "País/región si se evidencia, si no: No confirmado", | |
| "tipo": "Fabricante/Distribuidor/Stockista/Marketplace/No confirmado", | |
| "match_tecnico": 85, | |
| "probabilidad_buen_precio": "Alta/Media/Baja", | |
| "riesgo": "Bajo/Medio/Alto", | |
| "decision": "Recomendado/Validar antes de cotizar/Descartar", | |
| "evidencia": "Por qué puede servir según la evidencia", | |
| "que_validar": "Qué pedir antes de comprar o cotizar", | |
| "url": "URL exacta de evidencia" | |
| }} | |
| ] | |
| }} | |
| Reglas para match_tecnico: | |
| - Debe ser un número entero de 0 a 100. | |
| - 90-100: evidencia directa del producto exacto o equivalente técnico claro. | |
| - 70-89: proveedor industrial compatible, pero requiere validar datos. | |
| - 40-69: posible proveedor con evidencia parcial. | |
| - 0-39: evidencia débil o resultado poco útil. | |
| """ | |
| def normalize_match_score(value): | |
| try: | |
| if isinstance(value, str): | |
| match = re.search(r"\d+(?:\.\d+)?", value) | |
| score = float(match.group(0)) if match else 0 | |
| else: | |
| score = float(value or 0) | |
| except Exception: | |
| score = 0 | |
| if 0 < score <= 1: | |
| score *= 100 | |
| elif 1 < score <= 5: | |
| score *= 20 | |
| elif 5 < score <= 10: | |
| score *= 10 | |
| return int(max(0, min(100, round(score)))) | |
| def parse_sourcing_ai_response(text): | |
| raw = str(text or "").strip().replace("```json", "").replace("```", "").strip() | |
| try: | |
| data = json.loads(raw) | |
| except Exception: | |
| start = raw.find("{") | |
| end = raw.rfind("}") | |
| if start >= 0 and end > start: | |
| data = json.loads(raw[start:end + 1]) | |
| else: | |
| raise | |
| providers = data.get("proveedores", []) if isinstance(data, dict) else [] | |
| rows = [] | |
| for item in providers[:10]: | |
| rows.append({ | |
| "Renglón": item.get("renglon", ""), | |
| "Proveedor": item.get("proveedor", ""), | |
| "País / Región": item.get("pais_region", "No confirmado"), | |
| "Tipo": item.get("tipo", "No confirmado"), | |
| "Match técnico": normalize_match_score(item.get("match_tecnico", 0)), | |
| "Probabilidad de buen precio": item.get("probabilidad_buen_precio", "Media"), | |
| "Riesgo": item.get("riesgo", "Medio"), | |
| "Decisión": item.get("decision", "Validar antes de cotizar"), | |
| "Evidencia": item.get("evidencia", ""), | |
| "Qué validar": item.get("que_validar", ""), | |
| "URL": item.get("url", ""), | |
| }) | |
| return data.get("resumen_busqueda", "") if isinstance(data, dict) else "", pd.DataFrame(rows) | |
| def build_sourcing_fallback_df(ctx, evidence, target_count=10): | |
| fallback_rows = [] | |
| for item in evidence[:target_count]: | |
| score, risk, ev = score_provider_result(item, sourcing_base_query_from_context(ctx)) | |
| fallback_rows.append({ | |
| "Renglón": ctx.get("renglon", ""), | |
| "Proveedor": item.get("title", ""), | |
| "País / Región": "No confirmado", | |
| "Tipo": "No confirmado", | |
| "Match técnico": score, | |
| "Probabilidad de buen precio": "Media", | |
| "Riesgo": risk, | |
| "Decisión": "Validar antes de cotizar", | |
| "Evidencia": ev, | |
| "Qué validar": "Confirmar ficha técnica, stock, precio, dirección, referencia comercial y forma de pago.", | |
| "URL": item.get("url", ""), | |
| }) | |
| return pd.DataFrame(fallback_rows) | |
| def run_global_sourcing_for_context(ctx, custom_prompt="", depth="Profunda", target_count=10): | |
| evidence = collect_sourcing_evidence(ctx, custom_prompt=custom_prompt, target_results=36, depth=depth) | |
| if not evidence: | |
| return "Sin evidencia web automática. Configura Tavily o Brave para búsqueda real.", pd.DataFrame(), [] | |
| if not st.session_state.get("gemini_key"): | |
| return "Ranking preliminar sin Gemini; configurar Gemini mejora validación de riesgo.", build_sourcing_fallback_df(ctx, evidence, target_count), evidence | |
| prompt = build_sourcing_ai_prompt(ctx, evidence, custom_prompt=custom_prompt, target_count=target_count) | |
| try: | |
| ai_text = gemini_generate_text(st.session_state.gemini_key, prompt) | |
| summary, df = parse_sourcing_ai_response(ai_text) | |
| if df.empty: | |
| raise ValueError("La IA no devolvió proveedores útiles.") | |
| return summary, df.head(target_count), evidence | |
| except Exception as exc: | |
| fallback = build_sourcing_fallback_df(ctx, evidence, target_count) | |
| return f"Ranking preliminar por evidencia; la validación IA no pudo completarse: {exc}", fallback, evidence | |
| def radar_urgency_label(value): | |
| if pd.isna(value): | |
| return "Sin fecha" | |
| try: | |
| delta_days = int((pd.Timestamp(value).normalize() - pd.Timestamp(datetime.now()).normalize()).days) | |
| except Exception: | |
| return "Sin fecha" | |
| if delta_days < 0: | |
| return "Vencida" | |
| if delta_days == 0: | |
| return "Hoy" | |
| if delta_days == 1: | |
| return "Mañana" | |
| if delta_days <= 7: | |
| return f"{delta_days} días" | |
| return f"{delta_days} días" | |
| def build_radar_table_view(radar_df): | |
| view = pd.DataFrame(index=radar_df.index) | |
| if "enmienda_alerta" in radar_df.columns: | |
| view["Alerta"] = radar_df["enmienda_alerta"].apply(lambda v: "Enmienda nueva" if coerce_bool(v, default=False) else "") | |
| else: | |
| view["Alerta"] = "" | |
| view["RFQ"] = radar_df["numero_licitacion"].astype(str) if "numero_licitacion" in radar_df.columns else "" | |
| view["Objeto"] = radar_df["objeto"].astype(str) if "objeto" in radar_df.columns else "" | |
| view["Publicación"] = radar_df["_fecha_apertura_dt"] if "_fecha_apertura_dt" in radar_df.columns else pd.NaT | |
| view["Cierre"] = radar_df["_fecha_cierre_dt"] if "_fecha_cierre_dt" in radar_df.columns else pd.NaT | |
| if "numero_enmienda" in radar_df.columns: | |
| view["Enmienda"] = radar_df["numero_enmienda"].fillna("").astype(str).apply(lambda v: v if v.strip() else "No") | |
| else: | |
| view["Enmienda"] = "No" | |
| view["Urgencia"] = radar_df["_fecha_cierre_dt"].apply(radar_urgency_label) if "_fecha_cierre_dt" in radar_df.columns else "Sin fecha" | |
| view["Prioridad"] = radar_df["es_prioritaria"].fillna(False).apply(lambda v: "Alta" if bool(v) else "Normal") if "es_prioritaria" in radar_df.columns else "Normal" | |
| view["Historial"] = radar_df["hist_participaciones"].fillna(0).astype(int) if "hist_participaciones" in radar_df.columns else 0 | |
| view["Ganadas"] = radar_df["hist_ganadas"].fillna(0).astype(int) if "hist_ganadas" in radar_df.columns else 0 | |
| view["Score"] = radar_df["score_interes"].fillna(0).astype(int) if "score_interes" in radar_df.columns else 0 | |
| view["Estado"] = radar_df["estado_radar"].fillna("").astype(str) if "estado_radar" in radar_df.columns else "" | |
| return view | |
| def apply_radar_excel_filter(df, search_text, selected_labels=None): | |
| if df is None or df.empty or not str(search_text or "").strip(): | |
| return df | |
| column_map = { | |
| "RFQ": "numero_licitacion", | |
| "Objeto": "objeto", | |
| "Publicación": "fecha_apertura", | |
| "Cierre": "fecha_cierre", | |
| "Enmienda": "numero_enmienda", | |
| "Estado": "estado_radar", | |
| "Categoría": "categoria", | |
| "Notas": "notas", | |
| "Revisada por": "revisada_por", | |
| } | |
| selected_labels = selected_labels or list(column_map.keys()) | |
| selected_cols = [column_map[label] for label in selected_labels if column_map.get(label) in df.columns] | |
| if not selected_cols: | |
| selected_cols = [col for col in column_map.values() if col in df.columns] | |
| terms = [_radar_norm(term) for term in str(search_text).split() if _radar_norm(term)] | |
| if not terms: | |
| return df | |
| haystack = df[selected_cols].fillna("").astype(str).agg(" ".join, axis=1).map(_radar_norm) | |
| mask = pd.Series(True, index=df.index) | |
| for term in terms: | |
| mask &= haystack.str.contains(re.escape(term), na=False) | |
| return df[mask].copy() | |
| def logistics_local_cost(local_row, peso_kg): | |
| if local_row is None or local_row.empty: | |
| return 0.0 | |
| if peso_kg <= 400: | |
| return float(local_row.get("hasta_400kg", 0) or 0) | |
| if peso_kg <= 1000: | |
| return float(local_row.get("kg_500_1000", 0) or 0) | |
| return float(local_row.get("mayor_1000kg", 0) or 0) | |
| LOGISTICS_ROUTE_ORIGIN = "USA / Miami" | |
| LOGISTICS_ROUTE_DESTINATION = "Panamá / ACP" | |
| LOGISTICS_ROUTE_LABEL = f"{LOGISTICS_ROUTE_ORIGIN} → {LOGISTICS_ROUTE_DESTINATION}" | |
| LOGISTICS_ROUTE_SCOPE = ( | |
| "Las tarifas actuales están calibradas para envíos desde USA/Miami hacia Panamá/ACP. " | |
| "Para otros orígenes como China, Europa o México se debe cargar una ruta logística distinta." | |
| ) | |
| LOGISTICS_STEP_LABELS = { | |
| "embalaje_verificacion": "Embalaje y verificación", | |
| "carga_almacen": "Carga en almacén", | |
| "transporte_interno_origen": "Transporte interno origen", | |
| "tramites_aduaneros_exportacion": "Aduana exportación", | |
| "costo_terminal_origen": "Terminal origen", | |
| "transporte_principal": "Transporte principal", | |
| "seguro_transporte": "Seguro transporte", | |
| "costo_terminal_destino": "Terminal destino", | |
| "tramites_aduaneros_importacion": "Aduana importación", | |
| "transporte_interior_destino": "Transporte interior destino", | |
| "descarga_almacen_comprador": "Descarga almacén comprador", | |
| } | |
| LOGISTICS_FORWARDER_ALIASES = { | |
| "ABMCARGO": ["ABM LOGISTICS", "ABMCARGO"], | |
| "SOUTHCARGO": ["SOUTH CARGO", "SOUTHCARGO", "SOUTH CARGO"], | |
| "SOUTH CARGO": ["SOUTH CARGO", "SOUTHCARGO"], | |
| } | |
| def normalize_logistics_name(value): | |
| text = str(value or "").upper() | |
| text = re.sub(r"[^A-Z0-9]", "", text) | |
| return text | |
| def get_forwarder_for_agent(forwarders_df, agent_name): | |
| if forwarders_df is None or forwarders_df.empty: | |
| return pd.Series(dtype=object) | |
| normalized_agent = normalize_logistics_name(agent_name) | |
| candidates = LOGISTICS_FORWARDER_ALIASES.get(normalized_agent, [agent_name]) | |
| candidate_keys = {normalize_logistics_name(item) for item in candidates} | |
| for _, row in forwarders_df.iterrows(): | |
| row_key = normalize_logistics_name(row.get("nombre", "")) | |
| if row_key in candidate_keys or row_key == normalized_agent: | |
| return row | |
| for _, row in forwarders_df.iterrows(): | |
| row_key = normalize_logistics_name(row.get("nombre", "")) | |
| if normalized_agent and (normalized_agent in row_key or row_key in normalized_agent): | |
| return row | |
| return pd.Series(dtype=object) | |
| def build_incoterm_summary(incoterms_df, sigla): | |
| if incoterms_df is None or incoterms_df.empty: | |
| return "No hay matriz de Incoterms cargada." | |
| match = incoterms_df[incoterms_df["sigla"].astype(str).str.upper() == str(sigla or "").upper()] | |
| if match.empty: | |
| return f"No hay explicación cargada para {sigla}." | |
| row = match.iloc[0] | |
| responsabilidades = row.get("responsabilidades", {}) or {} | |
| if isinstance(responsabilidades, str): | |
| try: | |
| responsabilidades = json.loads(responsabilidades) | |
| except Exception: | |
| responsabilidades = {} | |
| comprador = [ | |
| LOGISTICS_STEP_LABELS.get(key, key.replace("_", " ").title()) | |
| for key, value in responsabilidades.items() | |
| if str(value).strip().lower().startswith("comprador") | |
| ] | |
| vendedor = [ | |
| LOGISTICS_STEP_LABELS.get(key, key.replace("_", " ").title()) | |
| for key, value in responsabilidades.items() | |
| if str(value).strip().lower().startswith("vendedor") | |
| ] | |
| incoterm_name = row.get("incoterm", sigla) | |
| comprador_text = ", ".join(comprador[:4]) + ("..." if len(comprador) > 4 else "") if comprador else "No especificado" | |
| vendedor_text = ", ".join(vendedor[:4]) + ("..." if len(vendedor) > 4 else "") if vendedor else "No especificado" | |
| return ( | |
| f"{sigla} - {incoterm_name}. " | |
| f"Proveedor cubre principalmente: {vendedor_text}. " | |
| f"Comprador/Proyelec debe estimar: {comprador_text}." | |
| ) | |
| def logistics_calc_summary(freight_row, local_row, peso_libras, incoterm="FOB", | |
| largo=0, ancho=0, alto=0, unidad_dimensional="in", bultos=1): | |
| peso_libras = float(peso_libras or 0) | |
| largo = float(largo or 0) | |
| ancho = float(ancho or 0) | |
| alto = float(alto or 0) | |
| bultos = max(float(bultos or 1), 1.0) | |
| unidad_dimensional = (unidad_dimensional or "in").lower() | |
| peso_volumetrico_libras = 0.0 | |
| volumen_pies_cubicos = 0.0 | |
| if largo > 0 and ancho > 0 and alto > 0: | |
| if unidad_dimensional == "cm": | |
| volumen_pies_cubicos = ((largo / 2.54) * (ancho / 2.54) * (alto / 2.54) * bultos) / 1728 | |
| peso_volumetrico_libras = ((largo * ancho * alto * bultos) / 6000) * 2.20462 | |
| else: | |
| volumen_pies_cubicos = (largo * ancho * alto * bultos) / 1728 | |
| peso_volumetrico_libras = (largo * ancho * alto * bultos) / 166 | |
| peso_facturable_libras = max(peso_libras, peso_volumetrico_libras) | |
| peso_kg = peso_libras * 0.453592 | |
| peso_facturable_kg = peso_facturable_libras * 0.453592 | |
| tarifa = float(freight_row.get("tarifa_por_libra", 0) or 0) | |
| minimo = float(freight_row.get("minimo_envio", 0) or 0) | |
| costo_internacional = max(peso_facturable_libras * tarifa, minimo) | |
| costo_local = logistics_local_cost(local_row, peso_facturable_kg) | |
| total = costo_internacional + costo_local | |
| dias = int(freight_row.get("tiempo_transito_dias", 0) or 0) | |
| return { | |
| "peso_libras": peso_libras, | |
| "peso_kg": peso_kg, | |
| "peso_facturable_libras": peso_facturable_libras, | |
| "peso_facturable_kg": peso_facturable_kg, | |
| "peso_volumetrico_libras": peso_volumetrico_libras, | |
| "largo": largo, | |
| "ancho": ancho, | |
| "alto": alto, | |
| "unidad_dimensional": unidad_dimensional, | |
| "bultos": bultos, | |
| "volumen_pies_cubicos": volumen_pies_cubicos, | |
| "costo_internacional": costo_internacional, | |
| "costo_local": costo_local, | |
| "costo_total": total, | |
| "tiempo_transito_dias": dias, | |
| "incoterm": incoterm, | |
| } | |
| if active_view == "🚚 Centro Logístico": | |
| render_page_header( | |
| "Operación logística", | |
| "Centro Logístico", | |
| "Tarifas, forwarders, incoterms y cálculos para estimar costos antes de cotizar.", | |
| ["Supabase", "Tarifas ACP", "Beta logística"] | |
| ) | |
| freight_df = get_logistics_freight_rates_cached() | |
| local_df = get_logistics_local_rates_cached() | |
| forwarders_df = get_logistics_forwarders_cached() | |
| incoterms_df = get_logistics_incoterms_cached() | |
| render_summary_strip([ | |
| {"label": "Tarifas flete", "value": len(freight_df), "tone": "green" if len(freight_df) else "orange"}, | |
| {"label": "Destinos locales", "value": len(local_df), "tone": "green" if len(local_df) else "orange"}, | |
| {"label": "Forwarders", "value": len(forwarders_df), "tone": "blue"}, | |
| {"label": "Incoterms", "value": len(incoterms_df), "tone": "amber"}, | |
| ]) | |
| st.markdown(""" | |
| <div class="work-panel"> | |
| <div class="panel-label">Uso operativo</div> | |
| <div class="panel-copy"> | |
| Logística mantiene las tarifas y rutas. Analistas y supervisores usarán estos datos para estimar costo logístico, | |
| lead time y margen real de participación. | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown(f""" | |
| <div class="work-panel"> | |
| <div class="panel-label">Ruta activa</div> | |
| <div class="panel-copy"> | |
| <strong>{escape(LOGISTICS_ROUTE_LABEL)}</strong><br> | |
| {escape(LOGISTICS_ROUTE_SCOPE)} | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| tab_calc, tab_rates, tab_local, tab_forwarders, tab_incoterms, tab_history = st.tabs([ | |
| "Calculadora", "Flete internacional", "Entrega local", "Forwarders", "Incoterms", "Cálculos guardados" | |
| ]) | |
| with tab_calc: | |
| if freight_df.empty: | |
| render_empty_state("Sin tarifas", "Aún no hay tarifas internacionales cargadas para calcular.") | |
| else: | |
| calc_left, calc_right = st.columns([0.58, 0.42]) | |
| with calc_left: | |
| st.markdown("#### Parámetros del cálculo") | |
| route_a, route_b = st.columns(2) | |
| with route_a: | |
| st.text_input("Origen tarifario", value=LOGISTICS_ROUTE_ORIGIN, disabled=True, key="log_calc_origin") | |
| with route_b: | |
| st.text_input("Destino tarifario", value=LOGISTICS_ROUTE_DESTINATION, disabled=True, key="log_calc_route_dest") | |
| modo_peso_log = st.radio( | |
| "Forma de cálculo", | |
| ["Envío completo", "Por paquete"], | |
| horizontal=True, | |
| key="log_calc_modo_peso", | |
| ) | |
| c1, c2 = st.columns(2) | |
| with c1: | |
| tipo_flete_calc = st.selectbox("Tipo de flete", sorted(freight_df["tipo_flete"].dropna().unique().tolist()), key="log_calc_tipo") | |
| filtered_freight = freight_df[freight_df["tipo_flete"] == tipo_flete_calc].copy() | |
| with c2: | |
| agente_calc = st.selectbox("Agente internacional", filtered_freight["agente"].dropna().unique().tolist(), key="log_calc_agente") | |
| freight_row = filtered_freight[filtered_freight["agente"] == agente_calc].iloc[0] | |
| if modo_peso_log == "Por paquete": | |
| c3, c4, c5, c6 = st.columns([0.24, 0.24, 0.24, 0.28]) | |
| with c3: | |
| peso_paquete_log = st.number_input("Peso por paquete (lb)", min_value=0.0, value=10.0, step=1.0, key="log_calc_pkg_lb") | |
| with c4: | |
| bultos_log = st.number_input("Paquetes", min_value=1, value=1, step=1, key="log_calc_pkg_count") | |
| with c5: | |
| incoterm_calc = st.selectbox("Incoterm base", incoterms_df["sigla"].tolist() if not incoterms_df.empty else ["FOB"], key="log_calc_incoterm") | |
| with c6: | |
| lic_log = st.text_input("Licitación", value=str(st.session_state.get("cg", {}).get("numero_licitacion", "")), key="log_calc_lic") | |
| peso_libras = peso_paquete_log * bultos_log | |
| else: | |
| c3, c4, c5, c6 = st.columns([0.24, 0.24, 0.24, 0.28]) | |
| with c3: | |
| peso_libras = st.number_input("Peso total (lb)", min_value=0.0, value=10.0, step=1.0, key="log_calc_lb") | |
| with c4: | |
| bultos_log = st.number_input("Paquetes / bultos", min_value=1, value=1, step=1, key="log_calc_bultos") | |
| with c5: | |
| incoterm_calc = st.selectbox("Incoterm base", incoterms_df["sigla"].tolist() if not incoterms_df.empty else ["FOB"], key="log_calc_incoterm") | |
| with c6: | |
| lic_log = st.text_input("Licitación", value=str(st.session_state.get("cg", {}).get("numero_licitacion", "")), key="log_calc_lic") | |
| peso_paquete_log = peso_libras / bultos_log if bultos_log else peso_libras | |
| st.caption("Dimensiones por paquete/bulto para calcular peso volumétrico. Si no aplican, déjalas en cero.") | |
| d1, d2, d3, d4, d5 = st.columns([0.2, 0.2, 0.2, 0.18, 0.22]) | |
| with d1: | |
| largo_log = st.number_input("Largo", min_value=0.0, value=0.0, step=1.0, key="log_calc_largo") | |
| with d2: | |
| ancho_log = st.number_input("Ancho", min_value=0.0, value=0.0, step=1.0, key="log_calc_ancho") | |
| with d3: | |
| alto_log = st.number_input("Alto", min_value=0.0, value=0.0, step=1.0, key="log_calc_alto") | |
| with d4: | |
| unidad_log = st.selectbox("Unidad", ["in", "cm"], key="log_calc_unidad_dim") | |
| with d5: | |
| st.metric("Peso total", f"{peso_libras:,.2f} lb") | |
| local_row = pd.Series(dtype=object) | |
| destino_calc = "Sin entrega local" | |
| if not local_df.empty: | |
| destino_calc = st.selectbox("Destino ACP / entrega local", ["Sin entrega local"] + local_df["destino"].dropna().unique().tolist(), key="log_calc_destino") | |
| if destino_calc != "Sin entrega local": | |
| local_candidates = local_df[local_df["destino"] == destino_calc] | |
| if not local_candidates.empty: | |
| local_row = local_candidates.iloc[0] | |
| renglon_log = st.text_input("Renglón / referencia", placeholder="Opcional", key="log_calc_renglon") | |
| calc = logistics_calc_summary( | |
| freight_row, | |
| local_row, | |
| peso_libras, | |
| incoterm=incoterm_calc, | |
| largo=largo_log, | |
| ancho=ancho_log, | |
| alto=alto_log, | |
| unidad_dimensional=unidad_log, | |
| bultos=bultos_log, | |
| ) | |
| if st.button("Guardar cálculo logístico", type="primary", use_container_width=True): | |
| db.save_logistics_calculation( | |
| username=st.session_state.username, | |
| licitacion=lic_log, | |
| renglon=renglon_log, | |
| agente=agente_calc, | |
| tipo_flete=tipo_flete_calc, | |
| incoterm=incoterm_calc, | |
| peso_libras=calc["peso_libras"], | |
| peso_kg=calc["peso_kg"], | |
| costo_internacional=calc["costo_internacional"], | |
| costo_local=calc["costo_local"], | |
| costo_total=calc["costo_total"], | |
| tiempo_transito_dias=calc["tiempo_transito_dias"], | |
| peso_facturable_libras=calc["peso_facturable_libras"], | |
| peso_volumetrico_libras=calc["peso_volumetrico_libras"], | |
| largo=calc["largo"], | |
| ancho=calc["ancho"], | |
| alto=calc["alto"], | |
| unidad_dimensional=calc["unidad_dimensional"], | |
| metadata={ | |
| "destino": destino_calc, | |
| "ruta": LOGISTICS_ROUTE_LABEL, | |
| "origen_tarifario": LOGISTICS_ROUTE_ORIGIN, | |
| "destino_tarifario": LOGISTICS_ROUTE_DESTINATION, | |
| "modo_calculo": modo_peso_log, | |
| "peso_por_paquete_lb": peso_paquete_log, | |
| "tarifa_por_libra": float(freight_row.get("tarifa_por_libra", 0) or 0), | |
| "bultos": calc["bultos"], | |
| "volumen_pies_cubicos": calc["volumen_pies_cubicos"], | |
| } | |
| ) | |
| clear_logistics_cache() | |
| st.success("Cálculo logístico guardado.") | |
| with calc_right: | |
| st.markdown("#### Resultado estimado") | |
| st.metric("Costo total", f"$ {calc['costo_total']:,.2f}") | |
| st.metric("Flete internacional", f"$ {calc['costo_internacional']:,.2f}") | |
| st.metric("Entrega local", f"$ {calc['costo_local']:,.2f}") | |
| st.metric("Peso convertido", f"{calc['peso_kg']:,.2f} kg") | |
| st.metric("Peso volumétrico", f"{calc['peso_volumetrico_libras']:,.2f} lb") | |
| st.metric("Peso facturable", f"{calc['peso_facturable_libras']:,.2f} lb") | |
| st.metric("Volumen", f"{calc['volumen_pies_cubicos']:,.2f} ft³") | |
| render_notice_panel( | |
| "Ruta y lead time", | |
| f"{LOGISTICS_ROUTE_LABEL}. {agente_calc} / {tipo_flete_calc}: {calc['tiempo_transito_dias']} día(s) estimados. Día de corte: {freight_row.get('dia_corte', 'N/A') or 'N/A'}.", | |
| "blue", | |
| ) | |
| with tab_rates: | |
| st.dataframe( | |
| freight_df, | |
| use_container_width=True, | |
| hide_index=True, | |
| column_config={ | |
| "tarifa_por_libra": st.column_config.NumberColumn("Tarifa/lb", format="$ %.2f"), | |
| "minimo_envio": st.column_config.NumberColumn("Mínimo", format="$ %.2f"), | |
| "tiempo_transito_dias": st.column_config.NumberColumn("Días", format="%d"), | |
| }, | |
| ) | |
| with st.expander("Agregar o actualizar tarifa internacional"): | |
| f1, f2, f3 = st.columns(3) | |
| with f1: | |
| agente = st.text_input("Agente", key="log_rate_agente") | |
| tipo_servicio = st.text_input("Tipo de servicio", value="Door-To-Door", key="log_rate_servicio") | |
| with f2: | |
| tipo_flete = st.selectbox("Tipo flete", ["Aereo", "Maritimo"], key="log_rate_tipo") | |
| tarifa = st.number_input("Tarifa por libra", min_value=0.0, step=0.5, key="log_rate_tarifa") | |
| with f3: | |
| dias = st.number_input("Tiempo tránsito días", min_value=0, step=1, key="log_rate_dias") | |
| minimo = st.number_input("Mínimo por envío", min_value=0.0, step=5.0, key="log_rate_minimo") | |
| dia_corte = st.text_input("Día de corte", value="-", key="log_rate_corte") | |
| salidas = st.text_input("Salidas", value="Según disponibilidad", key="log_rate_salidas") | |
| if st.button("Guardar tarifa internacional", type="primary"): | |
| if agente.strip(): | |
| db.upsert_logistics_freight_rate(agente, tipo_servicio, tipo_flete, tarifa, dias, minimo, dia_corte, salidas) | |
| clear_logistics_cache() | |
| st.success("Tarifa internacional guardada.") | |
| st.rerun() | |
| else: | |
| st.warning("Indica el agente.") | |
| with tab_local: | |
| st.dataframe( | |
| local_df, | |
| use_container_width=True, | |
| hide_index=True, | |
| column_config={ | |
| "hasta_400kg": st.column_config.NumberColumn("Hasta 400 kg", format="$ %.2f"), | |
| "kg_500_1000": st.column_config.NumberColumn("500 a 1000 kg", format="$ %.2f"), | |
| "mayor_1000kg": st.column_config.NumberColumn("> 1000 kg", format="$ %.2f"), | |
| }, | |
| ) | |
| with st.expander("Agregar o actualizar entrega local"): | |
| l1, l2, l3 = st.columns(3) | |
| with l1: | |
| agente_local = st.text_input("Agente local", value="Ariel Nunez", key="log_local_agente") | |
| destino_local = st.text_input("Destino", key="log_local_destino") | |
| with l2: | |
| hasta_400 = st.number_input("Hasta 400 kg", min_value=0.0, step=10.0, key="log_local_400") | |
| de_500_1000 = st.number_input("500 a 1000 kg", min_value=0.0, step=10.0, key="log_local_1000") | |
| with l3: | |
| mayor_1000 = st.number_input("Mayor a 1000 kg", min_value=0.0, step=10.0, key="log_local_mayor") | |
| tipo_local = st.text_input("Tipo", value="Terrestre", key="log_local_tipo") | |
| if st.button("Guardar entrega local", type="primary"): | |
| if destino_local.strip(): | |
| db.upsert_logistics_local_rate(agente_local, destino_local, tipo_local, hasta_400, de_500_1000, mayor_1000) | |
| clear_logistics_cache() | |
| st.success("Tarifa local guardada.") | |
| st.rerun() | |
| else: | |
| st.warning("Indica el destino.") | |
| with tab_forwarders: | |
| st.dataframe(forwarders_df, use_container_width=True, hide_index=True) | |
| with st.expander("Agregar o actualizar forwarder"): | |
| nombre_fwd = st.text_input("Nombre", key="log_fwd_nombre") | |
| direccion_fwd = st.text_area("Dirección", key="log_fwd_direccion") | |
| obs_fwd = st.text_area("Observación", key="log_fwd_obs") | |
| if st.button("Guardar forwarder", type="primary"): | |
| if nombre_fwd.strip(): | |
| db.upsert_logistics_forwarder(nombre_fwd, direccion_fwd, obs_fwd) | |
| clear_logistics_cache() | |
| st.success("Forwarder guardado.") | |
| st.rerun() | |
| else: | |
| st.warning("Indica el nombre.") | |
| with tab_incoterms: | |
| if incoterms_df.empty: | |
| render_empty_state("Sin incoterms", "No hay incoterms cargados.") | |
| else: | |
| incoterm_view = incoterms_df[["sigla", "incoterm"]].copy() | |
| st.dataframe(incoterm_view, use_container_width=True, hide_index=True) | |
| render_notice_panel( | |
| "Cómo usar esta matriz", | |
| "El incoterm define qué costos asume el proveedor y qué costos debe estimar Proyelec. La tabla inferior muestra el responsable por tramo.", | |
| "blue", | |
| ) | |
| selected_inc = st.selectbox("Ver responsabilidad por incoterm", incoterms_df["sigla"].tolist()) | |
| inc_row = incoterms_df[incoterms_df["sigla"] == selected_inc].iloc[0] | |
| responsabilidades = inc_row.get("responsabilidades", {}) or {} | |
| if isinstance(responsabilidades, str): | |
| try: | |
| responsabilidades = json.loads(responsabilidades) | |
| except Exception: | |
| responsabilidades = {} | |
| resp_df = pd.DataFrame([ | |
| {"Tramo": key.replace("_", " ").title(), "Responsable": value} | |
| for key, value in responsabilidades.items() | |
| ]) | |
| st.dataframe(resp_df, use_container_width=True, hide_index=True) | |
| with tab_history: | |
| calc_df = get_logistics_calculations_cached(100) | |
| if calc_df.empty: | |
| render_empty_state("Sin cálculos guardados", "Los cálculos logísticos guardados aparecerán aquí.") | |
| else: | |
| st.dataframe( | |
| calc_df, | |
| use_container_width=True, | |
| hide_index=True, | |
| column_config={ | |
| "costo_internacional": st.column_config.NumberColumn("Internacional", format="$ %.2f"), | |
| "costo_local": st.column_config.NumberColumn("Local", format="$ %.2f"), | |
| "costo_total": st.column_config.NumberColumn("Total", format="$ %.2f"), | |
| "peso_libras": st.column_config.NumberColumn("lb", format="%.2f"), | |
| "peso_kg": st.column_config.NumberColumn("kg", format="%.2f"), | |
| "peso_facturable_libras": st.column_config.NumberColumn("Peso facturable lb", format="%.2f"), | |
| "peso_volumetrico_libras": st.column_config.NumberColumn("Peso volumétrico lb", format="%.2f"), | |
| }, | |
| ) | |
| st.divider() | |
| delete_options = calc_df["id"].tolist() | |
| selected_delete = st.selectbox( | |
| "Cálculo guardado a borrar", | |
| delete_options, | |
| format_func=lambda calc_id: ( | |
| f"#{calc_id} | " | |
| f"{calc_df.loc[calc_df['id'] == calc_id, 'licitacion'].iloc[0] or 'Sin licitación'} | " | |
| f"$ {float(calc_df.loc[calc_df['id'] == calc_id, 'costo_total'].iloc[0] or 0):,.2f}" | |
| ), | |
| key="log_delete_calc_id", | |
| ) | |
| if st.button("Borrar cálculo seleccionado", type="secondary", use_container_width=True): | |
| deleted = db.delete_logistics_calculation(selected_delete) | |
| clear_logistics_cache() | |
| if deleted: | |
| st.success("Cálculo borrado.") | |
| st.rerun() | |
| else: | |
| st.warning("No se encontró el cálculo seleccionado.") | |
| if active_view == "🌐 Proveedores": | |
| render_page_header( | |
| "Sourcing global IA", | |
| "Proveedores", | |
| "Agente de búsqueda por renglón para encontrar proveedores globales, útiles y con menor riesgo.", | |
| ["10 proveedores", "Precio bajo", "Riesgo controlado"] | |
| ) | |
| rfq_loaded = st.session_state.df_exportar is not None and not st.session_state.df_exportar.empty | |
| tavily_ready = bool(st.session_state.get("tavily_key")) | |
| brave_ready = bool(BRAVE_SEARCH_API_KEY) | |
| render_summary_strip([ | |
| {"label": "Búsqueda automática", "value": "Activa" if (tavily_ready or brave_ready) else "Sin API", "tone": "green" if (tavily_ready or brave_ready) else "orange"}, | |
| {"label": "Ranking IA", "value": "Activo" if bool(st.session_state.get("gemini_key")) else "Sin Gemini", "tone": "green" if bool(st.session_state.get("gemini_key")) else "orange"}, | |
| {"label": "Objetivo", "value": "Precio bajo + técnico", "tone": "blue"}, | |
| {"label": "RFQ cargado", "value": "Si" if rfq_loaded else "No", "tone": "green" if rfq_loaded else "orange"}, | |
| ]) | |
| st.markdown(""" | |
| <div class="work-panel"> | |
| <div class="panel-label">Regla operativa del sourcing</div> | |
| <div class="panel-copy"> | |
| La IA debe buscar proveedores globales sin limitar país, priorizando cumplimiento técnico, | |
| probabilidad de buen precio y señales de empresa real. Los proveedores poco obvios son valiosos | |
| solo si tienen evidencia verificable. | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if not rfq_loaded: | |
| render_notice_panel( | |
| "Carga un RFQ para obtener sourcing por renglón", | |
| "La nueva búsqueda usa los renglones, marca/modelo, cantidades y restricciones técnicas del pliego. Sin RFQ solo puedes usar una búsqueda manual básica.", | |
| "amber", | |
| ) | |
| provider_df = pd.DataFrame() | |
| if rfq_loaded: | |
| provider_df = normalize_technical_fields(normalize_history_columns(st.session_state.df_exportar.copy())) | |
| left_col, right_col = st.columns([0.58, 0.42]) | |
| with left_col: | |
| st.markdown("#### Alcance del agente") | |
| if rfq_loaded and not provider_df.empty: | |
| search_scope = st.radio( | |
| "Buscar proveedores para", | |
| ["Un renglón", "Todos los renglones"], | |
| horizontal=True, | |
| key="sourcing_scope", | |
| ) | |
| def _sourcing_row_label(idx): | |
| row = provider_df.iloc[idx] | |
| term = str(row.get("termino_de_busqueda_corto", "") or row.get("ficha_tecnica_completa", "") or "")[:80] | |
| return f"Renglón {row.get('renglon', idx + 1)} | {term or 'Sin descripción'}" | |
| selected_idx = st.selectbox( | |
| "Renglón base", | |
| list(range(len(provider_df))), | |
| format_func=_sourcing_row_label, | |
| disabled=search_scope == "Todos los renglones", | |
| key="sourcing_selected_row", | |
| ) | |
| contexts = [sourcing_row_context(provider_df.iloc[selected_idx])] if search_scope == "Un renglón" else [ | |
| sourcing_row_context(row) for _, row in provider_df.iterrows() | |
| ] | |
| preview_ctx = contexts[0] | |
| else: | |
| search_scope = "Búsqueda manual" | |
| manual_item = st.text_input("Producto, número de parte, marca o descripción", key="sourcing_manual_item") | |
| contexts = [{ | |
| "renglon": "Manual", | |
| "codigo_acp": "", | |
| "descripcion": manual_item, | |
| "busqueda_sugerida": manual_item, | |
| "cantidad": "", | |
| "unidad": "", | |
| "marca_modelo": "", | |
| "acepta_equivalente": None, | |
| "requiere_propuesta_tecnica": False, | |
| "requiere_ficha_tecnica": False, | |
| "evidencia_tecnica": "", | |
| }] | |
| preview_ctx = contexts[0] | |
| c1, c2 = st.columns([0.45, 0.55]) | |
| with c1: | |
| sourcing_depth = st.selectbox("Profundidad", ["Profunda", "Rápida"], key="sourcing_depth") | |
| with c2: | |
| target_per_row = st.number_input("Proveedores por renglón", min_value=3, max_value=10, value=10, step=1, key="sourcing_target_count") | |
| sourcing_prompt = st.text_area( | |
| "Instrucción personalizada para la IA", | |
| value=( | |
| "Prioriza proveedores globales poco obvios, stockistas industriales, distribuidores regionales " | |
| "y fabricantes directos. Deben cumplir técnicamente y tener probabilidad de buen precio. " | |
| "Descarta empresas fantasma, sitios sin evidencia, blogs y resultados que no vendan el producto." | |
| ), | |
| height=120, | |
| key="sourcing_custom_prompt", | |
| ) | |
| run_sourcing = st.button("Buscar 10 proveedores con IA", type="primary", use_container_width=True) | |
| with right_col: | |
| render_table_toolbar( | |
| "Contexto técnico", | |
| "La búsqueda se arma desde el RFQ y se ajusta con tu instrucción.", | |
| ["Global", "Long-tail", "Validación"], | |
| ) | |
| st.markdown(f""" | |
| <div class="provider-rfq-card"> | |
| <div class="provider-rfq-eyebrow">Renglón de referencia</div> | |
| <div class="provider-rfq-title">{escape(sourcing_base_query_from_context(preview_ctx) or 'Sin búsqueda')}</div> | |
| <div class="provider-rfq-meta"> | |
| <span>Renglón <b>{escape(str(preview_ctx.get('renglon') or 'N/A'))}</b></span> | |
| <span>Código ACP <b>{escape(str(preview_ctx.get('codigo_acp') or 'S/C'))}</b></span> | |
| <span>Cantidad <b>{escape(str(preview_ctx.get('cantidad') or 'N/A'))}</b></span> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| render_notice_panel( | |
| "Criterio de decisión", | |
| "Recomendado solo si combina match técnico, probabilidad de buen precio y riesgo aceptable. Si falta evidencia, queda como Validar antes de cotizar.", | |
| "blue", | |
| ) | |
| if not (tavily_ready or brave_ready): | |
| render_notice_panel( | |
| "Falta motor de búsqueda", | |
| "Configura Tavily o Brave para que el agente pueda buscar proveedores reales automáticamente.", | |
| "amber", | |
| ) | |
| if run_sourcing: | |
| if not contexts or not sourcing_base_query_from_context(contexts[0]): | |
| st.error("No hay suficiente descripción técnica para buscar proveedores.") | |
| elif not (tavily_ready or brave_ready): | |
| st.error("Para esta nueva búsqueda automática necesitas Tavily o Brave configurado.") | |
| else: | |
| all_results = [] | |
| summaries = [] | |
| evidence_count = 0 | |
| max_contexts = contexts if search_scope == "Un renglón" else contexts[:8] | |
| with st.status("Agente de Sourcing Global buscando proveedores útiles...", expanded=True) as status: | |
| try: | |
| for idx, ctx in enumerate(max_contexts, 1): | |
| st.write(f"Renglón {ctx.get('renglon') or idx}: buscando evidencia web...") | |
| summary, result_df, evidence = run_global_sourcing_for_context( | |
| ctx, | |
| custom_prompt=sourcing_prompt, | |
| depth=sourcing_depth, | |
| target_count=int(target_per_row), | |
| ) | |
| evidence_count += len(evidence) | |
| if summary: | |
| summaries.append(f"Renglón {ctx.get('renglon') or idx}: {summary}") | |
| if result_df is not None and not result_df.empty: | |
| all_results.append(result_df) | |
| provider_results = pd.concat(all_results, ignore_index=True) if all_results else pd.DataFrame() | |
| if not provider_results.empty: | |
| provider_results = provider_results.drop_duplicates(subset=["Proveedor", "URL"], keep="first") | |
| provider_results = provider_results.head(int(target_per_row) if search_scope == "Un renglón" else 80) | |
| st.session_state.provider_results = provider_results | |
| st.session_state.provider_last_query = " | ".join([sourcing_base_query_from_context(c) for c in max_contexts[:3]]) | |
| st.session_state.provider_sourcing_summary = "\n".join(summaries) | |
| status.update(label=f"Sourcing completado con {evidence_count} evidencias revisadas.", state="complete") | |
| st.toast("Sourcing global completado", icon="🔎") | |
| except Exception as e: | |
| st.session_state.provider_results = pd.DataFrame() | |
| status.update(label="No se pudo completar el sourcing.", state="error") | |
| st.error(f"No se pudo completar la búsqueda: {e}") | |
| provider_results = st.session_state.get("provider_results", pd.DataFrame()) | |
| if provider_results is not None and not provider_results.empty: | |
| render_table_toolbar( | |
| "Ranking IA de proveedores", | |
| "10 candidatos pensados para cumplir técnicamente, buscar buen precio y evitar riesgo innecesario.", | |
| [f"{len(provider_results)} resultados", st.session_state.get("provider_last_query", "Búsqueda")], | |
| ) | |
| summary_text = st.session_state.get("provider_sourcing_summary", "") | |
| if summary_text: | |
| st.caption(summary_text[:900]) | |
| st.dataframe( | |
| provider_results, | |
| column_config={ | |
| "Match técnico": st.column_config.ProgressColumn("Match técnico", min_value=0, max_value=100), | |
| "Probabilidad de buen precio": st.column_config.TextColumn("Prob. buen precio", width="small"), | |
| "Riesgo": st.column_config.TextColumn("Riesgo", width="small"), | |
| "Decisión": st.column_config.TextColumn("Decisión", width="medium"), | |
| "Proveedor": st.column_config.TextColumn("Proveedor", width="large"), | |
| "País / Región": st.column_config.TextColumn("País / Región", width="small"), | |
| "Tipo": st.column_config.TextColumn("Tipo", width="small"), | |
| "Evidencia": st.column_config.TextColumn("Evidencia", width="large"), | |
| "Qué validar": st.column_config.TextColumn("Qué validar", width="large"), | |
| "URL": st.column_config.LinkColumn("URL"), | |
| }, | |
| use_container_width=True, | |
| hide_index=True, | |
| height=520, | |
| ) | |
| top_rows = provider_results.head(3) | |
| card_cols = st.columns(min(3, len(top_rows))) | |
| for idx, (_, result_row) in enumerate(top_rows.iterrows()): | |
| with card_cols[idx]: | |
| st.markdown( | |
| f'<div class="provider-card">' | |
| f'<h4>{escape(str(result_row.get("Proveedor", "Proveedor"))[:80])}</h4>' | |
| f'<p><b>Match:</b> {escape(str(result_row.get("Match técnico", "")))} | <b>Precio:</b> {escape(str(result_row.get("Probabilidad de buen precio", "")))} | <b>Riesgo:</b> {escape(str(result_row.get("Riesgo", "")))}</p>' | |
| f'<p>{escape(str(result_row.get("Evidencia", ""))[:180])}</p>' | |
| f'<a href="{escape(str(result_row.get("URL", "#")))}" target="_blank">Revisar evidencia</a>' | |
| f'</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| else: | |
| render_empty_state( | |
| "Aún no hay proveedores recomendados", | |
| "Ejecuta el Agente de Sourcing Global para obtener 10 candidatos con evidencia, riesgo y validaciones." | |
| ) | |
| if active_view == "📊 Historial Global": | |
| render_page_header( | |
| "Datos corporativos", | |
| "Historial Global", | |
| "Consulta de precios, licitaciones previas y trazabilidad importada a Supabase.", | |
| ["Supabase", "Costos históricos"], | |
| ) | |
| h_count = get_historico_count_cached() | |
| h1, h2, h3 = st.columns([0.25, 0.35, 0.4]) | |
| h1.metric("Registros en Supabase", f"{h_count:,}") | |
| with h2: | |
| anios_hist = ["Todos"] + [str(a) for a in get_historico_anios_cached()] | |
| anio_hist = st.selectbox("Año", anios_hist, key="hist_global_anio") | |
| with h3: | |
| search_hist = st.text_input("Buscar", placeholder="Licitación, código ACP u observación", key="hist_global_search") | |
| if st.session_state.role in ["Gerencia", "Admin"] or st.session_state.username == "admin": | |
| with st.expander("Importar histórico desde Excel local"): | |
| st.caption("Uso de desarrollo: carga el Excel a Supabase para que producción no dependa del archivo físico.") | |
| replace_hist = st.checkbox("Reemplazar registros existentes antes de importar", value=False) | |
| if st.button("Importar a Supabase", type="primary"): | |
| if not os.path.exists("ACP DATA LIC PASADAS v2_2.xlsx"): | |
| st.error("No se encontró el Excel local para importar.") | |
| else: | |
| try: | |
| result = db.import_historico_excel_to_db("ACP DATA LIC PASADAS v2_2.xlsx", replace=replace_hist) | |
| load_historical_prices.clear() | |
| get_historico_anios_cached.clear() | |
| get_historico_count_cached.clear() | |
| get_historico_licitaciones_cached.clear() | |
| st.success(f"Histórico importado: {result['rows_processed']:,} filas procesadas.") | |
| except Exception as e: | |
| st.error(f"Error importando histórico: {e}") | |
| if h_count == 0: | |
| render_empty_state("Histórico sin datos", "La base de Supabase aún no tiene precios históricos importados para comparar nuevas ofertas.") | |
| else: | |
| df_hist_global = get_historico_licitaciones_cached(search=search_hist, anio=anio_hist, limit=1000) | |
| if df_hist_global.empty: | |
| render_empty_state("Sin coincidencias", "Ajusta el filtro de año o la búsqueda para ver precios y licitaciones anteriores.") | |
| else: | |
| render_table_toolbar( | |
| "Histórico de precios", | |
| f"{len(df_hist_global):,} registros visibles para comparar ofertas nuevas contra referencias anteriores.", | |
| [f"Año: {anio_hist}", "Supabase", "Solo consulta" if not role_can("settings") else "Importacion habilitada"], | |
| ) | |
| hist_preferred_cols = [ | |
| "N° Licitación", "Año", "Mes", "Código ACP", "Cantidad", | |
| "Precio Proyelec", "Precio Competencia", "Adjudicada a Proyelec", | |
| "Analista", "Observaciones", | |
| ] | |
| hist_visible_cols = [c for c in hist_preferred_cols if c in df_hist_global.columns] | |
| df_hist_view = df_hist_global[hist_visible_cols].copy() if hist_visible_cols else df_hist_global.copy() | |
| st.dataframe( | |
| df_hist_view, | |
| use_container_width=True, | |
| hide_index=True, | |
| height=560, | |
| column_config={ | |
| "N° Licitación": st.column_config.TextColumn("Licitacion", width="medium"), | |
| "Año": st.column_config.NumberColumn("Año", width="small", format="%d"), | |
| "Mes": st.column_config.TextColumn("Mes", width="small"), | |
| "Código ACP": st.column_config.TextColumn("Codigo ACP", width="medium"), | |
| "Cantidad": st.column_config.NumberColumn("Cant.", width="small"), | |
| "Precio Proyelec": st.column_config.NumberColumn("Precio Proyelec", format="$ %.2f"), | |
| "Precio Competencia": st.column_config.NumberColumn("Precio Competencia", format="$ %.2f"), | |
| "Adjudicada a Proyelec": st.column_config.TextColumn("Adjudicada", width="small"), | |
| "Analista": st.column_config.TextColumn("Analista", width="medium"), | |
| "Observaciones": st.column_config.TextColumn("Observaciones", width="large"), | |
| }, | |
| ) | |
| if active_view == "📡 Radar Supervisor": | |
| render_page_header( | |
| "Supervision comercial", | |
| "Radar Supervisor", | |
| "Licitaciones abiertas del SLI priorizadas por historial, cierre y oportunidad comercial.", | |
| ["SLI abierto", "Supabase", "Beta"], | |
| ) | |
| scan_notice = st.session_state.pop("radar_scan_notice", None) | |
| if scan_notice: | |
| notice_type, notice_text = scan_notice | |
| getattr(st, notice_type)(notice_text) | |
| with st.expander("Buscar licitaciones abiertas en SLI", expanded=False): | |
| st.caption("Consulta el SLI de la ACP, recorre todas las páginas abiertas, guarda los resultados en el Radar y evita descargar archivos al repositorio.") | |
| bs1, bs2, bs3 = st.columns([0.42, 0.24, 0.34]) | |
| with bs1: | |
| sli_palabra = st.text_input("Palabra clave", placeholder="Vacío = todas las abiertas", key="radar_sli_palabra") | |
| with bs2: | |
| sli_numero = st.text_input("Número de licitación", placeholder="Ej: 213906", key="radar_sli_numero") | |
| with bs3: | |
| st.markdown("<div style='height: 28px;'></div>", unsafe_allow_html=True) | |
| buscar_sli = st.button("Buscar en SLI", type="primary", use_container_width=True) | |
| if buscar_sli: | |
| with st.status("Conectando con SLI y actualizando el Radar...", expanded=True) as status: | |
| try: | |
| import sli_scraper | |
| resultado_scan = sli_scraper.ejecutar_radar_detallado( | |
| db_module=db, | |
| palabra_clave=sli_palabra, | |
| numero_licitacion=sli_numero, | |
| ) | |
| radar_full_after_scan = add_radar_date_columns(db.get_licitaciones_radar()) | |
| vencidas_ids_scan = radar_full_after_scan.loc[ | |
| radar_full_after_scan.get("_radar_vencida", pd.Series(False, index=radar_full_after_scan.index)), | |
| "id", | |
| ].dropna().astype(int).tolist() if not radar_full_after_scan.empty and "id" in radar_full_after_scan.columns else [] | |
| eliminadas_scan = db.eliminar_licitaciones_radar(vencidas_ids_scan) if vencidas_ids_scan else 0 | |
| clear_radar_cache() | |
| total_scan = int(resultado_scan.get("total", 0) or 0) | |
| nuevas_scan = int(resultado_scan.get("nuevas", 0) or 0) | |
| actualizadas_scan = int(resultado_scan.get("actualizadas", 0) or 0) | |
| obsoletas_scan = int(resultado_scan.get("obsoletas_eliminadas", 0) or 0) | |
| errores_scan = str(resultado_scan.get("errores", "") or "").strip() | |
| paginas_scan = int(resultado_scan.get("paginas_recorridas", 0) or 0) | |
| metodo_scan = str(resultado_scan.get("metodo", "") or "n/d") | |
| cobertura_scan = bool(resultado_scan.get("escaneo_completo", False)) | |
| status.update(label="Búsqueda SLI completada.", state="complete") | |
| if errores_scan: | |
| st.session_state["radar_scan_notice"] = ( | |
| "warning", | |
| f"SLI respondió con advertencia: {errores_scan} | Método: {metodo_scan} | Páginas: {paginas_scan} | Cobertura completa: {'sí' if cobertura_scan else 'no'}", | |
| ) | |
| elif total_scan == 0: | |
| st.session_state["radar_scan_notice"] = ("info", "No se encontraron licitaciones abiertas con esos filtros en el SLI.") | |
| else: | |
| st.session_state["radar_scan_notice"] = ( | |
| "success", | |
| f"SLI actualizado: {total_scan} abiertas encontradas, {nuevas_scan} nuevas, {actualizadas_scan} existentes actualizadas, {obsoletas_scan} fuera del SLI eliminadas y {eliminadas_scan} vencidas eliminadas. Método: {metodo_scan}; páginas recorridas: {paginas_scan}; cobertura completa: {'sí' if cobertura_scan else 'no'}.", | |
| ) | |
| st.rerun() | |
| except Exception as exc: | |
| status.update(label="No se pudo consultar el SLI.", state="error") | |
| db.registrar_escaneo_radar(0, 0, str(exc)) | |
| clear_radar_cache() | |
| st.error(f"Error consultando SLI: {exc}") | |
| rf1, rf2, rf3, rf4, rf5 = st.columns([0.15, 0.15, 0.18, 0.18, 0.34]) | |
| with rf1: | |
| solo_nuevas = st.checkbox("Solo nuevas", value=False) | |
| with rf2: | |
| solo_hoy = st.checkbox("Solo hoy", value=False) | |
| with rf3: | |
| ocultar_vencidas = st.checkbox("Ocultar vencidas", value=True) | |
| with rf4: | |
| solo_alertas_enmienda = st.checkbox("Solo alertas de enmienda", value=False) | |
| with rf5: | |
| search_radar = st.text_input("Filtro tipo Excel", placeholder="Ej: 213906 rexroth abierta") | |
| excel_filter_cols = st.multiselect( | |
| "Buscar dentro de columnas", | |
| ["RFQ", "Objeto", "Publicación", "Cierre", "Enmienda", "Estado", "Categoría", "Notas", "Revisada por"], | |
| default=["RFQ", "Objeto", "Enmienda", "Estado", "Categoría", "Notas"], | |
| help="Funciona como filtro de Excel: escribe una o varias palabras y el Radar solo muestra filas que contengan todos los términos en las columnas seleccionadas.", | |
| key="radar_excel_filter_cols", | |
| ) | |
| radar_sort_mode = st.selectbox( | |
| "Ordenar Radar por", | |
| ["Publicación más reciente", "Cierre más cercano", "Cierre más lejano", "Publicación más antigua", "Score más alto"], | |
| key="radar_sort_mode", | |
| help="Usa fechas reales, no texto. Publicación más reciente muestra primero las licitaciones recién abiertas en el SLI.", | |
| ) | |
| ff1, ff2, ff3 = st.columns([0.34, 0.34, 0.32]) | |
| with ff1: | |
| filtro_publicacion = st.selectbox( | |
| "Fecha de publicación", | |
| ["Todas", "Hoy", "Últimos 3 días", "Últimos 7 días", "Este mes", "Rango personalizado"], | |
| key="radar_filtro_publicacion", | |
| ) | |
| rango_publicacion = None | |
| if filtro_publicacion == "Rango personalizado": | |
| rango_publicacion = st.date_input( | |
| "Rango publicación", | |
| value=(datetime.now().date() - timedelta(days=7), datetime.now().date()), | |
| key="radar_rango_publicacion", | |
| ) | |
| with ff2: | |
| filtro_cierre = st.selectbox( | |
| "Fecha de cierre", | |
| ["Todas", "Hoy", "Mañana", "Próximos 3 días", "Próximos 7 días", "Este mes", "Rango personalizado"], | |
| key="radar_filtro_cierre", | |
| ) | |
| rango_cierre = None | |
| if filtro_cierre == "Rango personalizado": | |
| rango_cierre = st.date_input( | |
| "Rango cierre", | |
| value=(datetime.now().date(), datetime.now().date() + timedelta(days=7)), | |
| key="radar_rango_cierre", | |
| ) | |
| with ff3: | |
| filtro_enmienda = st.selectbox( | |
| "Enmiendas", | |
| ["Todas", "Con enmienda", "Sin enmienda"], | |
| key="radar_filtro_enmienda", | |
| ) | |
| st.caption("Puedes filtrar por publicación, cierre y enmienda reportada por el SLI.") | |
| radar_df = get_radar_cached(solo_nuevas=solo_nuevas, solo_hoy=solo_hoy) | |
| radar_df = add_radar_date_columns(radar_df) | |
| hist_radar_df = get_historico_radar_cached() | |
| alertas_enmienda_df = pd.DataFrame() | |
| if not radar_df.empty and "enmienda_alerta" in radar_df.columns: | |
| alerta_enmienda_mask = radar_df["enmienda_alerta"].apply(lambda value: coerce_bool(value, default=False)) | |
| alertas_enmienda_df = radar_df[alerta_enmienda_mask].copy() | |
| if not alertas_enmienda_df.empty: | |
| rfqs_alerta = ", ".join(alertas_enmienda_df["numero_licitacion"].astype(str).head(5).tolist()) | |
| extra_alertas = len(alertas_enmienda_df) - min(len(alertas_enmienda_df), 5) | |
| extra_txt = f" y {extra_alertas} más" if extra_alertas > 0 else "" | |
| st.warning(f"Hay {len(alertas_enmienda_df)} licitación(es) con enmienda nueva después de haber sido descartadas o enviadas a seguimiento: {rfqs_alerta}{extra_txt}.") | |
| vencidas_radar = int(radar_df["_radar_vencida"].sum()) if not radar_df.empty and "_radar_vencida" in radar_df.columns else 0 | |
| if vencidas_radar > 0: | |
| clean_col1, clean_col2 = st.columns([0.72, 0.28]) | |
| with clean_col1: | |
| st.warning(f"Hay {vencidas_radar} licitaciones vencidas guardadas en el Radar.") | |
| with clean_col2: | |
| if st.button("Eliminar vencidas", use_container_width=True): | |
| vencidas_ids = radar_df.loc[radar_df["_radar_vencida"], "id"].dropna().astype(int).tolist() | |
| eliminadas = db.eliminar_licitaciones_radar(vencidas_ids) | |
| clear_radar_cache() | |
| st.toast(f"{eliminadas} licitaciones vencidas eliminadas.") | |
| st.rerun() | |
| if not radar_df.empty and ocultar_vencidas and "_radar_vencida" in radar_df.columns: | |
| radar_df = radar_df[~radar_df["_radar_vencida"]].copy() | |
| if not radar_df.empty and solo_alertas_enmienda and "enmienda_alerta" in radar_df.columns: | |
| radar_df = radar_df[radar_df["enmienda_alerta"].apply(lambda value: coerce_bool(value, default=False))].copy() | |
| radar_df = apply_radar_date_filter( | |
| radar_df, | |
| "_fecha_apertura_dt", | |
| preset=filtro_publicacion, | |
| date_range=rango_publicacion, | |
| ) | |
| radar_df = apply_radar_date_filter( | |
| radar_df, | |
| "_fecha_cierre_dt", | |
| preset=filtro_cierre, | |
| date_range=rango_cierre, | |
| ) | |
| if not radar_df.empty and "numero_enmienda" in radar_df.columns and filtro_enmienda != "Todas": | |
| enmienda_text = radar_df["numero_enmienda"].fillna("").astype(str).str.strip() | |
| tiene_enmienda = enmienda_text.ne("") & ~enmienda_text.str.lower().isin(["0", "no", "n/a", "na", "none", "null"]) | |
| radar_df = radar_df[tiene_enmienda if filtro_enmienda == "Con enmienda" else ~tiene_enmienda].copy() | |
| radar_df = apply_radar_excel_filter(radar_df, search_radar, excel_filter_cols) | |
| if not radar_df.empty: | |
| radar_df = enrich_radar_with_history(radar_df, hist_radar_df) | |
| radar_df = sort_radar_for_view(radar_df, radar_sort_mode) | |
| total_radar = len(radar_df) | |
| nuevas_radar = len(radar_df[radar_df["estado_radar"].fillna("") == "nueva"]) if not radar_df.empty and "estado_radar" in radar_df.columns else 0 | |
| prioritarias = int(radar_df["es_prioritaria"].fillna(False).sum()) if not radar_df.empty and "es_prioritaria" in radar_df.columns else 0 | |
| score_prom = float(radar_df["score_interes"].fillna(0).mean()) if not radar_df.empty and "score_interes" in radar_df.columns else 0 | |
| con_historial = int((radar_df["hist_participaciones"].fillna(0) > 0).sum()) if not radar_df.empty and "hist_participaciones" in radar_df.columns else 0 | |
| rk1, rk2, rk3, rk4 = st.columns(4) | |
| rk1.metric("Licitaciones radar", total_radar) | |
| rk2.metric("Nuevas", nuevas_radar) | |
| rk3.metric("Prioritarias", prioritarias) | |
| rk4.metric("Con historial similar", con_historial, help=f"Score promedio actual: {score_prom:.1f}") | |
| render_summary_strip([ | |
| {"label": "Vista", "value": radar_sort_mode, "tone": "blue"}, | |
| {"label": "Decision", "value": "Analizar oportunidad", "tone": "green"}, | |
| {"label": "Enmiendas nuevas", "value": str(len(alertas_enmienda_df)), "tone": "amber" if len(alertas_enmienda_df) else "blue"}, | |
| {"label": "Riesgo", "value": f"{vencidas_radar} vencidas ocultables", "tone": "amber" if vencidas_radar else "blue"}, | |
| ]) | |
| if radar_df.empty: | |
| render_empty_state("Radar sin resultados", "Ejecuta una búsqueda SLI o ajusta los filtros para visualizar licitaciones abiertas.") | |
| else: | |
| render_table_toolbar( | |
| "Oportunidades abiertas", | |
| f"Radar ordenado por {radar_sort_mode.lower()}, usando fechas reales del SLI.", | |
| [f"{len(radar_df)} visibles", f"Score prom. {score_prom:.1f}", "SLI"], | |
| ) | |
| radar_view = build_radar_table_view(radar_df) | |
| selected_radar = st.dataframe( | |
| radar_view, | |
| use_container_width=True, | |
| hide_index=True, | |
| height=520, | |
| on_select="rerun", | |
| selection_mode="single-row", | |
| column_config={ | |
| "Alerta": st.column_config.TextColumn("Alerta", width="medium"), | |
| "RFQ": st.column_config.TextColumn("RFQ", width="small"), | |
| "Objeto": st.column_config.TextColumn("Objeto", width="large"), | |
| "Publicación": st.column_config.DatetimeColumn("Publicación", width="medium", format="DD-MMM-YYYY hh:mm a"), | |
| "Cierre": st.column_config.DatetimeColumn("Cierre", width="medium", format="DD-MMM-YYYY hh:mm a"), | |
| "Enmienda": st.column_config.TextColumn("Enmienda", width="small"), | |
| "Urgencia": st.column_config.TextColumn("Urgencia", width="small"), | |
| "Prioridad": st.column_config.TextColumn("Prioridad", width="small"), | |
| "Historial": st.column_config.NumberColumn("Hist.", width="small", help="Participaciones similares encontradas"), | |
| "Ganadas": st.column_config.NumberColumn("Gan.", width="small", help="Historial similar adjudicado a Proyelec"), | |
| "Score": st.column_config.NumberColumn("Score", width="small", format="%d"), | |
| "Estado": st.column_config.TextColumn("Estado", width="small"), | |
| }, | |
| ) | |
| if len(selected_radar.selection.rows) > 0: | |
| radar_row = radar_df.iloc[selected_radar.selection.rows[0]] | |
| radar_id = int(radar_row["id"]) | |
| numero_radar = str(radar_row.get("numero_licitacion", "")) | |
| objeto_radar = str(radar_row.get("objeto", f"Licitación {numero_radar}")) | |
| link_radar = str(radar_row.get("link_sli", "") or f"https://apps.pancanal.com/sli/Licitaciones/LicitacionHeader?rfqId={numero_radar}") | |
| enmienda_radar = str(radar_row.get("numero_enmienda", "") or "").strip() | |
| enmienda_anterior_radar = str(radar_row.get("enmienda_anterior", "") or "").strip() | |
| tiene_alerta_enmienda = coerce_bool(radar_row.get("enmienda_alerta", False), default=False) | |
| st.markdown(f""" | |
| <div class="selected-record"> | |
| <div class="selected-kicker">Licitacion seleccionada</div> | |
| <div class="selected-title">{escape(numero_radar)}</div> | |
| <div class="selected-subtitle">{escape(objeto_radar)}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| detail_cols = st.columns(3) | |
| detail_cols[0].metric("Publicación", str(radar_row.get("fecha_apertura", "") or "N/D")) | |
| detail_cols[1].metric("Cierre", str(radar_row.get("fecha_cierre", "") or "N/D")) | |
| detail_cols[2].metric("Enmienda", enmienda_radar if enmienda_radar else "No") | |
| if tiene_alerta_enmienda: | |
| render_notice_panel( | |
| "Enmienda nueva detectada", | |
| f"Esta licitación estaba en estado {radar_row.get('estado_radar', 'N/D')} y el SLI cambió la enmienda de '{enmienda_anterior_radar or 'Sin enmienda'}' a '{enmienda_radar or 'Sin enmienda'}'. Conviene reabrir la revisión antes de mantener el descarte o continuar el seguimiento.", | |
| "amber", | |
| ) | |
| if st.button("Marcar alerta de enmienda como revisada", use_container_width=True, key=f"ack_enmienda_{radar_id}"): | |
| db.marcar_alerta_enmienda_revisada(radar_id) | |
| clear_radar_cache() | |
| st.toast("Alerta de enmienda marcada como revisada.") | |
| st.rerun() | |
| matches_radar = build_historico_matches_for_radar( | |
| objeto_radar, | |
| str(radar_row.get("categoria", "") or ""), | |
| hist_radar_df, | |
| max_rows=12, | |
| ) | |
| if matches_radar.empty: | |
| st.info("No encontré participaciones anteriores parecidas en el histórico cargado. Para este caso conviene revisar el RFQ antes de estimar rentabilidad.") | |
| else: | |
| m1, m2, m3 = st.columns(3) | |
| m1.metric("Participaciones similares", len(matches_radar)) | |
| m2.metric("Ganadas por Proyelec", int((matches_radar["adjudicada_proyelec"] == "Si").sum())) | |
| years_match = pd.to_numeric(matches_radar["anio_hist"], errors="coerce").dropna() | |
| m3.metric("Ultimo ano visto", int(years_match.max()) if not years_match.empty else "N/D") | |
| with st.expander("Participaciones anteriores similares", expanded=True): | |
| st.caption("Cruce orientativo por objeto, categoría, códigos ACP y observaciones del histórico. No reemplaza el análisis del RFQ.") | |
| st.dataframe( | |
| matches_radar[[ | |
| "score_hist", "licitacion_hist", "anio_hist", "codigo_acp", | |
| "precio_proyelec", "adjudicada_proyelec", "evidencia", "observaciones", | |
| ]], | |
| use_container_width=True, | |
| hide_index=True, | |
| column_config={ | |
| "score_hist": st.column_config.NumberColumn("Score hist.", format="%d"), | |
| "licitacion_hist": st.column_config.TextColumn("Licitacion hist."), | |
| "anio_hist": st.column_config.NumberColumn("Ano", format="%d"), | |
| "codigo_acp": st.column_config.TextColumn("Codigo ACP"), | |
| "precio_proyelec": st.column_config.NumberColumn("Precio Proyelec", format="$ %.2f"), | |
| "adjudicada_proyelec": st.column_config.TextColumn("Adjudicada"), | |
| "evidencia": st.column_config.TextColumn("Coincidencia"), | |
| "observaciones": st.column_config.TextColumn("Observaciones"), | |
| }, | |
| ) | |
| analysis_key = f"radar_oportunidad_{radar_id}" | |
| op_col1, op_col2 = st.columns([0.28, 0.72]) | |
| with op_col1: | |
| if st.button("Analizar oportunidad", type="primary", use_container_width=True, key=f"analizar_op_{radar_id}"): | |
| sli_data = {} | |
| with st.status("Consultando SLI y calculando oportunidad...", expanded=True) as status: | |
| try: | |
| sli_rfq = "".join(filter(str.isdigit, numero_radar)) | |
| resp_sli = requests.get( | |
| f"{API_URL_BASE}/consultar-sli/{sli_rfq}", | |
| timeout=55, | |
| headers=API_HEADERS, | |
| ) | |
| if resp_sli.status_code == 200: | |
| sli_data = resp_sli.json() | |
| status.update(label="SLI consultado. Calculando recomendacion...", state="running") | |
| else: | |
| try: | |
| detail = resp_sli.json().get("detail", {}) | |
| except Exception: | |
| detail = resp_sli.text | |
| sli_data = {"error": str(detail)} | |
| st.warning("No se pudo leer el detalle SLI; se usara la informacion del Radar.") | |
| except Exception as exc: | |
| sli_data = {"error": str(exc)} | |
| st.warning("No se pudo consultar SLI; se usara la informacion del Radar.") | |
| st.session_state[analysis_key] = analyze_radar_opportunity( | |
| radar_row, | |
| matches_df=matches_radar, | |
| sli_data=sli_data, | |
| ) | |
| status.update(label="Analisis de oportunidad listo.", state="complete") | |
| st.rerun() | |
| with op_col2: | |
| st.caption("Evalua rubro, historial Proyelec, fecha de cierre, prioridad y estado SLI para sugerir una decision.") | |
| opportunity = st.session_state.get(analysis_key) | |
| if opportunity: | |
| alert_fn = getattr(st, opportunity.get("color", "info"), st.info) | |
| alert_fn(f"Recomendacion: {opportunity['decision']} | Score {opportunity['score']}/100") | |
| oc1, oc2, oc3 = st.columns(3) | |
| oc1.metric("Decision", opportunity["decision"]) | |
| oc2.metric("Score oportunidad", f"{opportunity['score']}/100") | |
| oc3.metric("Estatus SLI", opportunity.get("sli", {}).get("estatus") or "No disponible") | |
| with st.expander("Razones, riesgos y próximos pasos", expanded=True): | |
| st.markdown("**Razones a favor**") | |
| for reason in opportunity.get("razones", []): | |
| st.markdown(f"- {reason}") | |
| st.markdown("**Riesgos / validaciones**") | |
| for risk in opportunity.get("riesgos", []): | |
| st.markdown(f"- {risk}") | |
| st.markdown("**Próximos pasos sugeridos**") | |
| for step in opportunity.get("pasos", []): | |
| st.markdown(f"- {step}") | |
| sli_info = opportunity.get("sli", {}) | |
| if sli_info.get("descripcion") or sli_info.get("fecha_cierre") or sli_info.get("fecha_publicacion"): | |
| st.markdown("**Datos SLI consultados**") | |
| st.caption( | |
| f"Publicacion: {sli_info.get('fecha_publicacion') or 'N/D'} | " | |
| f"Cierre: {sli_info.get('fecha_cierre') or 'N/D'}" | |
| ) | |
| if sli_info.get("error"): | |
| st.warning(f"SLI no disponible para detalle: {sli_info.get('error')}") | |
| action_col1, action_col2, action_col3, action_col4 = st.columns([0.25, 0.25, 0.25, 0.25]) | |
| notas_radar = st.text_area("Notas de decisión", value=str(radar_row.get("notas", "") or ""), height=80) | |
| with action_col1: | |
| if st.button("Marcar revisada", use_container_width=True): | |
| db.marcar_licitacion_radar(radar_id, "revisada", st.session_state.username, notas_radar) | |
| clear_radar_cache() | |
| st.toast("Radar marcado como revisado.") | |
| st.rerun() | |
| with action_col2: | |
| if st.button("Descartar", use_container_width=True): | |
| db.marcar_licitacion_radar(radar_id, "descartada", st.session_state.username, notas_radar) | |
| clear_radar_cache() | |
| st.toast("Licitación descartada del radar.") | |
| st.rerun() | |
| with action_col3: | |
| if st.button("Pasar a seguimiento", type="primary", use_container_width=True): | |
| nota_seguimiento = notas_radar or f"Agregado desde Radar Supervisor. Historial similar: {int(radar_row.get('hist_participaciones', 0) or 0)} registros." | |
| opportunity_for_note = st.session_state.get(f"radar_oportunidad_{radar_id}") | |
| if opportunity_for_note and "Recomendacion Radar:" not in nota_seguimiento: | |
| nota_seguimiento += f" | Recomendacion Radar: {opportunity_for_note.get('decision')} ({opportunity_for_note.get('score')}/100)" | |
| ok = db.crear_seguimiento( | |
| numero_radar, | |
| objeto_radar, | |
| str(radar_row.get("fecha_apertura", "") or ""), | |
| "", | |
| float(radar_row.get("monto_estimado", 0) or 0), | |
| str(radar_row.get("moneda", "USD") or "USD"), | |
| link_radar, | |
| nota_seguimiento, | |
| st.session_state.username, | |
| ) | |
| db.marcar_licitacion_radar(radar_id, "en_seguimiento", st.session_state.username, notas_radar) | |
| clear_radar_cache() | |
| clear_monitor_cache() | |
| st.toast("Licitación enviada al Monitor ACP." if ok else "Ya existía en el Monitor ACP.") | |
| st.rerun() | |
| with action_col4: | |
| st.link_button("Abrir SLI", link_radar, use_container_width=True) | |
| with st.expander("Últimos escaneos del radar"): | |
| scans_df = get_radar_scans_cached(10) | |
| if scans_df.empty: | |
| render_empty_state("Sin escaneos registrados", "Cuando se consulte el SLI, los resultados de cada escaneo quedaran aqui.") | |
| else: | |
| render_table_toolbar("Bitácora de escaneos", f"{len(scans_df)} registros recientes", ["Radar SLI", "Auditoria"]) | |
| st.dataframe(scans_df, use_container_width=True, hide_index=True) | |
| if active_view == "🏛️ Monitor ACP": | |
| render_page_header( | |
| "Operacion post-RFQ", | |
| "Monitor ACP", | |
| "Seguimiento de licitaciones enviadas, estados SLI, alertas y decisiones de cierre.", | |
| ["Pipeline", "SLI", "Alertas"], | |
| ) | |
| seg_df = get_seguimientos_cached() | |
| # --- KPIs del monitor --- | |
| total_seg = len(seg_df) | |
| adjudicadas = len(seg_df[seg_df['estado'] == 'Adjudicada']) if not seg_df.empty else 0 | |
| en_proceso = len(seg_df[seg_df['estado'].isin(['Oferta Enviada al SLI', 'Cumple Tecnicamente', 'En Evaluacion Economica'])]) if not seg_df.empty else 0 | |
| tasa = f"{int(adjudicadas/total_seg*100)}%" if total_seg > 0 else "—" | |
| mk1, mk2, mk3, mk4 = st.columns(4) | |
| mk1.metric("Total Licitaciones", total_seg) | |
| mk2.metric("En Evaluación", en_proceso) | |
| mk3.metric("Adjudicadas", adjudicadas) | |
| mk4.metric("Tasa de Éxito", tasa) | |
| prep_count = len(seg_df[seg_df["estado"] == "En Preparacion"]) if not seg_df.empty else 0 | |
| enviada_count = len(seg_df[seg_df["estado"] == "Oferta Enviada al SLI"]) if not seg_df.empty else 0 | |
| eval_count = len(seg_df[seg_df["estado"].isin(["Cumple Tecnicamente", "No Cumple Tecnicamente", "En Evaluacion Economica"])]) if not seg_df.empty else 0 | |
| cierre_count = len(seg_df[seg_df["estado"].isin(["Adjudicada", "No Adjudicada", "Desierta"])]) if not seg_df.empty else 0 | |
| render_summary_strip([ | |
| {"label": "Preparacion", "value": prep_count, "tone": "blue"}, | |
| {"label": "Enviadas SLI", "value": enviada_count, "tone": "amber"}, | |
| {"label": "Evaluacion", "value": eval_count, "tone": "orange"}, | |
| {"label": "Cerradas", "value": cierre_count, "tone": "green"}, | |
| ]) | |
| st.divider() | |
| col_monitor, col_form = st.columns([0.62, 0.38]) | |
| with col_form: | |
| with st.expander("➕ Añadir Licitación al Seguimiento", expanded=(total_seg == 0)): | |
| f_num = st.text_input("Nº Licitación ACP (RFQ)*", placeholder="Ej: 213330", key="seg_num") | |
| f_nota = st.text_area("Notas internas (opcional)", key="seg_nota", height=60) | |
| if st.button("Añadir al Monitor", type="primary", use_container_width=True): | |
| if f_num: | |
| sli_rfq = "".join(filter(str.isdigit, f_num)) | |
| with st.status(f"Obteniendo datos del SLI para {sli_rfq}...", expanded=True): | |
| try: | |
| # Hacer peticion a la API local para consultar el SLI | |
| resp_sli = requests.get(f"{API_URL_BASE}/consultar-sli/{sli_rfq}", timeout=45, headers=API_HEADERS) | |
| f_obj = f"Licitación {sli_rfq}" | |
| f_link = f"https://apps.pancanal.com/sli/Licitaciones/LicitacionHeader?rfqId={sli_rfq}" | |
| f_estatus = "" | |
| if resp_sli.status_code == 200: | |
| datos_sli = resp_sli.json() | |
| if datos_sli.get("descripcion"): | |
| f_obj = datos_sli.get("descripcion") | |
| if datos_sli.get("url"): | |
| f_link = datos_sli.get("url") | |
| f_estatus = datos_sli.get("estatus", "") | |
| ok = db.crear_seguimiento( | |
| sli_rfq, f_obj, "", "", | |
| 0.0, "USD", f_link, f_nota, st.session_state.username) | |
| if ok: | |
| clear_monitor_cache() | |
| # buscar la licitacion insertada | |
| seg_df_new = get_seguimientos_cached() | |
| lic_row = seg_df_new[seg_df_new['numero_licitacion'] == sli_rfq] | |
| if not lic_row.empty and f_estatus: | |
| lic_id = int(lic_row.iloc[0]['id']) | |
| estado_mapeado = MAPA_ESTADOS_SLI.get(f_estatus.upper(), f_estatus) | |
| db.actualizar_estado(lic_id, estado_mapeado, f"Estado inicial desde SLI: {f_estatus}", "Sistema SLI") | |
| clear_monitor_cache() | |
| st.toast(f"✅ Licitación {sli_rfq} añadida al monitor.") | |
| st.rerun() | |
| else: | |
| st.error(f"La licitación '{sli_rfq}' ya existe en el sistema.") | |
| except Exception as e: | |
| st.error(f"Error conectando con la API: {e}") | |
| else: | |
| st.warning("El número de licitación es obligatorio.") | |
| st.caption("🔗 Acceso rápido al portal") | |
| st.link_button("Abrir SLI de la ACP", "https://sli.pancanal.com", use_container_width=True) | |
| with col_monitor: | |
| if seg_df.empty: | |
| render_empty_state("Sin licitaciones registradas", "Usa el formulario para registrar la primera licitacion ACP en seguimiento.") | |
| else: | |
| estado_filtro = st.selectbox( | |
| "Filtrar por estado", | |
| ["Todos"] + list(db.ESTADOS_ACP), | |
| label_visibility="collapsed", | |
| key="monitor_estado_filtro" | |
| ) | |
| seg_view = seg_df if estado_filtro == "Todos" else seg_df[seg_df["estado"] == estado_filtro] | |
| render_table_toolbar( | |
| "Pipeline de seguimiento", | |
| f"{len(seg_view)} licitaciones visibles en el monitor.", | |
| [f"Estado: {estado_filtro}", "Comentarios", "SLI"], | |
| ) | |
| if seg_view.empty: | |
| render_empty_state("Sin licitaciones en este estado", "Cambia el filtro para revisar otras etapas del pipeline.") | |
| for _, row in seg_view.iterrows(): | |
| estado = row.get('estado', 'En Preparacion') | |
| emoji, bg_color, txt_color = ESTADO_CONFIG.get(estado, ("🔵", "#1E3A5F", "#58A6FF")) | |
| lic_id = int(row['id']) | |
| num_lic = row.get('numero_licitacion', '') | |
| objeto = row.get('objeto', '')[:80] | |
| resp = row.get('responsable', '') | |
| link_sli = row.get('link_sli', '') | |
| monto = row.get('monto_ofertado', 0) or 0 | |
| moneda = row.get('moneda', 'USD') | |
| objeto_safe = escape(str(objeto or "Sin descripción")) | |
| resp_safe = escape(str(resp or "Sin responsable")) | |
| estado_safe = escape(str(estado or "")) | |
| # Calcular días desde envío oferta | |
| dias_txt = "" | |
| try: | |
| f_env_dt = datetime.strptime(str(row.get('fecha_envio_oferta', ''))[:10], "%Y-%m-%d") | |
| dias_el = (datetime.now() - f_env_dt).days | |
| alerta = " ⚠️" if dias_el > 60 and estado in ["Oferta Enviada al SLI", "En Evaluacion Economica"] else "" | |
| dias_txt = f"{dias_el} días en evaluación{alerta}" | |
| except Exception: | |
| dias_txt = "" | |
| with st.container(): | |
| st.markdown(f""" | |
| <div class='monitor-row' style='--status-color:{txt_color};'> | |
| <div class='monitor-row-head'> | |
| <div> | |
| <div class='monitor-owner'>{resp_safe}</div> | |
| <div class='monitor-id'>{num_lic}</div> | |
| <div class='monitor-object'>{objeto_safe}</div> | |
| </div> | |
| <div> | |
| <div class='status-badge'><span class='status-dot'></span>{estado_safe}</div> | |
| <div class='monitor-meta'> | |
| <span class='mini-pill'>{dias_txt or "Sin fecha base"}</span> | |
| <span class='mini-pill'>{moneda} {monto:,.2f}</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # --- ALERTA DE ENMIENDA RECIENTE --- | |
| alerta_enmienda = st.session_state.get(f"sli_alerta_enmienda_{lic_id}") | |
| if alerta_enmienda: | |
| st.markdown(f""" | |
| <div style="background: #2B220B; border: 1px solid #E3B341; border-left: 4px solid #E3B341; | |
| padding: 10px 14px; border-radius: 8px; margin: 8px 0 12px 0;"> | |
| <div style="color: #F6B44B; font-weight: bold; font-size: 12px; margin-bottom: 4px; display: flex; align-items: center; gap: 6px;"> | |
| ⚠️ Nueva Enmienda o Modificación Detectada en ACP | |
| </div> | |
| <div style="color: #E6EDF3; font-size: 11px; line-height: 1.45;"> | |
| El portal SLI registra una actualización en los documentos de esta licitación.<br> | |
| • <b>Última Revisión:</b> {alerta_enmienda['rev_nueva']} (antes: {alerta_enmienda['rev_antigua'] or 'Desconocida'})<br> | |
| • <b>Nueva Fecha de Cierre:</b> {alerta_enmienda['cierre_nuevo']} (antes: {alerta_enmienda['cierre_antiguo'] or 'Desconocida'}) | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # --- LÍNEA DE TIEMPO INTERACTIVA / PROGRESO --- | |
| estado_idx = 1 | |
| if estado == "Oferta Enviada al SLI": | |
| estado_idx = 2 | |
| elif estado in ["Cumple Tecnicamente", "No Cumple Tecnicamente", "En Evaluacion Economica"]: | |
| estado_idx = 3 | |
| elif estado in ["Adjudicada", "No Adjudicada", "Desierta"]: | |
| estado_idx = 4 | |
| p1_bg = "#4F9CF9" | |
| p1_txt = "#080C12" | |
| p2_bg = "#F6B44B" if estado_idx >= 2 else "#242C38" | |
| p2_txt = "#080C12" if estado_idx >= 2 else "#8C98A8" | |
| p3_bg = "#F08A3C" if estado_idx >= 3 else "#242C38" | |
| p3_txt = "#080C12" if estado_idx >= 3 else "#8C98A8" | |
| if estado == "No Cumple Tecnicamente": | |
| p3_bg = "#EF5B5B" | |
| p3_txt = "#ffffff" | |
| p4_bg = "#31C48D" if estado_idx == 4 else "#242C38" | |
| p4_txt = "#080C12" if estado_idx == 4 else "#8C98A8" | |
| if estado == "No Adjudicada": | |
| p4_bg = "#748091" | |
| p4_txt = "#ffffff" | |
| elif estado == "Desierta": | |
| p4_bg = "#748091" | |
| p4_txt = "#ffffff" | |
| l1_color = "#F6B44B" if estado_idx >= 2 else "#242C38" | |
| l2_color = "#F08A3C" if estado_idx >= 3 else "#242C38" | |
| l3_color = "#31C48D" if estado_idx == 4 else "#242C38" | |
| st.markdown(f""" | |
| <div style="display: flex; align-items: center; justify-content: space-between; | |
| margin: 10px 0 16px 0; padding: 10px 14px; background: #161B22; | |
| border-radius: 8px; border: 1px solid #30363D;"> | |
| <!-- Paso 1 --> | |
| <div style="text-align: center; flex: 1;"> | |
| <div style="width: 22px; height: 22px; border-radius: 50%; background: {p1_bg}; color: {p1_txt}; display: flex; align-items: center; justify-content: center; margin: 0 auto 4px auto; font-size: 10px; font-weight: bold; box-shadow: 0 0 10px rgba(79, 156, 249, 0.2);">{ "✓" if estado_idx > 1 else "1" }</div> | |
| <div style="font-size: 9px; color: #4F9CF9; font-weight: 700; text-transform: uppercase;">Preparación</div> | |
| </div> | |
| <div style="flex: 0.4; height: 2px; background: {l1_color}; margin-bottom: 12px;"></div> | |
| <!-- Paso 2 --> | |
| <div style="text-align: center; flex: 1;"> | |
| <div style="width: 22px; height: 22px; border-radius: 50%; background: {p2_bg}; color: {p2_txt}; display: flex; align-items: center; justify-content: center; margin: 0 auto 4px auto; font-size: 10px; font-weight: bold;">{ "✓" if estado_idx > 2 else "2" }</div> | |
| <div style="font-size: 9px; color: { "#F6B44B" if estado_idx >= 2 else "#8C98A8" }; font-weight: 700; text-transform: uppercase;">Envío SLI</div> | |
| </div> | |
| <div style="flex: 0.4; height: 2px; background: {l2_color}; margin-bottom: 12px;"></div> | |
| <!-- Paso 3 --> | |
| <div style="text-align: center; flex: 1;"> | |
| <div style="width: 22px; height: 22px; border-radius: 50%; background: {p3_bg}; color: {p3_txt}; display: flex; align-items: center; justify-content: center; margin: 0 auto 4px auto; font-size: 10px; font-weight: bold;">{ "✓" if estado_idx > 3 else "3" }</div> | |
| <div style="font-size: 9px; color: { "#F08A3C" if estado_idx >= 3 else "#8C98A8" }; font-weight: 700; text-transform: uppercase;">Evaluación</div> | |
| </div> | |
| <div style="flex: 0.4; height: 2px; background: {l3_color}; margin-bottom: 12px;"></div> | |
| <!-- Paso 4 --> | |
| <div style="text-align: center; flex: 1;"> | |
| <div style="width: 22px; height: 22px; border-radius: 50%; background: {p4_bg}; color: {p4_txt}; display: flex; align-items: center; justify-content: center; margin: 0 auto 4px auto; font-size: 10px; font-weight: bold;">{ "✓" if estado_idx == 4 else "4" }</div> | |
| <div style="font-size: 9px; color: { "#31C48D" if estado_idx == 4 else "#8C98A8" }; font-weight: 700; text-transform: uppercase;">Resolución</div> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Controles — fila 1: manual | |
| resumen_acta_cache = st.session_state.get(f"sli_resumen_acta_{lic_id}") | |
| if resumen_acta_cache: | |
| resumen_txt = escape(str(resumen_acta_cache.get("resumen", "Resumen de propuestas consultado."))) | |
| st.markdown(f""" | |
| <div class='sli-summary'> | |
| <div class='sli-summary-title'>Resumen SLI</div> | |
| <div class='sli-summary-body'>{resumen_txt}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| hallazgos_cache = resumen_acta_cache.get("hallazgos", []) | |
| if hallazgos_cache: | |
| with st.expander("Ver observaciones detectadas en resumen SLI"): | |
| for hallazgo in hallazgos_cache: | |
| st.markdown(f"- {hallazgo}") | |
| if resumen_acta_cache.get("url"): | |
| st.link_button("Abrir resumen SLI", resumen_acta_cache.get("url")) | |
| ac1, ac2, ac3 = st.columns([0.4, 0.35, 0.25]) | |
| nuevo_estado = ac1.selectbox("Estado manual", db.ESTADOS_ACP, | |
| index=db.ESTADOS_ACP.index(estado) if estado in db.ESTADOS_ACP else 0, | |
| key=f"est_{lic_id}", label_visibility="collapsed") | |
| nota_upd = ac2.text_input("Nota", key=f"nota_{lic_id}", | |
| placeholder="Observación...", label_visibility="collapsed") | |
| with ac3: | |
| if st.button("Guardar", key=f"upd_{lic_id}", use_container_width=True): | |
| db.actualizar_estado(lic_id, nuevo_estado, nota_upd, st.session_state.username) | |
| clear_monitor_cache() | |
| st.toast(f"✅ Estado actualizado a '{nuevo_estado}'") | |
| st.rerun() | |
| # Controles — fila 2: consulta automática SLI | |
| sli_col1, sli_col2, sli_col3 = st.columns([0.38, 0.34, 0.28]) | |
| with sli_col1: | |
| sli_rfq = "".join(filter(str.isdigit, num_lic)) | |
| if st.button("Consultar SLI", key=f"sli_{lic_id}", use_container_width=True, type="primary"): | |
| with st.status(f"Consultando SLI para licitación {num_lic}...", expanded=True): | |
| try: | |
| resp_sli = requests.get( | |
| f"{API_URL_BASE}/consultar-sli/{sli_rfq}", | |
| timeout=75, | |
| headers=API_HEADERS | |
| ) | |
| if resp_sli.status_code == 200: | |
| datos_sli = resp_sli.json() | |
| estatus_sli = datos_sli.get("estatus") | |
| desc_sli = datos_sli.get("descripcion", "") | |
| cierre_sli = datos_sli.get("fecha_cierre", "") | |
| rev_sli = datos_sli.get("ultima_revision", "") | |
| resumen_acta = datos_sli.get("resumen_acta", {}) | |
| err_sli = datos_sli.get("error") | |
| if err_sli: | |
| st.warning(f"⚠️ {err_sli}") | |
| elif estatus_sli: | |
| # Mapear estatus SLI al catálogo interno | |
| estado_mapeado = MAPA_ESTADOS_SLI.get( | |
| estatus_sli.upper(), estatus_sli) | |
| # --- WATCHDOG DE ENMIENDAS / CAMBIOS --- | |
| df_hist_prev = get_historial_seguimiento_cached(lic_id) | |
| if not df_hist_prev.empty: | |
| nota_antigua = str(df_hist_prev.iloc[0].get('nota', '')) | |
| cierre_antiguo, rev_antigua = extraer_meta_nota(nota_antigua) | |
| cambio_cierre = cierre_sli and cierre_antiguo and (cierre_sli.strip() != cierre_antiguo.strip()) | |
| cambio_rev = rev_sli and rev_antigua and (rev_sli.strip() != rev_antigua.strip()) | |
| if cambio_cierre or cambio_rev: | |
| st.session_state[f"sli_alerta_enmienda_{lic_id}"] = { | |
| "cierre_antiguo": cierre_antiguo, | |
| "cierre_nuevo": cierre_sli, | |
| "rev_antigua": rev_antigua, | |
| "rev_nueva": rev_sli | |
| } | |
| nota_auto = f"[SLI Auto] Estatus: {estatus_sli}" | |
| if cierre_sli: | |
| nota_auto += f" | Cierre: {cierre_sli}" | |
| if rev_sli: | |
| nota_auto += f" | Última rev: {rev_sli}" | |
| if resumen_acta.get("disponible"): | |
| st.session_state[f"sli_resumen_acta_{lic_id}"] = resumen_acta | |
| nota_auto += f" | Resumen: {resumen_acta.get('resumen', '')}" | |
| if resumen_acta.get("hallazgos"): | |
| nota_auto += " | Observaciones: " + " / ".join(resumen_acta.get("hallazgos", [])[:3]) | |
| elif resumen_acta.get("error"): | |
| st.session_state.pop(f"sli_resumen_acta_{lic_id}", None) | |
| nota_auto += f" | Resumen SLI: {resumen_acta.get('error')}" | |
| db.actualizar_estado(lic_id, estado_mapeado, | |
| nota_auto, "Sistema SLI") | |
| clear_monitor_cache() | |
| if resumen_acta.get("disponible"): | |
| st.info(resumen_acta.get("resumen", "Resumen de propuestas consultado.")) | |
| for hallazgo in resumen_acta.get("hallazgos", [])[:3]: | |
| st.markdown(f"- {hallazgo}") | |
| st.success(f"✅ Estado SLI: **{estatus_sli}** — Registro actualizado") | |
| st.rerun() | |
| else: | |
| st.warning("El SLI no devolvió un estatus reconocible.") | |
| elif resp_sli.status_code == 503: | |
| try: | |
| err_detail = resp_sli.json().get("detail", {}) | |
| except Exception: | |
| err_detail = {} | |
| if isinstance(err_detail, dict): | |
| st.error(f"⚠️ {err_detail.get('message', 'Servicio SLI no disponible')}") | |
| if err_detail.get("hint"): | |
| st.caption(err_detail.get("hint")) | |
| else: | |
| st.error(f"⚠️ {err_detail or 'Servicio SLI no disponible'}") | |
| else: | |
| try: | |
| err_detail = resp_sli.json().get("detail", {}) | |
| except Exception: | |
| err_detail = {} | |
| if isinstance(err_detail, dict): | |
| st.error(f"Error SLI {resp_sli.status_code}: {err_detail.get('message', 'No se pudo consultar el SLI')}") | |
| if err_detail.get("hint"): | |
| st.caption(err_detail.get("hint")) | |
| if err_detail.get("technical"): | |
| with st.expander("Detalle tecnico"): | |
| st.code(err_detail.get("technical")) | |
| else: | |
| st.error(f"Error SLI {resp_sli.status_code}: {err_detail or 'No se pudo consultar el SLI'}") | |
| except requests.exceptions.Timeout: | |
| st.error("⏱️ El SLI tardó demasiado. Intenta de nuevo.") | |
| except Exception as e_sli: | |
| st.error(f"Error: {e_sli}") | |
| with sli_col2: | |
| sli_url_directo = link_sli or f"https://apps.pancanal.com/sli/Licitaciones/LicitacionHeader?rfqId={sli_rfq}" | |
| st.link_button("Ver en SLI", sli_url_directo, use_container_width=True) | |
| with sli_col3: | |
| if st.button("Eliminar", key=f"del_seg_{lic_id}", help="Eliminar del Monitor ACP", use_container_width=True): | |
| db.eliminar_seguimiento(lic_id) | |
| clear_monitor_cache() | |
| st.toast(f"Seguimiento {num_lic} eliminado.", icon="🗑️") | |
| st.rerun() | |
| # --- ASESORÍA ESTRATÉGICA IA --- | |
| st.markdown("<div style='margin-top: 10px;'></div>", unsafe_allow_html=True) | |
| btn_col, clear_btn_col = st.columns([0.7, 0.3]) | |
| with btn_col: | |
| btn_consultar_ia = st.button( | |
| "🎓 Carlos Méndez: Consultar Estrategia", | |
| key=f"ai_carlos_{lic_id}", | |
| use_container_width=True | |
| ) | |
| with clear_btn_col: | |
| btn_clear_ia = st.button( | |
| "🗑️ Limpiar Asesoría", | |
| key=f"clear_ai_carlos_{lic_id}", | |
| use_container_width=True | |
| ) | |
| # Si el usuario hace clic en Consultar | |
| if btn_consultar_ia: | |
| if not st.session_state.gemini_key: | |
| st.error("🔑 Configura tu API Key de Gemini en el panel lateral.") | |
| else: | |
| with st.spinner("🎓 El Asesor Carlos Méndez está evaluando el estatus..."): | |
| try: | |
| hist_seg_str = "" | |
| df_hist_seg = get_historial_seguimiento_cached(lic_id) | |
| if not df_hist_seg.empty: | |
| hist_seg_str = "\n".join([f"- {h['fecha'][:10]}: {h['estado_nuevo']} ({h['nota'] or 'Sin nota'})" for _, h in df_hist_seg.head(5).iterrows()]) | |
| else: | |
| hist_seg_str = "Sin historial de actualizaciones." | |
| alerta_ctx = "" | |
| alerta_enmienda = st.session_state.get(f"sli_alerta_enmienda_{lic_id}") | |
| if alerta_enmienda: | |
| alerta_ctx = f""" | |
| *** ALERTA DE ENMIENDA RECIENTE EN LA ACP *** | |
| - Se detectó un cambio en el pliego o documentos adjuntos en la última consulta. | |
| - Fecha de revisión anterior: {alerta_enmienda.get('rev_antigua','N/A')} | |
| - Nueva fecha de revisión: {alerta_enmienda.get('rev_nueva','N/A')} | |
| - Fecha de cierre anterior: {alerta_enmienda.get('cierre_antiguo','N/A')} | |
| - Nueva fecha de cierre: {alerta_enmienda.get('cierre_nuevo','N/A')} | |
| Debes comentar esta alerta en tu recomendación y dar instrucciones claras de cómo proceder.""" | |
| prompt_seg_ai = f"""Eres Carlos Méndez, Asesor Senior de Procura con 25 años de experiencia en licitaciones del Canal de Panamá (ACP) y Procura B2B. | |
| El analista te pide tu opinión experta sobre esta licitación activa en el Monitor ACP: | |
| INFORMACIÓN DE LA LICITACIÓN: | |
| - RFQ / Número: {num_lic} | |
| - Objeto: {objeto_safe} | |
| - Responsable: {resp_safe} | |
| - Estado Actual: {estado} | |
| - Días desde que se envió la oferta: {dias_txt or "No definido"} | |
| - Monto Ofertado: {moneda} {monto:,.2f} | |
| HISTORIAL DE ACTUALIZACIONES RECIENTES: | |
| {hist_seg_str} | |
| {alerta_ctx} | |
| Tu misión es dar un análisis estratégico súper corto, ultra-accionable y con lenguaje de mentor (en español). | |
| Estructura tu respuesta en este formato exacto: | |
| 🎓 **Estrategia para Licitación {num_lic}** | |
| - **Diagnóstico del Estatus**: [1 frase sobre qué significa esta fase o el retraso actual en la ACP, comentando brevemente la enmienda si existe] | |
| - **Acción Inmediata**: [1 paso concreto que el analista debe hacer HOY. Ej: Extender fianza de propuesta, preparar subsanación técnica o monitorear acta de apertura] | |
| - **Alerta de Riesgo**: [Qué podría salir mal si no se actúa y cómo mitigarlo] | |
| Sé directo y profesional, escribe máximo 100 palabras en total. ¡Usa tu amplia experiencia con la ACP!""" | |
| st.session_state[f"sli_analisis_ia_{lic_id}"] = gemini_generate_text(st.session_state.gemini_key, prompt_seg_ai) | |
| except Exception as e: | |
| st.error(f"❌ Error al consultar al Asesor: {e}") | |
| # Si el usuario hace clic en Limpiar | |
| if btn_clear_ia: | |
| st.session_state.pop(f"sli_analisis_ia_{lic_id}", None) | |
| st.rerun() | |
| # Mostrar análisis si existe | |
| analisis_ia = st.session_state.get(f"sli_analisis_ia_{lic_id}") | |
| if analisis_ia: | |
| st.markdown(f""" | |
| <div style="background: #0E1624; border: 1px solid #1E2A3A; border-left: 3px solid #10B981; | |
| padding: 12px; border-radius: 8px; margin-top: 10px; margin-bottom: 10px;"> | |
| <div style="color: #31C48D; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 5px;"> | |
| 🎓 Consejo Estratégico de Carlos Méndez, CPSM | |
| </div> | |
| <div style="color: #E6EDF3; font-size: 12.5px; line-height: 1.45;"> | |
| {analisis_ia} | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Historial colapsable | |
| df_hist_seg = get_historial_seguimiento_cached(lic_id) | |
| if not df_hist_seg.empty: | |
| with st.expander(f"📋 Historial ({len(df_hist_seg)} actualizaciones)"): | |
| for _, h in df_hist_seg.iterrows(): | |
| h_emoji, _, h_color = ESTADO_CONFIG.get(h['estado_nuevo'], ("🔵","","#58A6FF")) | |
| st.markdown(f""" | |
| <div style='border-left:3px solid {h_color};padding:6px 12px;margin-bottom:6px;'> | |
| <div style='font-size:11px;color:#484f58;'>{h['fecha'][:16]} — {h['registrado_por']}</div> | |
| <div style='font-size:13px;color:#F0F6FC;'>{h_emoji} <b>{h['estado_nuevo']}</b></div> | |
| <div style='font-size:12px;color:#8B949E;'>{h['nota'] or ''}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown("---") | |
| if active_view == "📚 Base de Conocimiento": | |
| render_page_header( | |
| "Memoria operativa", | |
| "Base de Conocimiento", | |
| "Workspaces guardados, historial de análisis y documentos reutilizables por el equipo.", | |
| ["Workspaces", "Historial", st.session_state.role], | |
| ) | |
| df_history = get_user_history(st.session_state.username) | |
| # --- STATS GLOBALES --- | |
| total_lic_h = len(df_history) | |
| total_reng_h = int(df_history['Renglones'].sum()) if not df_history.empty and 'Renglones' in df_history.columns else 0 | |
| ultima_h = df_history['Fecha Proceso'].iloc[0][:10] if not df_history.empty else "—" | |
| hk1, hk2, hk3 = st.columns(3) | |
| hk1.metric("Total Licitaciones Procesadas", total_lic_h) | |
| hk2.metric("Total Renglones Analizados", total_reng_h) | |
| hk3.metric("Última Actividad", ultima_h) | |
| st.divider() | |
| if df_history.empty: | |
| render_empty_state("Sin registros aún", "Procesa tu primer pliego para comenzar a construir el historial operativo.") | |
| else: | |
| # --- BUSCADOR --- | |
| busqueda = st.text_input("🔍 Buscar por número de licitación", placeholder="Ej: ACP-2024-001", label_visibility="collapsed") | |
| df_filtrado = df_history[df_history['Nº Licitación'].astype(str).str.contains(busqueda, case=False, na=False)] if busqueda else df_history | |
| col_tabla, col_acciones = st.columns([0.65, 0.35]) | |
| with col_tabla: | |
| if df_filtrado.empty: | |
| render_empty_state("Sin coincidencias", "Ajusta la búsqueda para encontrar licitaciones procesadas.") | |
| else: | |
| render_table_toolbar( | |
| "Historial de análisis", | |
| f"{len(df_filtrado)} de {len(df_history)} licitaciones visibles.", | |
| [st.session_state.role, "Workspaces"], | |
| ) | |
| ev_hist = st.dataframe( | |
| df_filtrado, | |
| use_container_width=True, | |
| hide_index=True, | |
| on_select="rerun", | |
| selection_mode="single-row", | |
| column_config={ | |
| "Nº Licitación": st.column_config.TextColumn("Nº Licitación", width="medium"), | |
| "Fecha Proceso": st.column_config.TextColumn("Fecha", width="medium"), | |
| "Renglones": st.column_config.NumberColumn("Renglones", width="small"), | |
| } | |
| ) | |
| with col_acciones: | |
| st.caption("Acciones") | |
| # --- WORKSPACES GUARDADOS --- | |
| es_gerencia = st.session_state.role == "Gerencia" | |
| df_ws_list = get_all_workspaces_cached(st.session_state.username, all_users=es_gerencia) | |
| if df_ws_list.empty: | |
| st.info("No hay workspaces guardados.") | |
| else: | |
| st.caption(f"{'Todos los workspaces del equipo' if es_gerencia else 'Mis workspaces guardados'} ({len(df_ws_list)})") | |
| for _, ws_row in df_ws_list.iterrows(): | |
| ws_lic = ws_row['licitacion'] | |
| ws_user = ws_row.get('username', st.session_state.username) | |
| ws_fecha = ws_row.get('fecha_guardado', '')[:10] | |
| wc1, wc2, wc3 = st.columns([0.5, 0.25, 0.25]) | |
| with wc1: | |
| st.markdown(f"<div style='font-size:12px;color:#F0F6FC;padding-top:6px;'><b>{ws_lic}</b><br><span style='color:#484f58;font-size:10px;'>{ws_user} · {ws_fecha}</span></div>", unsafe_allow_html=True) | |
| with wc2: | |
| if st.button("Cargar", key=f"ws_load_{ws_user}_{ws_lic}", use_container_width=True): | |
| df_ws_load, cg_ws_load = load_workspace_by_licitacion(ws_user, ws_lic) | |
| if df_ws_load is not None: | |
| st.session_state.df_exportar = df_ws_load | |
| st.session_state.cg = cg_ws_load | |
| st.session_state.procesado = True | |
| st.toast(f"✅ Workspace '{ws_lic}' cargado.", icon="🔄") | |
| st.rerun() | |
| with wc3: | |
| if st.button("🗑️", key=f"ws_del_{ws_user}_{ws_lic}", help="Eliminar workspace"): | |
| db.delete_workspace(ws_user, ws_lic) | |
| get_all_workspaces_cached.clear() | |
| st.toast(f"Workspace '{ws_lic}' eliminado.") | |
| st.rerun() | |
| st.divider() | |
| # Si seleccionaron una fila — ver sus correos | |
| if len(ev_hist.selection.rows) > 0: | |
| fila_hist = df_filtrado.iloc[ev_hist.selection.rows[0]] | |
| lic_sel = str(fila_hist.get('Nº Licitación', '')) | |
| num_sel = "".join(re.findall(r'\d+', lic_sel)) | |
| st.markdown(f""" | |
| <div style='background:#161B22; border:1px solid #238636; border-radius:10px; padding:14px; margin-bottom:12px;'> | |
| <div style='font-size:11px; color:#3fb950; text-transform:uppercase; letter-spacing:1px;'>Seleccionada</div> | |
| <div style='font-size:14px; font-weight:700; color:#F0F6FC; margin-top:4px;'>{lic_sel}</div> | |
| <div style='font-size:11px; color:#8B949E;'>{int(fila_hist.get('Renglones', 0))} renglones procesados</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| df_correos_hist = obtener_correos_licitacion(num_sel) | |
| n_correos = len(df_correos_hist) | |
| st.metric("Correos en Bandeja", n_correos) | |
| if n_correos > 0: | |
| with st.expander(f"📥 Ver {n_correos} correo(s) de esta licitación"): | |
| for _, ch in df_correos_hist.iterrows(): | |
| st.markdown(f""" | |
| <div class='email-card'> | |
| <div style='display:flex;justify-content:space-between;color:#8B949E;font-size:11px;margin-bottom:6px;'> | |
| <strong>{ch.get('remitente','')[:40]}</strong> | |
| <span>{ch.get('fecha','')}</span> | |
| </div> | |
| <div style='color:#E0E0E0;font-size:14px;font-weight:600;margin-bottom:6px;'>{ch.get('asunto','')}</div> | |
| <div style='color:#C9D1D9;font-size:12px;'>💡 {ch.get('resumen','')}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| if st.button("🗑️ Eliminar este Registro", key=f"del_hist_{lic_sel}", use_container_width=True): | |
| db.delete_history_entry(st.session_state.username, lic_sel) | |
| get_user_history.clear() | |
| st.toast(f"Registro {lic_sel} eliminado.", icon="🗑️") | |
| st.rerun() | |
| st.divider() | |
| # Exportar historial completo | |
| buf_hist = io.BytesIO() | |
| df_history.to_excel(buf_hist, index=False, engine='openpyxl') | |
| col_exp, col_del = st.columns([0.7, 0.3]) | |
| with col_exp: | |
| st.download_button("📥 Exportar Historial a Excel", data=buf_hist.getvalue(), | |
| file_name="Historial_PROCURA.xlsx", | |
| mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| use_container_width=True) | |
| with col_del: | |
| if st.button("⚠️ Borrar Todo", use_container_width=True, type="secondary"): | |
| db.clear_all_history(st.session_state.username) | |
| get_user_history.clear() | |
| st.toast("Historial borrado completamente.", icon="✅") | |
| st.rerun() | |
| if active_view == "🚀 Tablero de Operaciones": | |
| if not st.session_state.procesado: | |
| # --- KPIs REALES DEL SISTEMA --- | |
| df_hist_kpi = get_user_history(st.session_state.username) | |
| total_lic = len(df_hist_kpi) | |
| total_reng = int(df_hist_kpi['Renglones'].sum()) if not df_hist_kpi.empty and 'Renglones' in df_hist_kpi.columns else 0 | |
| ultima_act = df_hist_kpi['Fecha Proceso'].iloc[0][:10] if not df_hist_kpi.empty else "Sin actividad" | |
| try: | |
| seg_kpi_df = get_seguimientos_cached() | |
| total_seg_kpi = len(seg_kpi_df) | |
| en_eval_kpi = len(seg_kpi_df[seg_kpi_df["estado"].isin(["Oferta Enviada al SLI", "Cumple Tecnicamente", "En Evaluacion Economica"])]) if not seg_kpi_df.empty else 0 | |
| except Exception: | |
| total_seg_kpi = 0 | |
| en_eval_kpi = 0 | |
| try: | |
| radar_kpi_df = add_radar_date_columns(get_radar_cached()) | |
| radar_abiertas = len(radar_kpi_df[~radar_kpi_df["_radar_vencida"]]) if not radar_kpi_df.empty and "_radar_vencida" in radar_kpi_df.columns else len(radar_kpi_df) | |
| radar_prioritarias = int(radar_kpi_df["es_prioritaria"].fillna(False).sum()) if not radar_kpi_df.empty and "es_prioritaria" in radar_kpi_df.columns else 0 | |
| except Exception: | |
| radar_abiertas = 0 | |
| radar_prioritarias = 0 | |
| role_profile = get_role_profile() | |
| api_online_now = get_api_health() | |
| hero_rows = [ | |
| ("RFQs analizados", total_lic), | |
| ("Renglones procesados", total_reng), | |
| ("En seguimiento", total_seg_kpi), | |
| ] | |
| if role_can("radar"): | |
| hero_rows = [ | |
| ("Radar abiertas", radar_abiertas), | |
| ("Prioritarias", radar_prioritarias), | |
| ("Monitor activo", en_eval_kpi), | |
| ] | |
| hero_rows_html = "".join( | |
| f"<div><span>{escape(str(label))}</span><b>{escape(str(value))}</b></div>" | |
| for label, value in hero_rows | |
| ) | |
| st.markdown(f""" | |
| <div class="ops-hero"> | |
| <div class="ops-hero-main"> | |
| <div class="page-eyebrow">{escape(role_profile["title"])}</div> | |
| <h1 class="page-title">Procura operativa</h1> | |
| <div class="page-subtitle">Hola {escape(st.session_state.username)}. {escape(role_profile["scope"])}</div> | |
| <div class="ops-hero-pills"> | |
| <span>Rol: {escape(current_role_mode())}</span> | |
| <span>Última actividad: {escape(str(ultima_act))}</span> | |
| <span class="{'pill-ok' if api_online_now else 'pill-alert'}">Motor IA {'activo' if api_online_now else 'apagado'}</span> | |
| </div> | |
| </div> | |
| <div class="ops-hero-side"> | |
| {hero_rows_html} | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| summary_items = [ | |
| {"label": "RFQs procesados", "value": total_lic, "tone": "blue"}, | |
| {"label": "Renglones", "value": total_reng, "tone": "green"}, | |
| {"label": "Seguimiento", "value": total_seg_kpi, "tone": "amber"}, | |
| {"label": "Motor IA", "value": "Activo" if api_online_now else "Apagado", "tone": "green" if api_online_now else "red"}, | |
| ] | |
| if role_can("radar"): | |
| summary_items = [ | |
| {"label": "Radar abiertas", "value": radar_abiertas, "tone": "blue"}, | |
| {"label": "Prioritarias", "value": radar_prioritarias, "tone": "amber"}, | |
| {"label": "Monitor activo", "value": en_eval_kpi, "tone": "green"}, | |
| {"label": "RFQs procesados", "value": total_lic, "tone": "blue"}, | |
| ] | |
| if role_can("historico"): | |
| summary_items.append({"label": "Histórico Supabase", "value": f"{get_historico_count_cached():,}", "tone": "blue"}) | |
| render_summary_strip(summary_items) | |
| st.markdown("<div class='section-title'>Rutas de trabajo</div>", unsafe_allow_html=True) | |
| route_cards = [ | |
| ("Analizar RFQ", "Carga de pliego y anexos desde el panel lateral.", "Listo", None), | |
| ("Proveedores", "Sourcing por renglón con evidencia y señales de precio.", "Abrir", "🌐 Proveedores"), | |
| ("Seguimiento", "Estados, comentarios y trazabilidad de participación.", "Abrir", "🏛️ Monitor ACP"), | |
| ("Workspaces", "Recuperar análisis guardados y actividad reciente.", "Abrir", "📚 Base de Conocimiento"), | |
| ] | |
| if role_can("radar"): | |
| route_cards.insert(3, ("Radar SLI", "Licitaciones abiertas, prioridad y acción sugerida.", "Abrir", "📡 Radar Supervisor")) | |
| if role_can("historico"): | |
| route_cards.append(("Histórico", "Base corporativa de precios y licitaciones previas.", "Abrir", "📊 Historial Global")) | |
| for start in range(0, len(route_cards), 3): | |
| route_cols = st.columns(min(3, len(route_cards) - start)) | |
| for col, (title, body, action, target_view) in zip(route_cols, route_cards[start:start + 3]): | |
| with col: | |
| st.markdown(f""" | |
| <div class="ops-route-card"> | |
| <div class="route-title">{escape(title)}</div> | |
| <div class="route-body">{escape(body)}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if target_view: | |
| if st.button(action, key=f"route_{target_view}", use_container_width=True): | |
| st.session_state.active_view = target_view | |
| st.query_params["view"] = next((item["key"] for item in navigation_items if item["view"] == target_view), "inicio") | |
| st.rerun() | |
| else: | |
| st.caption("Disponible en el panel lateral.") | |
| st.markdown("<div class='section-title'>Lectura rápida</div>", unsafe_allow_html=True) | |
| d1, d2 = st.columns([0.55, 0.45]) | |
| with d1: | |
| st.markdown(f""" | |
| <div class="work-panel dashboard-panel"> | |
| <div class="panel-label">Prioridad recomendada</div> | |
| <div class="panel-title">RFQ → Proveedores → Seguimiento</div> | |
| <div class="panel-copy">El flujo operativo queda separado por módulos para que cada cargo vea solo las decisiones que le corresponden.</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with d2: | |
| st.markdown(f""" | |
| <div class="work-panel dashboard-panel"> | |
| <div class="panel-label">Estado operativo</div> | |
| <div class="dashboard-stat-row"><span>Licitaciones prioritarias</span><b>{radar_prioritarias if role_can("radar") else "N/A"}</b></div> | |
| <div class="dashboard-stat-row"><span>Monitor activo</span><b>{en_eval_kpi}</b></div> | |
| <div class="dashboard-stat-row"><span>Alcance</span><b>{escape(role_profile["badge"])}</b></div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown("<div class='section-title'>Acceso del rol actual</div>", unsafe_allow_html=True) | |
| render_access_snapshot() | |
| if not df_hist_kpi.empty: | |
| st.markdown("<div class='section-title'>Actividad reciente</div>", unsafe_allow_html=True) | |
| render_table_toolbar("Actividad reciente", "Últimos análisis procesados por el usuario activo.", [st.session_state.username, "Historial"]) | |
| st.dataframe(df_hist_kpi.head(8), use_container_width=True, hide_index=True) | |
| else: | |
| render_empty_state("Sin actividad reciente", "Cuando proceses un RFQ, aparecera aqui como acceso rapido.") | |
| else: | |
| df_render = normalize_item_codes(normalize_technical_fields(normalize_history_columns(st.session_state.df_exportar))) | |
| cg_render = st.session_state.cg if isinstance(st.session_state.cg, dict) else {} | |
| df_render, missing_proposal_rows = apply_proposal_scope_from_cg(df_render, cg_render) | |
| if "acepta_equivalente" in df_render.columns: | |
| df_render["equivalente_txt"] = df_render["acepta_equivalente"].apply(bool_label) | |
| result_views = ["📋 1. Matriz de Productos", "📨 2. Emisión de RFQs", "🤖 3. Centro de Mando AI", "📈 4. Análisis de Costos"] | |
| if role_can("logistics_calc"): | |
| result_views.append("🚚 5. Costos Logísticos") | |
| result_view = st.radio( | |
| "Vista de licitacion", | |
| result_views, | |
| horizontal=True, | |
| label_visibility="collapsed", | |
| key="result_view", | |
| ) | |
| if result_view == "📋 1. Matriz de Productos": | |
| numero_licitacion = first_doc_value(cg_render, ["numero_licitacion", "licitacion", "numero_de_licitacion"]) | |
| lugar_entrega = first_doc_value(cg_render, ["lugar_de_entrega", "lugar_entrega", "sitio_entrega"]) | |
| tiempo_entrega = first_doc_value(cg_render, ["tiempo_de_entrega_global", "tiempo_entrega", "plazo_entrega"]) | |
| garantia_exigida = first_doc_value(cg_render, ["garantia_exigida", "garantia", "garantias"]) | |
| propuesta_tecnica = first_doc_value(cg_render, ["propuesta_tecnica_requerida"]) | |
| validez_oferta = first_doc_value(cg_render, ["validez_de_la_oferta", "validez_oferta"]) | |
| encargado_licitacion = first_doc_value(cg_render, [ | |
| "persona_encargada_licitacion", | |
| "persona_encargada", | |
| "agente_de_compras", | |
| "comprador", | |
| "responsable_licitacion", | |
| ]) | |
| correo_encargado = first_doc_value(cg_render, [ | |
| "correo_encargado_licitacion", | |
| "email_encargado_licitacion", | |
| "correo_encargado", | |
| "correo_contacto", | |
| "email", | |
| ]) | |
| telefono_encargado = first_doc_value(cg_render, [ | |
| "telefono_encargado_licitacion", | |
| "telefono_encargado", | |
| "telefono_contacto", | |
| "telefono", | |
| ]) | |
| presencia_local, participacion_sugerida, presencia_nota, presencia_tone = get_local_presence_decision(cg_render) | |
| evidencia_presencia = first_doc_value(cg_render, ["evidencia_presencia_local"], default="") | |
| evidencia_presencia_html = ( | |
| f"<small>{escape(evidencia_presencia)}</small>" if evidencia_presencia else "" | |
| ) | |
| render_table_toolbar( | |
| "Información de la licitación", | |
| "Datos críticos extraídos del pliego para ordenar la evaluación antes de cotizar.", | |
| ["Pliego", "Contacto ACP", participacion_sugerida], | |
| ) | |
| st.markdown(f""" | |
| <div class="bid-info-band"> | |
| <div class="rfq-summary-grid bid-info-grid"> | |
| <div class="rfq-summary-tile"> | |
| <span>Nº Licitación</span> | |
| <b>{escape(numero_licitacion)}</b> | |
| </div> | |
| <div class="rfq-summary-tile"> | |
| <span>Garantía</span> | |
| <b>{escape(garantia_exigida)}</b> | |
| </div> | |
| <div class="rfq-summary-tile"> | |
| <span>Lugar Entrega</span> | |
| <b>{escape(lugar_entrega)}</b> | |
| </div> | |
| <div class="rfq-summary-tile"> | |
| <span>Tiempo Entrega</span> | |
| <b>{escape(tiempo_entrega)}</b> | |
| </div> | |
| <div class="rfq-summary-tile"> | |
| <span>Req. Prop. Técnica</span> | |
| <b>{escape(propuesta_tecnica)}</b> | |
| </div> | |
| <div class="rfq-summary-tile"> | |
| <span>Validez de Oferta</span> | |
| <b>{escape(validez_oferta)}</b> | |
| </div> | |
| </div> | |
| <div class="rfq-summary-grid bid-info-grid bid-contact-grid"> | |
| <div class="rfq-summary-tile"> | |
| <span>Encargado ACP</span> | |
| <b>{escape(encargado_licitacion)}</b> | |
| </div> | |
| <div class="rfq-summary-tile"> | |
| <span>Correo</span> | |
| <b>{escape(correo_encargado)}</b> | |
| </div> | |
| <div class="rfq-summary-tile"> | |
| <span>Teléfono</span> | |
| <b>{escape(telefono_encargado)}</b> | |
| </div> | |
| <div class="rfq-summary-tile {presencia_tone}"> | |
| <span>Presencia local</span> | |
| <b>{escape(presencia_local)}</b>{evidencia_presencia_html} | |
| </div> | |
| <div class="rfq-summary-tile decision-card {presencia_tone}"> | |
| <span>Empresa sugerida</span> | |
| <b>{escape(participacion_sugerida)}</b> | |
| <small>{escape(presencia_nota)}</small> | |
| </div> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.divider() | |
| restriccion = cg_render.get("restriccion_marca_proveedor") | |
| evidencia_restr = cg_render.get("evidencia_restricciones", "") | |
| permite_equiv = coerce_optional_bool(cg_render.get("permite_equivalentes", None)) | |
| carta_obsol = coerce_bool(cg_render.get("permite_carta_obsolescencia", False), default=False) | |
| riesgo_global = str(cg_render.get("riesgo_tecnico_global", "Bajo") or "Bajo") | |
| req_prop_count = int(df_render["requiere_propuesta_tecnica"].fillna(False).sum()) if "requiere_propuesta_tecnica" in df_render.columns else 0 | |
| req_ficha_count = int(df_render["requiere_ficha_tecnica"].fillna(False).sum()) if "requiere_ficha_tecnica" in df_render.columns else 0 | |
| obsol_count = int(df_render["posible_obsolescencia"].fillna(False).sum()) if "posible_obsolescencia" in df_render.columns else 0 | |
| marca_count = int(df_render["marca_modelo_requerido"].apply(is_meaningful_text).sum()) if "marca_modelo_requerido" in df_render.columns else 0 | |
| tech_cols = st.columns(5) | |
| tech_cols[0].metric("Riesgo técnico", riesgo_global) | |
| tech_cols[1].metric("Propuesta técnica", req_prop_count) | |
| tech_cols[2].metric("Ficha/catálogo", req_ficha_count) | |
| tech_cols[3].metric("Marca/modelo exigido", marca_count) | |
| tech_cols[4].metric("Posible obsolescencia", obsol_count) | |
| if is_meaningful_text(restriccion): | |
| st.warning(f"Restricción de marca/proveedor detectada: {restriccion}") | |
| if permite_equiv is False: | |
| st.error("El pliego no parece permitir equivalentes. Validar restricción antes de participar.") | |
| elif permite_equiv is True: | |
| st.success("El pliego permite equivalentes o alternativas técnicas.") | |
| if carta_obsol: | |
| st.info("El pliego permite carta de fabricante para actualización de números de parte obsoletos.") | |
| if missing_proposal_rows: | |
| st.warning( | |
| "La evidencia de propuesta técnica menciona línea(s) " | |
| f"{', '.join(missing_proposal_rows)}, pero no aparecen como renglones en la matriz extraída. " | |
| "Conviene revisar si falta un renglón del PDF." | |
| ) | |
| render_analysis_state(cg_render, df_render, missing_proposal_rows, current_role_mode()) | |
| evidencia_prop = cg_render.get("evidencia_propuesta_tecnica", "") | |
| if is_meaningful_text(evidencia_prop): | |
| with st.expander("Ver evidencia de propuesta técnica"): | |
| st.write(evidencia_prop) | |
| if is_meaningful_text(evidencia_restr): | |
| with st.expander("Ver evidencia técnica general"): | |
| st.write(evidencia_restr) | |
| df_items_view = pd.DataFrame(index=df_render.index) | |
| df_items_view["Renglón"] = df_render["renglon"].astype(str) if "renglon" in df_render.columns else "" | |
| df_items_view["Código ACP"] = df_render["codigo_articulo"].astype(str) if "codigo_articulo" in df_render.columns else "" | |
| df_items_view["Cant."] = pd.to_numeric(df_render["cantidad"], errors="coerce") if "cantidad" in df_render.columns else None | |
| df_items_view["Descripción / búsqueda"] = df_render["termino_de_busqueda_corto"].fillna("").astype(str) if "termino_de_busqueda_corto" in df_render.columns else "" | |
| df_items_view["Prop. técnica"] = df_render["requiere_propuesta_tecnica"].fillna(False).apply(lambda v: "Sí" if bool(v) else "No") | |
| df_items_view["Ficha/catálogo adj."] = df_render["requiere_ficha_tecnica"].fillna(False).apply(lambda v: "Sí" if bool(v) else "No") | |
| df_items_view["Marca / restricción"] = df_render["marca_modelo_requerido"].apply(lambda v: str(v) if is_meaningful_text(v) else "No especificado") | |
| df_items_view["Equivalentes"] = df_render["acepta_equivalente"].apply(bool_label) if "acepta_equivalente" in df_render.columns else "No determinado" | |
| df_items_view["Obsolescencia"] = df_render["posible_obsolescencia"].fillna(False).apply(lambda v: "Revisar" if bool(v) else "No") | |
| col_config = { | |
| "Renglón": st.column_config.TextColumn("Renglón", width="small"), | |
| "Código ACP": st.column_config.TextColumn("Código ACP", width="medium"), | |
| "Cant.": st.column_config.NumberColumn("Cant.", format="%d", width="small"), | |
| "Descripción / búsqueda": st.column_config.TextColumn("Descripción / búsqueda", width="large"), | |
| "Prop. técnica": st.column_config.TextColumn("Prop. técnica", width="small"), | |
| "Ficha/catálogo adj.": st.column_config.TextColumn("Ficha/catálogo adj.", width="small"), | |
| "Marca / restricción": st.column_config.TextColumn("Marca / restricción", width="medium"), | |
| "Equivalentes": st.column_config.TextColumn("Equivalentes", width="small"), | |
| "Obsolescencia": st.column_config.TextColumn("Obsolescencia", width="small"), | |
| } | |
| render_table_toolbar( | |
| "Matriz de renglones", | |
| "Vista general del pliego procesado. Usa el selector inferior para abrir el detalle técnico de cada renglón.", | |
| [f"{len(df_items_view)} renglones", "Costos: pestaña 4"], | |
| ) | |
| col_exp, col_xls = st.columns([0.7, 0.3]) | |
| with col_exp: | |
| st.caption("Vista operativa del pliego procesado.") | |
| with col_xls: | |
| _buf = io.BytesIO() | |
| df_render.to_excel(_buf, index=False, engine='openpyxl') | |
| st.download_button("Exportar Excel", data=_buf.getvalue(), file_name=f"Licitacion_{cg_render.get('numero_licitacion','')}.xlsx", mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", use_container_width=True) | |
| st.dataframe(df_items_view, column_config=col_config, use_container_width=True, hide_index=True) | |
| def row_detail_label(row_idx): | |
| row = df_render.loc[row_idx] | |
| renglon = clean_doc_value(row.get("renglon", ""), default="-") | |
| codigo = clean_doc_value(row.get("codigo_articulo", ""), default="S/C") | |
| term = clean_doc_value(row.get("termino_de_busqueda_corto", ""), default="Sin descripción") | |
| term = term if len(term) <= 88 else f"{term[:85]}..." | |
| prop = "Prop. técnica" if coerce_bool(row.get("requiere_propuesta_tecnica", False), default=False) else "Sin prop. técnica" | |
| return f"Renglón {renglon} | {codigo} | {prop} | {term}" | |
| selected_row_idx = None | |
| if not df_render.empty: | |
| render_table_toolbar( | |
| "Detalle del renglón", | |
| "Elige el renglón a revisar. La tabla superior queda como vista general.", | |
| ["Checklist", "Evidencia", "Proveedores"], | |
| ) | |
| row_options = list(df_render.index) | |
| selected_row_idx = st.selectbox( | |
| "Ver detalle del renglón", | |
| row_options, | |
| format_func=row_detail_label, | |
| key="matrix_detail_row_idx", | |
| label_visibility="collapsed", | |
| ) | |
| if selected_row_idx is not None: | |
| fila = df_render.loc[selected_row_idx] | |
| lic_hist = fila.get("licitacion_hist", "") | |
| anio_hist = fila.get("anio_hist", "") | |
| precio_comp = fila.get("precio_comp_hist", None) | |
| precio_proy = fila.get("precio_proy_hist", None) | |
| hist_source = "Sin referencia histórica en Supabase" | |
| if pd.notna(precio_comp) or pd.notna(precio_proy): | |
| try: | |
| anio_hist_txt = str(int(float(anio_hist))) | |
| except Exception: | |
| anio_hist_txt = "Año N/A" | |
| hist_source = f"Histórico Supabase: licitación {lic_hist or 'N/A'} ({anio_hist_txt})" | |
| requiere_prop = coerce_bool(fila.get("requiere_propuesta_tecnica", False), default=False) | |
| requiere_ficha = coerce_bool(fila.get("requiere_ficha_tecnica", False), default=False) | |
| marca_modelo = fila.get("marca_modelo_requerido", None) | |
| acepta_equiv = coerce_optional_bool(fila.get("acepta_equivalente", None)) | |
| posible_obsol = coerce_bool(fila.get("posible_obsolescencia", False), default=False) | |
| evidencia_item = fila.get("evidencia_tecnica", "") | |
| row_flags = [] | |
| if requiere_prop: | |
| row_flags.append("Requiere propuesta técnica") | |
| if requiere_ficha: | |
| row_flags.append("Requiere ficha/catálogo adjunto") | |
| if is_meaningful_text(marca_modelo): | |
| row_flags.append(f"Marca/modelo: {marca_modelo}") | |
| if acepta_equiv is False: | |
| row_flags.append("No acepta equivalente") | |
| elif acepta_equiv is True: | |
| row_flags.append("Acepta equivalente") | |
| if posible_obsol: | |
| row_flags.append("Posible obsolescencia/actualización") | |
| row_flags_text = " | ".join(row_flags) if row_flags else "Sin alertas técnicas por renglón" | |
| texto_ficha = strip_html_markup(fila.get('ficha_tecnica_completa', 'Sin descripción')) or "Sin descripción" | |
| texto_ficha_html = escape(texto_ficha).replace("\n", "<br>") | |
| status_checklist = build_checklist_html([ | |
| { | |
| "label": "Cantidad solicitada", | |
| "value": str(fila.get('cantidad', 'N/A')), | |
| "state": "neutral", | |
| }, | |
| { | |
| "label": "Propuesta técnica", | |
| "value": "Requerida" if requiere_prop else "No requerida en este renglón", | |
| "state": "ok" if requiere_prop else "neutral", | |
| }, | |
| { | |
| "label": "Ficha/catálogo adjunto", | |
| "value": "Requerido" if requiere_ficha else "No pedido aparte", | |
| "state": "warn" if requiere_ficha else "neutral", | |
| }, | |
| { | |
| "label": "Marca / proveedor", | |
| "value": str(marca_modelo) if is_meaningful_text(marca_modelo) else "No especificado", | |
| "state": "warn" if is_meaningful_text(marca_modelo) else "neutral", | |
| }, | |
| { | |
| "label": "Equivalentes", | |
| "value": bool_label(acepta_equiv), | |
| "state": "ok" if acepta_equiv is True else ("warn" if acepta_equiv is False else "neutral"), | |
| }, | |
| { | |
| "label": "Obsolescencia", | |
| "value": "Revisar actualización de parte" if posible_obsol else "Sin alerta detectada", | |
| "state": "warn" if posible_obsol else "neutral", | |
| }, | |
| ]) | |
| spec_checklist = spec_text_to_checklist_html(texto_ficha) | |
| st.markdown(f""" | |
| <div class="item-detail-panel"> | |
| <div class="item-detail-head"> | |
| <div> | |
| <div class="selected-kicker">Renglón seleccionado</div> | |
| <div class="selected-title">Renglón {escape(str(fila.get('renglon', '-')))} | {escape(str(fila.get('codigo_articulo', 'N/A')))}</div> | |
| <div class="selected-subtitle">{escape(hist_source)}</div> | |
| </div> | |
| </div> | |
| <div class="checklist-title">Checklist del renglón</div> | |
| {status_checklist} | |
| <div class="item-warning-line">{escape(row_flags_text)}</div> | |
| <div class="checklist-title">Especificaciones detectadas</div> | |
| <div class="technical-spec-box checklist-mode">{spec_checklist}</div> | |
| <div class="checklist-title">Texto técnico completo del renglón</div> | |
| <div class="technical-spec-box text-mode">{texto_ficha_html}</div> | |
| <div class="future-note"> | |
| Fichas técnicas: pendiente para la próxima fase, conectada a búsqueda de datasheets, validación técnica y generación controlada. | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if is_meaningful_text(evidencia_item): | |
| with st.expander("Evidencia técnica del renglón"): | |
| st.write(evidencia_item) | |
| if st.button(f"Buscar proveedores para renglón {fila.get('renglon')}", type="primary", use_container_width=True): | |
| try: | |
| st.session_state.sourcing_selected_row = row_options.index(selected_row_idx) | |
| except Exception: | |
| st.session_state.sourcing_selected_row = 0 | |
| st.session_state.sourcing_scope = "Un renglón" | |
| st.session_state.active_view = "🌐 Proveedores" | |
| st.rerun() | |
| if result_view == "📨 2. Emisión de RFQs": | |
| # ================================================================ | |
| # 📨 MOTOR DE RFQs PROFESIONALES — Powered by Gemini AI | |
| # ================================================================ | |
| st.markdown(""" | |
| <div style="background: linear-gradient(135deg, #1a1f2e 0%, #161B22 100%); | |
| border: 1px solid #30363D; border-left: 4px solid #238636; | |
| border-radius: 12px; padding: 20px; margin-bottom: 24px;"> | |
| <h3 style="color:#3FB950; margin:0 0 6px 0; font-family:'Inter',sans-serif;">📨 Motor de RFQs Profesionales</h3> | |
| <p style="color:#8B949E; margin:0; font-size:14px;"> | |
| Gemini genera automáticamente el cuerpo del RFQ con la tabla de cumplimiento técnico, | |
| los datos de la ACP (validez, entrega, garantía) y el Excel de evaluación de proveedores adjunto. | |
| </p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Configuración del RFQ | |
| col_rfq_cfg1, col_rfq_cfg2, col_rfq_cfg3 = st.columns(3) | |
| with col_rfq_cfg1: | |
| idioma_rfq = st.selectbox("🌍 Idioma del RFQ", ["Inglés (Internacional)", "Español (Local)"], key="rfq_idioma") | |
| with col_rfq_cfg2: | |
| nombre_contacto = st.text_input("👤 Nombre del contacto", value="Gabriel Rodriguez", key="rfq_nombre") | |
| with col_rfq_cfg3: | |
| empresa_contacto = st.text_input("🏢 Empresa", value="Proyelec International", key="rfq_empresa") | |
| # Selección de renglón a procesar | |
| st.markdown("---") | |
| opciones_renglon = ["📦 Todos los renglones (Master RFQ)"] + [ | |
| f"Renglón {r.get('renglon')} — {r.get('termino_de_busqueda_corto','')}" | |
| for _, r in df_render.iterrows() | |
| ] | |
| renglon_seleccionado = st.selectbox("🎯 Generar RFQ para:", opciones_renglon, key="rfq_renglon_sel") | |
| btn_gen_rfq = st.button("⚡ Generar RFQ con IA", type="primary", use_container_width=False, key="btn_gen_rfq") | |
| st.markdown("---") | |
| if btn_gen_rfq: | |
| if not st.session_state.gemini_key: | |
| st.error("🔑 Configura tu API Key de Gemini en el panel lateral.") | |
| else: | |
| with st.spinner("📝 Gemini está redactando el RFQ profesional..."): | |
| try: | |
| cg = cg_render | |
| idioma = "English" if "Inglés" in idioma_rfq else "Español" | |
| # Filtrar renglones según selección | |
| if "Todos" in renglon_seleccionado: | |
| items_para_rfq = df_render.to_dict('records') | |
| else: | |
| num_sel = renglon_seleccionado.split("Renglón ")[1].split(" —")[0] | |
| items_para_rfq = [r for _, r in df_render.iterrows() if str(r.get('renglon','')) == num_sel] | |
| # Construir contexto de renglones para el prompt | |
| items_ctx = "" | |
| for item in items_para_rfq: | |
| items_ctx += f""" | |
| ITEM – Renglón {item.get('renglon','')} | Código: {item.get('codigo_articulo','')} | |
| Descripción: {item.get('termino_de_busqueda_corto','')} | |
| Cantidad: {item.get('cantidad','')} {item.get('unidad_de_medida','')} | |
| Especificaciones técnicas: | |
| {str(item.get('ficha_tecnica_completa',''))[:800]} | |
| ---""" | |
| prompt_rfq = f"""You are a professional procurement specialist writing a formal Request for Quotation (RFQ) for Proyelec International. | |
| LANGUAGE: Write the entire RFQ in {idioma}. | |
| ACP BID CONTEXT: | |
| - Bid Number: {cg.get('numero_licitacion','N/A')} | |
| - Delivery Location: {cg.get('lugar_de_entrega','N/A')} | |
| - Global Lead Time Required: {cg.get('tiempo_de_entrega_global','N/A')} | |
| - Offer Validity Required by ACP: {cg.get('validez_de_la_oferta','N/A')} | |
| - Warranty Required: {cg.get('garantia_exigida','N/A')} | |
| - Technical Proposal Required: {cg.get('propuesta_tecnica_requerida','N/A')} | |
| CONTACT: | |
| - Name: {nombre_contacto} | |
| - Company: {empresa_contacto} | |
| ITEMS TO QUOTE: | |
| {items_ctx} | |
| INSTRUCTIONS: | |
| Generate a complete, professional RFQ email body following EXACTLY this structure: | |
| 1. Professional greeting and introduction (2-3 sentences about Proyelec and the project context) | |
| 2. For EACH item, create: | |
| - Item header with code, description and quantity | |
| - A compliance table with columns: Requirement | Comply (Y/N) | Comments / Model Reference | |
| - Extract 6-10 specific technical requirements from the spec sheet above for the table rows | |
| - Add rows for: Technical datasheet available, Product is NEW condition, Complies with lead time of {cg.get('tiempo_de_entrega_global','N/A')}, Offer validity of {cg.get('validez_de_la_oferta','N/A')}, Warranty of {cg.get('garantia_exigida','N/A')} | |
| 3. Commercial & Logistics Information section requesting: unit pricing, Incoterms (EXW/FOB preferred), lead time, stock availability, country of origin, warranty info, packing dimensions, volume/project pricing | |
| 4. Note about alternative models being accepted if fully compliant | |
| 5. Note that a Supplier Evaluation Form is attached and MUST be completed and returned | |
| 6. Professional closing signed by {nombre_contacto} / {empresa_contacto} | |
| Use professional business English/Spanish. Format tables using plain text dashes and pipes (ASCII art tables, NOT markdown) since this will go into an email.""" | |
| cuerpo_rfq = gemini_generate_text(st.session_state.gemini_key, prompt_rfq) | |
| # Guardar en session state para poder editar y descargar | |
| st.session_state['rfq_generado'] = cuerpo_rfq | |
| st.session_state['rfq_editor'] = cuerpo_rfq | |
| st.session_state['rfq_subject'] = f"[PROY-ACP-{cg.get('numero_licitacion','')}] Request for Quotation — {renglon_seleccionado}" | |
| except Exception as e: | |
| st.error(f"❌ Error generando RFQ: {e}") | |
| # Mostrar el RFQ generado (si existe) | |
| if 'rfq_generado' in st.session_state and st.session_state.rfq_generado: | |
| st.success("RFQ generado. Revisa la vista previa, ajusta el texto y exporta el correo.") | |
| if "rfq_editor" not in st.session_state: | |
| st.session_state["rfq_editor"] = st.session_state.rfq_generado | |
| rfq_subject_value = st.session_state.get('rfq_subject', 'Request for Quotation') | |
| rfq_meta_1, rfq_meta_2, rfq_meta_3 = st.columns([0.44, 0.28, 0.28]) | |
| with rfq_meta_1: | |
| rfq_subject_edit = st.text_input("Asunto", value=rfq_subject_value, key="rfq_subject_editor") | |
| with rfq_meta_2: | |
| rfq_to = st.text_input("Para", placeholder="supplier@example.com", key="rfq_to") | |
| with rfq_meta_3: | |
| rfq_tone = st.selectbox( | |
| "Tono", | |
| ["Formal internacional", "Urgente", "Proveedor nuevo", "Proveedor conocido"], | |
| key="rfq_tone", | |
| ) | |
| rfq_meta_4, rfq_meta_5, rfq_meta_6 = st.columns(3) | |
| with rfq_meta_4: | |
| rfq_reply_by = st.date_input("Fecha limite proveedor", value=datetime.now().date(), key="rfq_reply_by") | |
| with rfq_meta_5: | |
| rfq_lead_time = st.text_input("Lead time requerido", value=str(cg_render.get("tiempo_de_entrega_global", "N/A")), key="rfq_lead_time") | |
| with rfq_meta_6: | |
| rfq_payment_terms = st.selectbox( | |
| "Credito solicitado", | |
| ["Net 30 o superior", "Net 45 si aplica", "Pago segun negociacion", "Contra entrega"], | |
| key="rfq_payment_terms", | |
| ) | |
| rfq_meta = [ | |
| ("To", rfq_to or "Pendiente"), | |
| ("Bid", cg_render.get("numero_licitacion", "N/A")), | |
| ("Selected scope", renglon_seleccionado), | |
| ("Reply by", rfq_reply_by), | |
| ("Required lead time", rfq_lead_time), | |
| ("Payment request", rfq_payment_terms), | |
| ("Tone", rfq_tone), | |
| ] | |
| tab_preview, tab_edit, tab_export = st.tabs(["Vista previa", "Editar contenido", "Exportar"]) | |
| with tab_edit: | |
| render_notice_panel( | |
| "Editor del cuerpo del correo", | |
| "Edita aqui el contenido generado por IA. La vista previa y los archivos exportados usan este texto.", | |
| "blue", | |
| ) | |
| rfq_editado = st.text_area( | |
| "Cuerpo del RFQ", | |
| height=520, | |
| key="rfq_editor" | |
| ) | |
| rfq_editado = st.session_state.get("rfq_editor", st.session_state.rfq_generado) | |
| rfq_html = build_rfq_email_html(rfq_subject_edit, rfq_editado, rfq_meta) | |
| with tab_preview: | |
| render_rfq_email_preview(rfq_subject_edit, rfq_editado, rfq_meta) | |
| with tab_export: | |
| try: | |
| import os | |
| from email.mime.multipart import MIMEMultipart | |
| from email.mime.text import MIMEText | |
| from email.mime.base import MIMEBase | |
| from email import encoders | |
| msg = MIMEMultipart("mixed") | |
| msg['Subject'] = rfq_subject_edit | |
| msg['From'] = st.session_state.email_user or "procura@proyelec.com" | |
| msg['To'] = rfq_to | |
| alternative = MIMEMultipart("alternative") | |
| alternative.attach(MIMEText(rfq_editado, 'plain', 'utf-8')) | |
| alternative.attach(MIMEText(rfq_html, 'html', 'utf-8')) | |
| msg.attach(alternative) | |
| excel_path = "PRY-FRPCL-003 Evaluación de Cumplimiento del Proveedor.xlsx" | |
| excel_adjunto = False | |
| if os.path.exists(excel_path): | |
| with open(excel_path, "rb") as f: | |
| parte = MIMEBase('application', 'octet-stream') | |
| parte.set_payload(f.read()) | |
| encoders.encode_base64(parte) | |
| parte.add_header('Content-Disposition', 'attachment; filename="PRY-FRPCL-003 Evaluación de Cumplimiento del Proveedor.xlsx"') | |
| msg.attach(parte) | |
| excel_adjunto = True | |
| eml_bytes = msg.as_bytes() | |
| ex1, ex2, ex3 = st.columns(3) | |
| with ex1: | |
| st.download_button( | |
| label="Descargar correo .eml" + (" + Excel" if excel_adjunto else ""), | |
| data=eml_bytes, | |
| file_name=f"RFQ_{cg_render.get('numero_licitacion','')}.eml", | |
| mime="message/rfc822", | |
| type="primary", | |
| use_container_width=True | |
| ) | |
| with ex2: | |
| st.download_button( | |
| label="Descargar HTML", | |
| data=rfq_html.encode("utf-8"), | |
| file_name=f"RFQ_{cg_render.get('numero_licitacion','')}.html", | |
| mime="text/html", | |
| use_container_width=True | |
| ) | |
| with ex3: | |
| st.download_button( | |
| label="Descargar texto", | |
| data=rfq_editado.encode('utf-8'), | |
| file_name=f"RFQ_{cg_render.get('numero_licitacion','')}.txt", | |
| mime="text/plain", | |
| use_container_width=True | |
| ) | |
| if not excel_adjunto: | |
| st.warning("Excel de evaluación no encontrado. El correo se exporta sin adjunto automático.") | |
| except Exception as e: | |
| st.error(f"Error generando archivos de RFQ: {e}") | |
| if result_view == "🤖 3. Centro de Mando AI": | |
| # ================================================================ | |
| # 🤖 CENTRO DE MANDO AI — Copilot + Agente Negociador | |
| # ================================================================ | |
| st.markdown(""" | |
| <div style="background: linear-gradient(135deg, #1a1f2e 0%, #161B22 100%); | |
| border: 1px solid #30363D; border-left: 4px solid #58A6FF; | |
| border-radius: 12px; padding: 20px; margin-bottom: 24px;"> | |
| <h3 style="color:#58A6FF; margin:0 0 6px 0; font-family:'Inter',sans-serif;">🤖 Centro de Mando AI</h3> | |
| <p style="color:#8B949E; margin:0; font-size:14px;"> | |
| Dos motores de inteligencia artificial a tu disposición: genera contraofertas profesionales | |
| y consulta el historial de licitaciones en lenguaje natural. | |
| </p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| ai_view = st.radio( | |
| "Modo AI", | |
| ["⚡ Agente Negociador", "💬 Procura Copilot"], | |
| horizontal=True, | |
| label_visibility="collapsed", | |
| key="ai_view", | |
| ) | |
| # ────────────────────────────────────────────── | |
| # ⚡ ASESOR SENIOR DE PROCURA | |
| # ────────────────────────────────────────────── | |
| if ai_view == "⚡ Agente Negociador": | |
| st.markdown(""" | |
| <div style="background:#161B22; border:1px solid #30363D; border-radius:10px; | |
| padding:16px 20px; margin-bottom:20px; display:flex; align-items:flex-start; gap:16px;"> | |
| <div style="font-size:36px; line-height:1;">🎓</div> | |
| <div> | |
| <div style="color:#E6EDF3; font-weight:700; font-size:15px; margin-bottom:4px;"> | |
| Asesor Senior de Procura — Carlos Méndez, CPSM | |
| </div> | |
| <div style="color:#8B949E; font-size:13px; line-height:1.5;"> | |
| 25 años de experiencia en licitaciones gubernamentales y procura industrial B2B. | |
| Descríbele tu situación o pega el correo del proveedor y recibirás un análisis | |
| estratégico completo: banderas rojas, tácticas de negociación, precio sugerido y | |
| el correo de respuesta listo para enviar. | |
| </div> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| col_neg_l, col_neg_r = st.columns([1.0, 1.0]) | |
| with col_neg_l: | |
| situacion = st.text_area( | |
| "📝 Describe tu situación o pega el correo del proveedor:", | |
| height=220, | |
| placeholder="Puedes escribir cualquier situación de procura, por ejemplo:\n\n• 'El proveedor nos cotizó USD 4,200 por la bomba centrífuga del renglón 1 pero el histórico dice que la compramos a USD 3,100 el año pasado. ¿Cómo negocio?'\n\n• O pega directamente el correo de cotización que recibiste...", | |
| key="situacion_neg_input" | |
| ) | |
| col_opt1, col_opt2 = st.columns(2) | |
| with col_opt1: | |
| precio_ref = st.number_input( | |
| "💰 Precio de referencia (USD)", | |
| min_value=0.0, step=100.0, value=0.0, | |
| help="Precio histórico o presupuesto objetivo. Déjalo en 0 si no lo conoces." | |
| ) | |
| with col_opt2: | |
| modo_analisis = st.selectbox( | |
| "🎯 Tipo de análisis:", | |
| ["Negociación de precio", "Evaluación de riesgo", "Tips para licitación ACP", "Análisis de condiciones contractuales"] | |
| ) | |
| btn_asesor = st.button("🎓 Consultar al Asesor Senior", type="primary", use_container_width=True) | |
| with col_neg_r: | |
| if btn_asesor: | |
| if not situacion.strip(): | |
| st.warning("⚠️ Describe tu situación o pega el correo del proveedor primero.") | |
| elif not st.session_state.gemini_key: | |
| st.error("🔑 Configura tu API Key de Gemini en el panel lateral.") | |
| else: | |
| with st.spinner("🎓 El Asesor Senior está analizando tu situación..."): | |
| try: | |
| cg_ctx = st.session_state.get('cg', {}) | |
| contexto_items = df_render[['renglon','codigo_articulo','cantidad','termino_de_busqueda_corto']].to_string(index=False) if not df_render.empty else "Sin renglones cargados." | |
| ref_precio_str = f"USD {precio_ref:,.2f}" if precio_ref > 0 else "No proporcionado" | |
| prompt_asesor = f"""Eres Carlos Méndez, Asesor Senior de Procura con 25 años de experiencia en licitaciones gubernamentales del Canal de Panamá, procura industrial B2B y negociaciones internacionales. Tienes certificación CPSM (Certified Professional in Supply Management). | |
| Tu misión es asesorar al equipo de compras de Proyelec S.A. como un colega experimentado, no como un robot. Hablas en español, de forma directa, práctica y con criterio profesional. Das consejos reales basados en experiencia de campo. | |
| CONTEXTO DE LA LICITACIÓN ACTIVA: | |
| - Número: {cg_ctx.get('numero_licitacion','N/A')} | |
| - Tiempo de entrega global: {cg_ctx.get('tiempo_de_entrega_global','N/A')} | |
| - Garantía exigida: {cg_ctx.get('garantia_exigida','N/A')} | |
| - Lugar de entrega: {cg_ctx.get('lugar_de_entrega','N/A')} | |
| - Validez de oferta: {cg_ctx.get('validez_de_la_oferta','N/A')} | |
| RENGLONES DE LA LICITACIÓN: | |
| {contexto_items} | |
| PRECIO DE REFERENCIA HISTÓRICO: {ref_precio_str} | |
| TIPO DE ANÁLISIS SOLICITADO: {modo_analisis} | |
| SITUACIÓN PRESENTADA POR EL EQUIPO: | |
| --- | |
| {situacion[:1500]} | |
| --- | |
| Responde con el siguiente formato estructurado en Markdown: | |
| ## 🔍 Mi Lectura de la Situación | |
| [Análisis breve y directo de lo que está pasando realmente, 2-3 oraciones] | |
| ## 🚩 Banderas Rojas / Puntos de Atención | |
| [Lista de 2-4 items con viñetas. Señala riesgos, cláusulas peligrosas o señales de alerta que el equipo quizás no vio] | |
| ## 💡 Mi Recomendación Estratégica | |
| [Consejo concreto y accionable. Qué harías tú exactamente en esta situación. Habla en primera persona como mentor] | |
| ## 🎯 Tácticas de Negociación para Este Caso | |
| [3-5 tácticas específicas numeradas, con el razonamiento psicológico o comercial detrás de cada una] | |
| ## 📧 Borrador de Respuesta Profesional | |
| [Si aplica: el correo de respuesta/contraoferta listo para copiar y personalizar. Firma como: Departamento de Compras — Proyelec S.A.] | |
| --- | |
| *💬 Tip del Asesor: [Un consejo de oro corto y memorable basado en experiencia real de negociaciones]*""" | |
| analisis = gemini_generate_text(st.session_state.gemini_key, prompt_asesor) | |
| st.markdown(""" | |
| <div style="background:#0D1117; border:1px solid #238636; border-radius:8px; | |
| padding:8px 14px; margin-bottom:12px; display:flex; align-items:center; gap:8px;"> | |
| <span style="color:#3FB950; font-size:14px;">✅</span> | |
| <span style="color:#3FB950; font-size:12px; font-weight:bold;"> | |
| Análisis completado por el Asesor Senior | |
| </span> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown(analisis) | |
| except Exception as e: | |
| st.error(f"❌ Error al consultar al Asesor: {e}") | |
| else: | |
| st.markdown(""" | |
| <div style="background:#0D1117; border:1px dashed #30363D; border-radius:10px; | |
| padding:40px 24px; text-align:center;"> | |
| <div style="font-size:48px; margin-bottom:16px;">🎓</div> | |
| <div style="color:#E6EDF3; font-size:15px; font-weight:600; margin-bottom:8px;"> | |
| El Asesor Senior está listo | |
| </div> | |
| <div style="color:#8B949E; font-size:13px; line-height:1.6; max-width:320px; margin:0 auto;"> | |
| Describe cualquier situación de procura o pega el correo de un proveedor. | |
| Recibirás un análisis completo con banderas rojas, estrategia y la respuesta | |
| lista para enviar. | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # ────────────────────────────────────────────── | |
| # 💬 PROCURA COPILOT | |
| # ────────────────────────────────────────────── | |
| if ai_view == "💬 Procura Copilot": | |
| st.markdown(""" | |
| <p style="color:#8B949E; font-size:13px; margin-bottom:16px;"> | |
| Hazle preguntas en lenguaje natural al Copilot sobre la licitación activa, los renglones | |
| cargados y el contexto del pliego. Ideal para análisis rápidos durante una reunión. | |
| </p> | |
| """, unsafe_allow_html=True) | |
| # Inicializar historial de chat por licitación | |
| licitacion_actual = str(cg_render.get('numero_licitacion', 'default')) | |
| if "copilot_messages_by_lic" not in st.session_state: | |
| st.session_state.copilot_messages_by_lic = {} | |
| if licitacion_actual not in st.session_state.copilot_messages_by_lic: | |
| st.session_state.copilot_messages_by_lic[licitacion_actual] = [] | |
| copilot_messages = st.session_state.copilot_messages_by_lic[licitacion_actual] | |
| # Mostrar historial | |
| for msg in copilot_messages: | |
| with st.chat_message(msg["role"]): | |
| st.markdown(msg["content"]) | |
| # Input del chat | |
| pregunta = st.chat_input("Pregúntale algo al Copilot... Ej: ¿Cuál es el tiempo de entrega exigido?") | |
| if pregunta: | |
| if not st.session_state.gemini_key: | |
| st.error("🔑 Configura tu API Key de Gemini en el panel lateral.") | |
| else: | |
| # Añadir mensaje del usuario | |
| copilot_messages.append({"role": "user", "content": pregunta}) | |
| with st.chat_message("user"): | |
| st.markdown(pregunta) | |
| # Generar respuesta | |
| with st.chat_message("assistant"): | |
| with st.spinner("Analizando..."): | |
| try: | |
| # Construir contexto de la licitación activa | |
| cg_ctx = st.session_state.get('cg', {}) | |
| items_ctx = df_render[['renglon','codigo_articulo','cantidad','unidad_de_medida','termino_de_busqueda_corto']].to_string(index=False) if not df_render.empty else "Sin renglones cargados." | |
| # Historial reciente del chat (últimas 6 interacciones) | |
| historial_str = "\n".join([f"{m['role'].upper()}: {m['content']}" for m in copilot_messages[-6:]]) | |
| prompt_cop = f"""Eres Procura Copilot, un asistente experto en procura y compras industriales de la empresa Proyelec S.A. | |
| Respondes en español de forma concisa, directa y útil. Si no tienes datos suficientes, lo dices honestamente. | |
| CONTEXTO DE LA LICITACIÓN ACTIVA: | |
| - Número: {cg_ctx.get('numero_licitacion','N/A')} | |
| - Tiempo de entrega global: {cg_ctx.get('tiempo_de_entrega_global','N/A')} | |
| - Garantía exigida: {cg_ctx.get('garantia_exigida','N/A')} | |
| - Lugar de entrega: {cg_ctx.get('lugar_de_entrega','N/A')} | |
| - Validez de oferta: {cg_ctx.get('validez_de_la_oferta','N/A')} | |
| - Propuesta técnica: {cg_ctx.get('propuesta_tecnica_requerida','N/A')} | |
| RENGLONES CARGADOS: | |
| {items_ctx} | |
| HISTORIAL DE CONVERSACIÓN: | |
| {historial_str} | |
| PREGUNTA DEL USUARIO: | |
| {pregunta} | |
| Responde de forma clara y profesional. Si puedes dar un número o dato exacto del contexto, hazlo.""" | |
| respuesta = gemini_generate_text(st.session_state.gemini_key, prompt_cop) | |
| st.markdown(respuesta) | |
| copilot_messages.append({"role": "assistant", "content": respuesta}) | |
| except Exception as e: | |
| err_msg = f"❌ Error del Copilot: {e}" | |
| st.error(err_msg) | |
| copilot_messages.append({"role": "assistant", "content": err_msg}) | |
| # Botón para limpiar chat | |
| if copilot_messages: | |
| if st.button("🗑️ Limpiar conversación", key="clear_copilot"): | |
| st.session_state.copilot_messages_by_lic[licitacion_actual] = [] | |
| st.rerun() | |
| if result_view == "📈 4. Análisis de Costos": | |
| st.markdown("### 📈 Visualización de Costos") | |
| if 'precio_comp_hist' in df_render.columns and not df_render['precio_comp_hist'].isna().all(): | |
| df_cost = df_render.copy() | |
| for col in ["cantidad", "precio_comp_hist", "precio_proy_hist", "margen_$"]: | |
| if col in df_cost.columns: | |
| df_cost[col] = pd.to_numeric(df_cost[col], errors="coerce") | |
| df_cost["valor_comp_hist"] = df_cost["cantidad"].fillna(0) * df_cost["precio_comp_hist"].fillna(0) | |
| df_cost["valor_proy_hist"] = df_cost["cantidad"].fillna(0) * df_cost["precio_proy_hist"].fillna(0) | |
| df_cost["diferencia_total_hist"] = df_cost["valor_proy_hist"] - df_cost["valor_comp_hist"] | |
| total_comp_hist = float(df_cost["valor_comp_hist"].sum()) | |
| total_proy_hist = float(df_cost["valor_proy_hist"].sum()) | |
| total_diff_hist = float(df_cost["diferencia_total_hist"].sum()) | |
| matched_hist = int(df_cost["precio_comp_hist"].notna().sum()) | |
| total_rows = len(df_cost) | |
| ck1, ck2, ck3, ck4 = st.columns(4) | |
| ck1.metric("Cobertura histórica", f"{matched_hist}/{total_rows}") | |
| ck2.metric("Base competencia", f"$ {total_comp_hist:,.2f}") | |
| ck3.metric("Base Proyelec", f"$ {total_proy_hist:,.2f}") | |
| ck4.metric("Diferencia hist.", f"$ {total_diff_hist:,.2f}") | |
| fig = go.Figure() | |
| fig.add_trace(go.Bar(x=df_cost['renglon'], y=df_cost['precio_comp_hist'], name='Competencia', marker_color='#30363D')) | |
| fig.add_trace(go.Bar(x=df_cost['renglon'], y=df_cost['precio_proy_hist'], name='Proyelec', marker_color='#58A6FF')) | |
| fig.update_layout(template="plotly_dark", title="Análisis de Competitividad Histórica", barmode='group', plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)') | |
| st.plotly_chart(fig, use_container_width=True) | |
| cost_cols = [ | |
| "renglon", "codigo_articulo", "cantidad", "licitacion_hist", "anio_hist", | |
| "precio_comp_hist", "precio_proy_hist", "margen_$", | |
| "valor_comp_hist", "valor_proy_hist", "diferencia_total_hist", | |
| ] | |
| cost_cols = [c for c in cost_cols if c in df_cost.columns] | |
| st.caption("Trazabilidad del precio histórico usado para cada renglón") | |
| st.dataframe( | |
| df_cost[cost_cols], | |
| use_container_width=True, | |
| hide_index=True, | |
| column_config={ | |
| "renglon": st.column_config.TextColumn("Renglón", width="small"), | |
| "codigo_articulo": st.column_config.TextColumn("Código ACP"), | |
| "licitacion_hist": st.column_config.TextColumn("Lic. Hist.", width="small"), | |
| "anio_hist": st.column_config.NumberColumn("Año Hist.", format="%d", width="small"), | |
| "precio_comp_hist": st.column_config.NumberColumn("Precio Comp.", format="$ %.2f"), | |
| "precio_proy_hist": st.column_config.NumberColumn("Precio Proyelec", format="$ %.2f"), | |
| "margen_$": st.column_config.NumberColumn("Margen Unit.", format="$ %.2f"), | |
| "valor_comp_hist": st.column_config.NumberColumn("Valor Comp.", format="$ %.2f"), | |
| "valor_proy_hist": st.column_config.NumberColumn("Valor Proyelec", format="$ %.2f"), | |
| "diferencia_total_hist": st.column_config.NumberColumn("Dif. Total", format="$ %.2f"), | |
| }, | |
| ) | |
| if "anio_hist" in df_cost.columns and df_cost["anio_hist"].notna().any(): | |
| df_year = df_cost.dropna(subset=["anio_hist"]).copy() | |
| df_year["anio_hist"] = pd.to_numeric(df_year["anio_hist"], errors="coerce") | |
| df_year = df_year.dropna(subset=["anio_hist"]) | |
| if not df_year.empty: | |
| year_counts = df_year.groupby("anio_hist").size().reset_index(name="renglones") | |
| st.caption("Origen de referencias por año histórico") | |
| st.bar_chart(year_counts.set_index("anio_hist")["renglones"]) | |
| else: | |
| st.info("📊 **Sin Historial de Costos**\n\nNo se encontraron registros de precios anteriores para los códigos de esta licitación en la base de datos histórica. Los artículos parecen ser nuevos o no han sido cotizados previamente.") | |
| st.markdown("#### Volumen Solicitado por Renglón") | |
| fig = go.Figure(data=[go.Bar(x=df_render['renglon'], y=df_render['cantidad'], marker_color='#238636')]) | |
| fig.update_layout(template="plotly_dark", plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)') | |
| st.plotly_chart(fig, use_container_width=True) | |
| if result_view == "🚚 5. Costos Logísticos": | |
| st.markdown("### 🚚 Cálculo logístico por renglón") | |
| render_notice_panel( | |
| "Ruta tarifaria vigente", | |
| f"{LOGISTICS_ROUTE_LABEL}. Estas tarifas no estiman costos desde China, Europa u otros orígenes hasta que Logística cargue una ruta específica.", | |
| "blue", | |
| ) | |
| freight_df = get_logistics_freight_rates_cached() | |
| local_df = get_logistics_local_rates_cached() | |
| incoterms_df = get_logistics_incoterms_cached() | |
| forwarders_df = get_logistics_forwarders_cached() | |
| if freight_df.empty: | |
| render_empty_state("Sin tarifas logísticas", "Solicita a Logística cargar las tarifas base antes de calcular costos.") | |
| else: | |
| log_left, log_right = st.columns([0.58, 0.42]) | |
| with log_left: | |
| def _log_item_label(idx): | |
| row = df_render.iloc[idx] | |
| return f"Renglón {row.get('renglon', idx + 1)} · {row.get('codigo_articulo', 'S/C')} · {str(row.get('termino_de_busqueda_corto', ''))[:55]}" | |
| route_a, route_b = st.columns(2) | |
| with route_a: | |
| st.text_input("Origen tarifario", value=LOGISTICS_ROUTE_ORIGIN, disabled=True, key="rfq_log_origin") | |
| with route_b: | |
| st.text_input("Destino tarifario", value=LOGISTICS_ROUTE_DESTINATION, disabled=True, key="rfq_log_route_dest") | |
| item_idx = st.selectbox( | |
| "Renglón", | |
| list(range(len(df_render))), | |
| format_func=_log_item_label, | |
| key="rfq_log_item_idx", | |
| ) | |
| selected_item = df_render.iloc[item_idx] | |
| qty_for_hint = pd.to_numeric(selected_item.get("cantidad", 1), errors="coerce") | |
| qty_for_hint = float(qty_for_hint) if pd.notna(qty_for_hint) and float(qty_for_hint) > 0 else 1.0 | |
| modo_peso_rfq = st.radio( | |
| "Forma de cálculo", | |
| ["Por unidad del renglón", "Por paquete"], | |
| horizontal=True, | |
| key="rfq_log_modo_peso", | |
| ) | |
| c1, c2 = st.columns(2) | |
| with c1: | |
| tipo_flete = st.selectbox("Tipo de flete", sorted(freight_df["tipo_flete"].dropna().unique().tolist()), key="rfq_log_tipo") | |
| freight_filtered = freight_df[freight_df["tipo_flete"] == tipo_flete].copy() | |
| with c2: | |
| agente_flete = st.selectbox("Agente", freight_filtered["agente"].dropna().unique().tolist(), key="rfq_log_agente") | |
| freight_row = freight_filtered[freight_filtered["agente"] == agente_flete].iloc[0] | |
| forwarder_row = get_forwarder_for_agent(forwarders_df, agente_flete) | |
| if modo_peso_rfq == "Por paquete": | |
| c3, c4, c5, c6 = st.columns([0.24, 0.24, 0.24, 0.28]) | |
| with c3: | |
| peso_paquete_rfq = st.number_input("Peso por paquete lb", min_value=0.0, value=1.0, step=0.5, key="rfq_log_pkg_lb") | |
| with c4: | |
| bultos_rfq = st.number_input("Paquetes", min_value=1, value=1, step=1, key="rfq_log_pkg_count") | |
| with c5: | |
| cantidad_log = st.number_input("Cantidad renglón", min_value=1.0, value=max(qty_for_hint, 1.0), step=1.0, key="rfq_log_qty") | |
| with c6: | |
| incoterm_log = st.selectbox("Incoterm", incoterms_df["sigla"].tolist() if not incoterms_df.empty else ["FOB"], key="rfq_log_incoterm") | |
| peso_unit_lb = peso_paquete_rfq | |
| peso_total_lb = peso_paquete_rfq * bultos_rfq | |
| else: | |
| c3, c4, c5, c6 = st.columns([0.24, 0.24, 0.24, 0.28]) | |
| with c3: | |
| peso_unit_lb = st.number_input("Peso unitario lb", min_value=0.0, value=1.0, step=0.5, key="rfq_log_unit_lb") | |
| with c4: | |
| cantidad_log = st.number_input("Cantidad", min_value=1.0, value=max(qty_for_hint, 1.0), step=1.0, key="rfq_log_qty") | |
| with c5: | |
| bultos_rfq = st.number_input("Paquetes / bultos", min_value=1, value=1, step=1, key="rfq_log_bultos") | |
| with c6: | |
| incoterm_log = st.selectbox("Incoterm", incoterms_df["sigla"].tolist() if not incoterms_df.empty else ["FOB"], key="rfq_log_incoterm") | |
| peso_paquete_rfq = peso_unit_lb | |
| peso_total_lb = peso_unit_lb * cantidad_log | |
| incoterm_summary = build_incoterm_summary(incoterms_df, incoterm_log) | |
| st.caption("Dimensiones por paquete/bulto para estimar peso volumétrico.") | |
| d1, d2, d3, d4, d5 = st.columns([0.2, 0.2, 0.2, 0.18, 0.22]) | |
| with d1: | |
| largo_rfq = st.number_input("Largo", min_value=0.0, value=0.0, step=1.0, key="rfq_log_largo") | |
| with d2: | |
| ancho_rfq = st.number_input("Ancho", min_value=0.0, value=0.0, step=1.0, key="rfq_log_ancho") | |
| with d3: | |
| alto_rfq = st.number_input("Alto", min_value=0.0, value=0.0, step=1.0, key="rfq_log_alto") | |
| with d4: | |
| unidad_rfq = st.selectbox("Unidad", ["in", "cm"], key="rfq_log_unidad_dim") | |
| with d5: | |
| st.metric("Peso total", f"{peso_total_lb:,.2f} lb") | |
| local_row = pd.Series(dtype=object) | |
| destino_log = "Sin entrega local" | |
| if not local_df.empty: | |
| destino_log = st.selectbox("Entrega local", ["Sin entrega local"] + local_df["destino"].dropna().unique().tolist(), key="rfq_log_destino") | |
| if destino_log != "Sin entrega local": | |
| local_candidates = local_df[local_df["destino"] == destino_log] | |
| if not local_candidates.empty: | |
| local_row = local_candidates.iloc[0] | |
| calc = logistics_calc_summary( | |
| freight_row, | |
| local_row, | |
| peso_total_lb, | |
| incoterm=incoterm_log, | |
| largo=largo_rfq, | |
| ancho=ancho_rfq, | |
| alto=alto_rfq, | |
| unidad_dimensional=unidad_rfq, | |
| bultos=bultos_rfq, | |
| ) | |
| costo_unit_log = calc["costo_total"] / cantidad_log if cantidad_log else 0 | |
| if st.button("Guardar costo logístico del renglón", type="primary", use_container_width=True): | |
| db.save_logistics_calculation( | |
| username=st.session_state.username, | |
| licitacion=str(cg_render.get("numero_licitacion", "")), | |
| renglon=str(selected_item.get("renglon", "")), | |
| agente=agente_flete, | |
| tipo_flete=tipo_flete, | |
| incoterm=incoterm_log, | |
| peso_libras=calc["peso_libras"], | |
| peso_kg=calc["peso_kg"], | |
| costo_internacional=calc["costo_internacional"], | |
| costo_local=calc["costo_local"], | |
| costo_total=calc["costo_total"], | |
| tiempo_transito_dias=calc["tiempo_transito_dias"], | |
| peso_facturable_libras=calc["peso_facturable_libras"], | |
| peso_volumetrico_libras=calc["peso_volumetrico_libras"], | |
| largo=calc["largo"], | |
| ancho=calc["ancho"], | |
| alto=calc["alto"], | |
| unidad_dimensional=calc["unidad_dimensional"], | |
| metadata={ | |
| "destino": destino_log, | |
| "ruta": LOGISTICS_ROUTE_LABEL, | |
| "origen_tarifario": LOGISTICS_ROUTE_ORIGIN, | |
| "destino_tarifario": LOGISTICS_ROUTE_DESTINATION, | |
| "modo_calculo": modo_peso_rfq, | |
| "cantidad": cantidad_log, | |
| "peso_unitario_lb": peso_unit_lb, | |
| "peso_por_paquete_lb": peso_paquete_rfq, | |
| "bultos": calc["bultos"], | |
| "volumen_pies_cubicos": calc["volumen_pies_cubicos"], | |
| "forwarder_direccion": str(forwarder_row.get("direccion", "")) if not forwarder_row.empty else "", | |
| "forwarder_observacion": str(forwarder_row.get("observacion", "")) if not forwarder_row.empty else "", | |
| "codigo_articulo": str(selected_item.get("codigo_articulo", "")), | |
| } | |
| ) | |
| clear_logistics_cache() | |
| st.success("Costo logístico guardado para trazabilidad.") | |
| with log_right: | |
| st.metric("Costo logístico total", f"$ {calc['costo_total']:,.2f}") | |
| st.metric("Costo logístico unitario", f"$ {costo_unit_log:,.2f}") | |
| st.metric("Flete internacional", f"$ {calc['costo_internacional']:,.2f}") | |
| st.metric("Entrega local", f"$ {calc['costo_local']:,.2f}") | |
| render_notice_panel( | |
| "Lead time calculado", | |
| f"{LOGISTICS_ROUTE_LABEL}. {agente_flete} / {tipo_flete}: {calc['tiempo_transito_dias']} día(s). Peso real: {calc['peso_libras']:,.2f} lb. Peso facturable: {calc['peso_facturable_libras']:,.2f} lb.", | |
| "blue", | |
| ) | |
| if not forwarder_row.empty: | |
| render_notice_panel( | |
| "Ubicación del forwarder", | |
| f"{forwarder_row.get('nombre', agente_flete)}: {forwarder_row.get('direccion', 'Sin dirección cargada')}. {forwarder_row.get('observacion', '')}", | |
| "green", | |
| ) | |
| else: | |
| render_notice_panel( | |
| "Ubicación del forwarder", | |
| "No hay dirección cargada para este agente. Logística debe completar la ficha del forwarder.", | |
| "amber", | |
| ) | |
| render_notice_panel( | |
| "Incoterm aplicado", | |
| incoterm_summary, | |
| "amber", | |
| ) | |
| calc_df = get_logistics_calculations_cached(50) | |
| if not calc_df.empty: | |
| st.caption("Últimos cálculos logísticos guardados") | |
| st.dataframe( | |
| calc_df[["created_at", "licitacion", "renglon", "agente", "tipo_flete", "incoterm", "costo_total", "tiempo_transito_dias"]].head(10), | |
| use_container_width=True, | |
| hide_index=True, | |
| column_config={"costo_total": st.column_config.NumberColumn("Total", format="$ %.2f")}, | |
| ) | |