diff --git "a/app.py" "b/app.py"
--- "a/app.py"
+++ "b/app.py"
@@ -1,36 +1,55 @@
-import streamlit as st
-import pandas as pd
-import json
+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
-import requests
-import os
-import re
-import io
-from html import escape
-import crypto
-from dotenv import load_dotenv
-import database as db
+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 ---
-@st.cache_data
-def get_css():
- try:
- with open('style.css', 'r', encoding='utf-8') as f:
- return f''
- except:
- return ""
-
-st.markdown(get_css(), unsafe_allow_html=True)
+# --- CACHE DE ESTILOS PARA EVITAR PARPADEO ---
+@st.cache_data(show_spinner=False)
+def get_css(css_mtime=0):
+ try:
+ with open('style.css', 'r', encoding='utf-8') as f:
+ return f''
+ 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)
@@ -63,10 +82,18 @@ def extraer_meta_nota(nota):
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")}
-TIEMPO_BLOQUEO = 15 # minutos — debe coincidir con database.py
-MAPA_ESTADOS_SLI = {
+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()
+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",
@@ -77,30 +104,394 @@ MAPA_ESTADOS_SLI = {
}
-# --- 2. BASE DE DATOS LOCAL Y PERSISTENCIA ---
-def init_db():
- db.init_db()
-
-init_db()
+# --- 2. BASE DE DATOS LOCAL Y PERSISTENCIA ---
+@st.cache_resource(show_spinner=False)
+def init_db():
+ db.init_db()
+ return True
+
+init_db()
+
+@st.cache_data(ttl=300, show_spinner=False)
+def load_historical_prices():
+ df_historico = db.get_historical_prices_df()
+ return df_historico if not df_historico.empty else None
+
+@st.cache_data(ttl=300, show_spinner=False)
+def get_historico_anios_cached():
+ return db.get_historico_anios()
+
+@st.cache_data(ttl=60, show_spinner=False)
+def get_historico_count_cached():
+ return db.get_historico_count()
+
+@st.cache_data(ttl=120, show_spinner=False)
+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"
", "\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"
+
+@st.cache_data(ttl=15, show_spinner=False)
+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))
-
-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])
+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 verify_login(username, password):
+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:
@@ -115,19 +506,17 @@ def verify_login(username, password):
u_list = list(user_data)
role = u_list[2]
- # Analistas heredan solo gemini_key y tavily_key si no tienen las suyas
- # (NO heredan email corporativo de Gerencia — reduce superficie de ataque)
- if role == "Analista":
- conn = db.get_connection()
- c = conn.cursor()
- c.execute("SELECT gemini_key, tavily_key FROM users WHERE role='Gerencia' AND gemini_key != '' LIMIT 1")
- gerencia = c.fetchone()
- conn.close()
- if gerencia:
- if not u_list[3]: u_list[3] = crypto.decrypt_data(gerencia[0])
- if not u_list[4]: u_list[4] = crypto.decrypt_data(gerencia[1])
-
- return tuple(u_list)
+ # 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)
@@ -137,19 +526,456 @@ def verify_login(username, password):
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)
-
-def save_history(username, licitacion, items_count):
- db.save_history(username, licitacion, items_count)
-
-def get_user_history(username):
- return db.get_user_history_df(username)
-
-def obtener_correos_licitacion(licitacion):
- return db.get_correos_licitacion_df(licitacion)
+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()
+
+@st.cache_data(ttl=30, show_spinner=False)
+def get_user_history(username):
+ return db.get_user_history_df(username)
+
+@st.cache_data(ttl=30, show_spinner=False)
+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
+
+@st.cache_data(ttl=30, show_spinner=False)
+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)
+
+@st.cache_data(ttl=30, show_spinner=False)
+def get_usage_filter_options_cached(days):
+ return db.get_usage_filter_options(days=days)
+
+@st.cache_data(ttl=30, show_spinner=False)
+def get_usage_summary_cached(days, username, module):
+ return db.get_usage_summary(days=days, username=username, module=module)
+
+@st.cache_data(ttl=3600, show_spinner=False)
+def get_api_pricing_cached():
+ return db.get_api_pricing_df()
+
+@st.cache_data(ttl=20, show_spinner=False)
+def get_seguimientos_cached():
+ return db.get_seguimientos()
+
+@st.cache_data(ttl=20, show_spinner=False)
+def get_historial_seguimiento_cached(licitacion_id):
+ return db.get_historial_seguimiento(int(licitacion_id))
+
+@st.cache_data(ttl=30, show_spinner=False)
+def get_all_workspaces_cached(username, all_users=False):
+ return db.get_all_workspaces(username, all_users=all_users)
+
+@st.cache_data(ttl=30, show_spinner=False)
+def get_radar_cached(solo_nuevas=False, solo_hoy=False):
+ return db.get_licitaciones_radar(solo_nuevas=solo_nuevas, solo_hoy=solo_hoy)
+
+@st.cache_data(ttl=60, show_spinner=False)
+def get_radar_scans_cached(limit=10):
+ return db.get_ultimos_escaneos(limite=limit)
+
+@st.cache_data(ttl=300, show_spinner=False)
+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"(? 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 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 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()
+
+@st.cache_data(ttl=30, show_spinner=False)
+def obtener_correos_licitacion(licitacion):
+ return db.get_correos_licitacion_df(licitacion)
def activar_organizacion_imap():
try:
@@ -165,143 +991,331 @@ def activar_organizacion_imap():
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 ---
-if not st.session_state.logged_in:
- st.markdown("""
-
- """, unsafe_allow_html=True)
-
- col_logo = st.columns([1,3,1])[1]
- with col_logo:
- try:
- st.image("proyelec_logo.png", use_container_width=True)
- except Exception:
- st.markdown("
⚡
", unsafe_allow_html=True)
-
- st.markdown("""
- PROYELEC
- Sourcing Intelligence Pro
- """, 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:
- 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 user_data[6] else ""
- 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
- st.rerun()
- else:
- st.error("❌ Credenciales incorrectas. Intenta de nuevo.")
- except ValueError as e:
- st.error(f"🔒 {e}")
-
- st.markdown('', unsafe_allow_html=True)
- st.stop()
-
-# --- NAVBAR RENDER ---
-st.markdown(f"""
-
-
💎 PROYELEC SOURCING PRO
-
Usuario: {st.session_state.username} {st.session_state.role} | {datetime.now().strftime("%d/%m/%Y")}
-
-
-""", unsafe_allow_html=True)
-
-# --- PANEL DE ADMINISTRACION GLOBAL ---
-if st.session_state.username == "admin":
- st.markdown("""""", unsafe_allow_html=True)
- st.title("🛡️ Panel de Control Global")
- st.markdown("Gestión de usuarios y monitorización del sistema central.")
-
- tab_users, tab_stats = st.tabs(["👥 Gestión de Personal", "📊 Estadísticas del Sistema"])
-
- with tab_users:
- col1, col2 = st.columns([0.6, 0.4])
- with col1:
- st.subheader("Personal Registrado")
- df_users = db.get_all_users()
- st.dataframe(df_users, use_container_width=True, hide_index=True)
+
+
+
+# --- 4. LOGIN CON IDENTIDAD PROYELEC ---
+restore_persistent_session()
+
+if not st.session_state.logged_in:
+ st.markdown("""
+
+ """, 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("""
+
+
+
Procura AI beta interna
+
Sourcing operativo para licitaciones ACP
+
+ Analiza pliegos, compara histórico de precios, organiza proveedores y da seguimiento comercial desde un solo panel.
+
+
+
RFQ y renglones con evidencia técnica
+
Histórico Supabase para comparar precios
+
Radar SLI para supervisores
+
Seguimiento de licitaciones y comentarios
+
+
+
+
+ """, unsafe_allow_html=True)
+
+ with login_right:
+ st.markdown("""
+
+
Ingresar al sistema
+
Usa tu usuario corporativo. El rol define los modulos disponibles dentro del sistema.
+
+ """, 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('', 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"]:
+ 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",
+ },
+ "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"},
+ "Supervisor": {"dashboard", "rfq_upload", "providers", "monitor", "workspaces", "historico", "radar"},
+ "Gerencia": {"dashboard", "rfq_upload", "providers", "monitor", "workspaces", "radar", "historico", "settings"},
+ "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("""""", unsafe_allow_html=True)
+ df_users_admin = get_all_users_cached()
+ role_admin_series = df_users_admin["role"] if "role" 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
+
+ st.markdown("""
+
+
+
Administración Global
+
Panel de Control
+
Usuarios, roles, llaves, métricas y salud operativa del sistema.
+
+
Acceso Admin
+
+ """, unsafe_allow_html=True)
+
+ a1, a2, a3, a4 = st.columns(4)
+ a1.metric("Usuarios", total_admin_users)
+ a2.metric("Analistas", total_admin_analysts)
+ a3.metric("Supervisores", total_admin_supervisors)
+ a4.metric("Gerencia", total_admin_gerencia)
+
+ 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")
+ 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):
- st.success(f"Usuario {del_user} eliminado.")
- st.rerun()
+ 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)
- st.success("Contraseña actualizada.")
- st.rerun()
-
- with col2:
+ 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["username"].tolist() if "username" 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["username"] == role_user]
+ if not role_match.empty:
+ current_role = str(role_match.iloc[0].get("role", "Analista") or "Analista")
+ role_options = ["Analista", "Supervisor", "Gerencia"]
+ 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("""
➕ Nuevo Usuario
""", 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", "Gerencia"])
+ new_r = st.selectbox("Nivel de Acceso", ["Analista", "Supervisor", "Gerencia"])
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):
- st.success(f"✅ Cuenta de {new_r} creada para '{new_u}'")
- st.rerun()
+ 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("", unsafe_allow_html=True)
- st.markdown("
", unsafe_allow_html=True)
- with st.expander("🔑 Configurar API Keys por Usuario"):
- target_u = st.selectbox("Seleccionar Usuario", [""] + df_users['Usuario'].tolist())
+ st.markdown("
", 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()
@@ -319,98 +1333,280 @@ if st.session_state.username == "admin":
# 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)
- st.success(f"Configuración guardada para {target_u}")
- st.rerun()
+ 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()
- with tab_stats:
+ if admin_view == "📊 Estadísticas del Sistema":
st.subheader("Salud del Sistema")
- 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()
+ 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.markdown("---")
- if st.button("Cerrar Sesión Segura", type="primary", use_container_width=True):
- st.session_state.clear()
- st.rerun()
+ 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 ---
-with st.sidebar:
- st.markdown("
", unsafe_allow_html=True)
-
- st.title("📄 1. Cargar Requerimiento")
- st.caption("Sube el pliego principal y los anexos técnicos en formato PDF.")
- archivos_pdf = st.file_uploader("", type=["pdf"], label_visibility="collapsed", accept_multiple_files=True)
-
- if st.button("🚀 Procesar con IA", type="primary", use_container_width=True):
+# --- 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": "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("
", unsafe_allow_html=True)
+ st.markdown("""
+
+ """, unsafe_allow_html=True)
+ api_online_sidebar = get_api_health()
+ st.markdown(f"""
+
+ """, unsafe_allow_html=True)
+
+ role_profile = get_role_profile()
+ st.markdown(f"""
+
+ """, unsafe_allow_html=True)
+
+ st.markdown('', unsafe_allow_html=True)
+ last_group = None
+ for item in navigation_items:
+ if item["group"] != last_group:
+ st.markdown(f"{escape(item['group'])}
", 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()
+
+ st.markdown('', unsafe_allow_html=True)
+ st.markdown("""
+
+ """, 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}
+ 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 = pd.DataFrame(datos_crudos.get("items", []))
-
- try:
- if not os.path.exists("ACP DATA LIC PASADAS v2_2.xlsx"):
- st.warning("⚠️ No se encontró el histórico 'ACP DATA LIC PASADAS v2_2.xlsx'. Procesando sin precios base.")
- else:
- df_historico = pd.read_excel("ACP DATA LIC PASADAS v2_2.xlsx", skiprows=8).rename(columns=lambda x: str(x).strip())
-
- # Limpiar códigos aislando solo el ID puro (Primera palabra, sin basura de la IA) y haciendo alfanumérico
- df_historico['codigo_match'] = df_historico['CODIGO ACP'].astype(str).str.replace(r'[^a-zA-Z0-9]', '', regex=True).str.upper()
- df_exportar['codigo_match'] = df_exportar['codigo_articulo'].astype(str).str.split().str[0].fillna("").astype(str).str.replace(r'[^a-zA-Z0-9]', '', regex=True).str.upper()
-
- df_cruzado = pd.merge(df_exportar, df_historico.drop_duplicates(subset=['codigo_match'])[['codigo_match', 'PRECIO COMPETENCIA', 'PRECIO PROYELEC']], 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"})
+ 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 = df_cruzado
- except Exception as excel_e:
- st.warning(f"⚠️ Error leyendo Excel histórico: {excel_e}")
+ 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.rerun()
+ 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")
st.divider()
- st.caption("Opciones del Sistema")
-
- # Indicador de estado de la API
- try:
- _r = requests.get(f"{API_URL_BASE.replace('/api/v1','')}/", timeout=2, headers=API_HEADERS)
- if _r.status_code == 200:
- st.markdown("🟢 Motor IA Activo
", unsafe_allow_html=True)
- else:
- st.markdown("🔴 Motor IA Sin respuesta
", unsafe_allow_html=True)
- except Exception:
- st.markdown("🔴 Motor IA Apagado — Ejecuta run.bat
", unsafe_allow_html=True)
+ st.markdown('', unsafe_allow_html=True)
+ st.markdown(f"""
+
+ """, unsafe_allow_html=True)
if st.session_state.role == "Gerencia":
with st.expander("⚙️ Configuración y Llaves", expanded=False):
@@ -426,70 +1622,14 @@ with st.sidebar:
st.toast("✅ Configuración guardada.")
else:
st.markdown("""
-
- ⚙️ Configuración administrada por Gerencia
-
- """, unsafe_allow_html=True)
-
- # --- PANEL DE ADMINISTRACIÓN (solo Gerencia) ---
- if st.session_state.role == "Gerencia":
- with st.expander("👥 Gestión de Usuarios", expanded=False):
- st.caption("Panel exclusivo de Gerencia")
-
- df_users = db.get_all_users()
- if not df_users.empty:
- for _, u in df_users.iterrows():
- u_col1, u_col2, u_col3 = st.columns([0.45, 0.3, 0.25])
- with u_col1:
- st.markdown(f"{u['username']}
", unsafe_allow_html=True)
- with u_col2:
- roles_opt = ["Gerencia", "Analista"]
- idx = roles_opt.index(u['role']) if u['role'] in roles_opt else 1
- nuevo_rol = st.selectbox("", roles_opt, index=idx, key=f"rol_{u['username']}", label_visibility="collapsed")
- if nuevo_rol != u['role']:
- db.update_user_role(u['username'], nuevo_rol)
- st.toast(f"Rol de {u['username']} actualizado.")
- st.rerun()
- with u_col3:
- if u['username'] != st.session_state.username:
- if st.button("🗑️", key=f"del_{u['username']}", help=f"Eliminar {u['username']}"):
- db.delete_user(u['username'])
- st.toast(f"Usuario {u['username']} eliminado.")
- st.rerun()
-
- st.divider()
- st.caption("Crear nuevo usuario")
- new_u = st.text_input("Username", key="new_username", placeholder="ej: analista2")
- new_p = st.text_input("Contraseña", key="new_password", type="password", placeholder="mínimo 6 caracteres")
- new_r = st.selectbox("Rol", ["Analista", "Gerencia"], key="new_role")
- if st.button("➕ Crear Usuario", use_container_width=True):
- if new_u and new_p and len(new_p) >= 6:
- ok = db.create_user(new_u, new_p, new_r)
- if ok:
- st.toast(f"✅ Usuario '{new_u}' creado con rol {new_r}.")
- st.rerun()
- else:
- st.error(f"El usuario '{new_u}' ya existe.")
- else:
- st.warning("Username y contraseña (mín. 6 chars) son requeridos.")
+
+ ⚙️ Configuración administrada por el equipo autorizado
+
+ """, unsafe_allow_html=True)
- st.divider()
- st.caption("Resetear contraseña")
- reset_u = st.selectbox("Usuario", df_users['username'].tolist() if not df_users.empty else [], key="reset_user")
- reset_p = st.text_input("Nueva contraseña", key="reset_pass", type="password")
- if st.button("🔑 Resetear", use_container_width=True):
- if reset_u and reset_p and len(reset_p) >= 6:
- db.reset_user_password(reset_u, reset_p)
- st.toast(f"✅ Contraseña de '{reset_u}' actualizada.")
- else:
- st.warning("Selecciona usuario y escribe la nueva contraseña.")
-
- if st.button("🚪 Cerrar Sesión", use_container_width=True):
- st.session_state.logged_in = False
- st.session_state.procesado = False
- st.session_state.role = ""
- st.rerun()
+ if st.button("🚪 Cerrar Sesión", use_container_width=True):
+ logout_user()
# --- 7. RENDERIZADO VISUAL PRINCIPAL ---
# Colores y emojis por estado ACP
@@ -516,29 +1656,1286 @@ ESTADO_CONFIG.update({
"Desierta": ("", "#171C24", "#748091"),
})
-tab_main, tab_hist, tab_acp = st.tabs([
- "🚀 Tablero de Operaciones",
- "📚 Base de Conocimiento",
- "🏛️ Monitor ACP"
-])
-
-with tab_acp:
- st.markdown("Monitor ACP
", unsafe_allow_html=True)
- st.markdown("Seguimiento de licitaciones, estados SLI y alertas de evaluacion.
", unsafe_allow_html=True)
- seg_df = db.get_seguimientos()
-
- # --- KPIs del monitor ---
- total_seg = len(seg_df)
- adjudicadas = len(seg_df[seg_df['estado'] == 'Adjudicada']) if not seg_df.empty else 0
+active_view = st.session_state.active_view
+
+def render_page_header(eyebrow, title, subtitle, meta_items=None):
+ meta_html = ""
+ if meta_items:
+ meta_html = "" + "".join(
+ f"{escape(str(item))}" for item in meta_items if item is not None
+ ) + "
"
+ st.markdown(f"""
+
+ """, 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"""
+
+ """, unsafe_allow_html=True)
+
+def render_empty_state(title, body):
+ st.markdown(f"""
+
+
{escape(str(title))}
+
{escape(str(body))}
+
+ """, unsafe_allow_html=True)
+
+def render_notice_panel(title, body, tone="blue"):
+ st.markdown(f"""
+
+
{escape(str(title))}
+
{escape(str(body))}
+
+ """, unsafe_allow_html=True)
+
+def render_table_toolbar(title, subtitle="", meta_items=None):
+ meta_html = ""
+ if meta_items:
+ meta_html = "" + "".join(
+ f"{escape(str(item))}" for item in meta_items if item is not None
+ ) + "
"
+ st.markdown(f"""
+
+ """, 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"{escape(label)}{'Activo' if enabled else 'No asignado'}
"
+ for label, enabled in items
+ )
+ st.markdown(f"""
+
+ {items_html}
+
+ """, 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''
+ )
+ return "" + "".join(rows) + "
"
+
+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"{build_checklist_html(state_items)}
", 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"""
+
+ | {escape(str(label))} |
+ {escape(str(value or 'N/A'))} |
+
+ """
+ for label, value in meta
+ )
+ body_safe = escape(str(body or ""))
+ return f"""
+
+
+
+
+
+
+
+ |
+ Proyelec International
+ Request for Quotation
+ {escape(str(subject or 'Request for Quotation'))}
+ |
+
+
+
+
+ {body_safe}
+ |
+
+
+ |
+ Generated by Procura AI. Please validate technical compliance, lead time, payment terms and supplier reliability before issuing a purchase decision.
+ |
+
+
+ |
+
+
+
+"""
+
+def render_rfq_email_preview(subject, body, meta):
+ meta_html = "".join(
+ f"{escape(str(label))}{escape(str(value or 'N/A'))}
"
+ for label, value in meta
+ )
+ body_preview = escape(str(body or "RFQ pendiente")).replace("\n", "
")
+ st.markdown(f"""
+
+
+
{meta_html}
+
{body_preview}
+
+ """, unsafe_allow_html=True)
+
+def provider_query_from_row(row):
+ parts = [
+ str(row.get("termino_de_busqueda_corto", "") or "").strip(),
+ str(row.get("codigo_articulo", "") or "").strip(),
+ str(row.get("marca_modelo_requerido", "") or "").strip(),
+ ]
+ return " ".join(dict.fromkeys([p for p in parts if is_meaningful_text(p)]))
+
+def provider_external_links(query):
+ encoded = urllib.parse.quote(str(query or "").strip())
+ if not encoded:
+ return []
+ return [
+ ("Google B2B", f"https://www.google.com/search?q={encoded}+supplier+distributor+industrial"),
+ ("Google Shopping", f"https://www.google.com/search?tbm=shop&q={encoded}"),
+ ("Mouser", f"https://www.mouser.com/c/?q={encoded}"),
+ ("DigiKey", f"https://www.digikey.com/en/products/result?keywords={encoded}"),
+ ("Octopart", f"https://octopart.com/search?q={encoded}"),
+ ("Thomasnet", f"https://www.thomasnet.com/search.html?cov=NA&what={encoded}"),
+ ("Alibaba", f"https://www.alibaba.com/trade/search?SearchText={encoded}"),
+ ]
+
+def provider_source_catalog(query):
+ encoded = urllib.parse.quote(str(query or "").strip())
+ brave_url = f"https://search.brave.com/search?q={encoded}+supplier+price+stock" if encoded else ""
+ return [
+ {
+ "name": "Tavily",
+ "kind": "IA web",
+ "status": "API activa" if bool(st.session_state.get("tavily_key")) else "API no configurada",
+ "tone": "green" if bool(st.session_state.get("tavily_key")) else "amber",
+ "detail": "Evidencia resumida.",
+ "url": "",
+ },
+ {
+ "name": "Brave Search",
+ "kind": "Web index",
+ "status": "API activa" if bool(BRAVE_SEARCH_API_KEY) else "Link directo / API no configurada",
+ "tone": "green" if bool(BRAVE_SEARCH_API_KEY) else "blue",
+ "detail": "Búsqueda alterna.",
+ "url": brave_url,
+ },
+ {
+ "name": "Google B2B",
+ "kind": "Link directo",
+ "status": "Link directo sin API",
+ "tone": "blue",
+ "detail": "Búsqueda web B2B.",
+ "url": f"https://www.google.com/search?q={encoded}+supplier+distributor+industrial" if encoded else "",
+ },
+ {
+ "name": "Mouser",
+ "kind": "Link directo",
+ "status": "Link directo sin API",
+ "tone": "green",
+ "detail": "Precio, stock y datasheets.",
+ "url": f"https://www.mouser.com/c/?q={encoded}" if encoded else "",
+ },
+ {
+ "name": "DigiKey",
+ "kind": "Link directo",
+ "status": "Link directo sin API",
+ "tone": "green",
+ "detail": "Escalas e inventario.",
+ "url": f"https://www.digikey.com/en/products/result?keywords={encoded}" if encoded else "",
+ },
+ {
+ "name": "Octopart",
+ "kind": "Comparador",
+ "status": "Link directo sin API",
+ "tone": "green",
+ "detail": "Cruce global de partes.",
+ "url": f"https://octopart.com/search?q={encoded}" if encoded else "",
+ },
+ {
+ "name": "Google Shopping",
+ "kind": "Precio web",
+ "status": "Link directo sin API",
+ "tone": "amber",
+ "detail": "Señal de precio.",
+ "url": f"https://www.google.com/search?tbm=shop&q={encoded}" if encoded else "",
+ },
+ {
+ "name": "Thomasnet",
+ "kind": "Link directo",
+ "status": "Link directo sin API",
+ "tone": "green",
+ "detail": "Directorio industrial.",
+ "url": f"https://www.thomasnet.com/search.html?cov=NA&what={encoded}" if encoded else "",
+ },
+ {
+ "name": "Alibaba",
+ "kind": "Link directo",
+ "status": "Link directo sin API",
+ "tone": "amber",
+ "detail": "Proveedores globales.",
+ "url": f"https://www.alibaba.com/trade/search?SearchText={encoded}" if encoded else "",
+ },
+ ]
+
+def provider_source_options(tavily_ready, brave_ready):
+ return [
+ {"name": "Tavily", "label": "Tavily", "legacy_labels": ["Tavily · API activa", "Tavily · API no configurada"], "short": "Tavily", "has_api": True, "active": tavily_ready, "kind": "Ranking IA", "status": "API activa" if tavily_ready else "Configurar API"},
+ {"name": "Brave Search", "label": "Brave", "legacy_labels": ["Brave Search · API activa", "Brave Search · API no configurada", "Brave Search · Link directo / API no configurada"], "short": "Brave", "has_api": True, "active": brave_ready, "kind": "Ranking web", "status": "API activa" if brave_ready else "Configurar API"},
+ {"name": "Google B2B", "label": "Google B2B", "legacy_labels": ["Google B2B · Link directo sin API"], "short": "Google B2B", "has_api": False, "active": True, "kind": "Web B2B", "status": "Manual"},
+ {"name": "Google Shopping", "label": "Shopping", "legacy_labels": ["Google Shopping · Link directo sin API"], "short": "Shopping", "has_api": False, "active": True, "kind": "Señal de precio", "status": "Manual"},
+ {"name": "Mouser", "label": "Mouser", "legacy_labels": ["Mouser · Link directo sin API"], "short": "Mouser", "has_api": False, "active": True, "kind": "Catálogo", "status": "Manual"},
+ {"name": "DigiKey", "label": "DigiKey", "legacy_labels": ["DigiKey · Link directo sin API"], "short": "DigiKey", "has_api": False, "active": True, "kind": "Catálogo", "status": "Manual"},
+ {"name": "Octopart", "label": "Octopart", "legacy_labels": ["Octopart · Link directo sin API"], "short": "Octopart", "has_api": False, "active": True, "kind": "Comparador", "status": "Manual"},
+ {"name": "Thomasnet", "label": "Thomasnet", "legacy_labels": ["Thomasnet · Link directo sin API"], "short": "Thomasnet", "has_api": False, "active": True, "kind": "Directorio", "status": "Manual"},
+ {"name": "Alibaba", "label": "Alibaba", "legacy_labels": ["Alibaba · Link directo sin API"], "short": "Alibaba", "has_api": False, "active": True, "kind": "Global", "status": "Manual"},
+ ]
+
+def provider_source_status_html(source_options, selected_source_names):
+ selected = set(selected_source_names or [])
+ cards = []
+ for source in source_options:
+ is_selected = source["name"] in selected
+ if source["has_api"] and source["active"]:
+ tone = "green"
+ caption = "Automático"
+ elif source["has_api"]:
+ tone = "amber"
+ caption = "Sin API"
+ else:
+ tone = "blue"
+ caption = "Link directo"
+ cards.append(
+ f''
+ f'
{escape(source.get("short", source["name"]))}{caption}
'
+ f'
{escape(source.get("kind", ""))} '
+ )
+ return "" + "".join(cards) + "
"
+
+def provider_query_variants(base_query, extra_terms="", region="Global", qty=1):
+ base = str(base_query or "").strip()
+ extras = str(extra_terms or "").strip()
+ region_txt = "" if region == "Global" else str(region)
+ qty_txt = f"quantity {qty}" if qty else ""
+ if not base:
+ return []
+ raw_variants = [
+ ("Compatibilidad técnica", f"{base} datasheet specifications manufacturer part number {extras}"),
+ ("Precio y stock", f"{base} price stock availability distributor {region_txt} {qty_txt} {extras}"),
+ ("Distribuidor autorizado", f"{base} authorized distributor supplier industrial {region_txt} {extras}"),
+ ("Proveedor alternativo", f"{base} equivalent replacement supplier global {extras}"),
+ ]
+ variants = []
+ seen = set()
+ for label, query in raw_variants:
+ clean = re.sub(r"\s+", " ", query).strip()
+ if clean and clean.lower() not in seen:
+ seen.add(clean.lower())
+ variants.append({"label": label, "query": clean})
+ return variants
+
+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 build_provider_results_table(results, query, source_name):
+ rows = []
+ for result in results or []:
+ score, risk, evidence = score_provider_result(result, query)
+ rows.append({
+ "Score": score,
+ "Riesgo": risk,
+ "Fuente": source_name,
+ "Proveedor / resultado": result.get("title", "Proveedor"),
+ "Evidencia": evidence,
+ "Resumen": result.get("content", ""),
+ "URL": result.get("url", ""),
+ "Precio": "Requiere confirmacion",
+ })
+ if not rows:
+ return pd.DataFrame()
+ return pd.DataFrame(rows).sort_values(["Score"], ascending=False)
+
+def merge_provider_result_tables(tables):
+ frames = [df for df in tables if df is not None and not df.empty]
+ if not frames:
+ return pd.DataFrame()
+ merged = pd.concat(frames, ignore_index=True)
+ if "URL" in merged.columns:
+ merged = merged.sort_values("Score", ascending=False).drop_duplicates(subset=["URL"], keep="first")
+ return merged.sort_values(["Score", "Riesgo"], ascending=[False, True]).reset_index(drop=True)
+
+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)
+ 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["Cierre"] = radar_df["fecha_cierre"].fillna("").astype(str) if "fecha_cierre" in radar_df.columns else ""
+ 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
+
+if active_view == "🌐 Proveedores":
+ render_page_header(
+ "Sourcing global",
+ "Proveedores",
+ "Búsqueda de proveedores reales, técnicamente compatibles y con señales de precio.",
+ ["Free-tier primero", "Precios con evidencia", "Beta interna"]
+ )
+
+ 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)
+ active_source_count = int(tavily_ready) + int(brave_ready)
+ render_summary_strip([
+ {"label": "Motores activos", "value": active_source_count, "tone": "green" if active_source_count else "orange"},
+ {"label": "Catálogos gratis", "value": "Mouser/DigiKey/Octopart", "tone": "green"},
+ {"label": "Rutas de precio", "value": "Shopping + catálogos", "tone": "amber"},
+ {"label": "RFQ cargado", "value": "Si" if rfq_loaded else "No", "tone": "green" if rfq_loaded else "orange"},
+ ])
+
+ st.markdown("""
+
+
Estrategia beta free-tier
+
+ El motor separa la búsqueda en rutas de compatibilidad técnica, precio/stock,
+ distribuidores autorizados y proveedores alternativos. Así reducimos resultados bonitos pero poco útiles.
+
+
+ """, unsafe_allow_html=True)
+
+ if not rfq_loaded:
+ render_notice_panel(
+ "Búsqueda manual disponible",
+ "Puedes buscar proveedores sin RFQ cargado; al procesar un pliego, el sistema sugerirá consultas por renglón automáticamente.",
+ "amber",
+ )
+
+ provider_df = pd.DataFrame()
+ selected_query = ""
+ selected_row_label = "Búsqueda manual"
+ if rfq_loaded:
+ provider_df = normalize_technical_fields(normalize_history_columns(st.session_state.df_exportar.copy()))
+ if st.session_state.get("provider_source_mode") == "Busqueda manual":
+ st.session_state.provider_source_mode = "Búsqueda manual"
+
+ left_col, right_col = st.columns([0.62, 0.38])
+ with left_col:
+ st.markdown("#### 1. Producto a buscar")
+ source_mode = st.radio(
+ "Origen",
+ ["Renglón del RFQ", "Búsqueda manual"],
+ horizontal=True,
+ label_visibility="collapsed",
+ disabled=not rfq_loaded,
+ key="provider_source_mode",
+ )
+
+ if source_mode == "Renglón del RFQ" and not provider_df.empty:
+ def _provider_row_label(idx):
+ row = provider_df.iloc[idx]
+ term = str(row.get("termino_de_busqueda_corto", "") or row.get("ficha_tecnica_completa", "") or "")[:70]
+ return f"Renglón {row.get('renglon', idx + 1)} · {term or 'Sin descripción'}"
+
+ selected_idx = st.selectbox(
+ "Seleccionar renglón",
+ list(range(len(provider_df))),
+ format_func=_provider_row_label,
+ key="provider_selected_row",
+ )
+ selected_row = provider_df.iloc[selected_idx]
+ selected_query = provider_query_from_row(selected_row)
+ selected_row_label = _provider_row_label(selected_idx)
+ selected_code = clean_doc_value(selected_row.get("codigo_articulo", ""), default="S/C")
+ selected_brand = clean_doc_value(selected_row.get("marca_modelo_requerido", ""), default="No especificada")
+ selected_qty = clean_doc_value(selected_row.get("cantidad", ""), default="N/A")
+ st.markdown(f"""
+
+
Búsqueda sugerida por el RFQ
+
{escape(selected_query or 'Sin término detectado')}
+
+ Código ACP {escape(str(selected_code))}
+ Marca/modelo {escape(str(selected_brand))}
+ Cantidad {escape(str(selected_qty))}
+
+
+ """, unsafe_allow_html=True)
+
+ manual_query = st.text_input(
+ "Reemplazar o escribir búsqueda manual",
+ placeholder="Opcional: número de parte, marca, modelo o descripción técnica...",
+ key="provider_manual_query",
+ )
+ extra_terms = st.text_input(
+ "Afinar búsqueda",
+ placeholder="Opcional: stock, datasheet, authorized distributor, replacement...",
+ key="provider_extra_terms",
+ )
+ st.markdown("#### 2. Alcance de búsqueda")
+ c_qty, c_region, c_depth = st.columns([0.22, 0.38, 0.4])
+ with c_qty:
+ qty = st.number_input("Cantidad", min_value=1, value=1, step=1, key="provider_qty")
+ with c_region:
+ region = st.selectbox("Región", ["Global", "Estados Unidos", "Europa", "Asia", "Latinoamérica"], key="provider_region")
+ with c_depth:
+ depth_label = st.selectbox("Profundidad", ["Básica", "Avanzada"], key="provider_depth")
+
+ st.markdown("#### 3. Fuentes de sourcing")
+ source_options = provider_source_options(tavily_ready, brave_ready)
+ source_label_by_name = {source["name"]: source["label"] for source in source_options}
+ source_name_by_label = {source["label"]: source["name"] for source in source_options}
+ for source in source_options:
+ for legacy_label in source.get("legacy_labels", []):
+ source_name_by_label[legacy_label] = source["name"]
+ active_api_source_names = {source["name"] for source in source_options if source["has_api"] and source["active"]}
+ direct_source_names = {source["name"] for source in source_options if not source["has_api"] or not source["active"]}
+ valid_source_labels = [source["label"] for source in source_options]
+
+ source_strategy = st.radio(
+ "Estrategia de sourcing",
+ ["Balanceada", "Solo links gratis", "Ranking automático"],
+ horizontal=True,
+ help="Balanceada usa API si existe y deja links manuales listos. Solo links no consume API. Ranking automático prioriza motores con API.",
+ key="provider_source_strategy",
+ )
+ if source_strategy == "Solo links gratis":
+ default_source_names = ["Google B2B", "Google Shopping", "Mouser", "DigiKey", "Octopart", "Thomasnet", "Alibaba"]
+ elif source_strategy == "Ranking automático":
+ default_source_names = list(active_api_source_names) or ["Google B2B", "Mouser", "DigiKey", "Octopart"]
+ else:
+ default_source_names = list(active_api_source_names) + ["Google B2B", "Google Shopping", "Mouser", "DigiKey", "Octopart"]
+ default_source_labels = [source_label_by_name[name] for name in default_source_names if name in source_label_by_name]
+
+ previous_provider_sources = st.session_state.get("provider_search_sources")
+ strategy_changed = st.session_state.get("_provider_source_strategy_applied") != source_strategy
+ if strategy_changed:
+ st.session_state.provider_search_sources = default_source_labels
+ st.session_state["_provider_source_strategy_applied"] = source_strategy
+ previous_provider_sources = default_source_labels
+ if previous_provider_sources and any(value not in valid_source_labels for value in previous_provider_sources):
+ normalized_sources = []
+ for value in previous_provider_sources:
+ if value in source_label_by_name:
+ normalized_sources.append(source_label_by_name[value])
+ elif value in source_name_by_label:
+ normalized_sources.append(source_label_by_name.get(source_name_by_label[value], value))
+ elif value in valid_source_labels:
+ normalized_sources.append(value)
+ st.session_state.provider_search_sources = normalized_sources or default_source_labels
+ selected_search_sources = st.multiselect(
+ "Fuentes a usar",
+ valid_source_labels,
+ default=default_source_labels,
+ help="Elige pocas fuentes si quieres una revisión rápida. Las tarjetas inferiores indican si son automáticas o manuales.",
+ key="provider_search_sources",
+ )
+ selected_source_names = [source_name_by_label.get(label, label) for label in selected_search_sources]
+ selected_api_sources = [name for name in selected_source_names if name in active_api_source_names]
+ selected_direct_sources = [name for name in selected_source_names if name in direct_source_names]
+ selected_inactive_api_sources = [
+ name for name in selected_source_names
+ if name not in active_api_source_names and any(source["name"] == name and source["has_api"] for source in source_options)
+ ]
+
+ st.markdown(provider_source_status_html(source_options, selected_source_names), unsafe_allow_html=True)
+ if selected_api_sources and selected_direct_sources:
+ st.caption(f"Modo mixto: ranking automático con {', '.join(selected_api_sources)} y validación manual con {', '.join(selected_direct_sources)}.")
+ elif selected_api_sources:
+ st.caption(f"Ranking automático activo con {', '.join(selected_api_sources)}.")
+ elif selected_direct_sources:
+ st.caption(f"Modo manual: {', '.join(selected_direct_sources)} abrirán links directos para revisar precio, stock y evidencia.")
+ if selected_inactive_api_sources:
+ st.caption(f"Pendiente por API: {', '.join(selected_inactive_api_sources)}. Puedes seleccionarlas para ver su estado, pero no generarán ranking hasta configurar la key.")
+
+ base_query = manual_query.strip() or selected_query.strip()
+ query_variants = provider_query_variants(base_query, extra_terms, region, qty)
+ final_query = query_variants[0]["query"] if query_variants else ""
+ st.markdown(f"""
+
+
Consulta preparada
+
{escape(base_query or 'Sin búsqueda')}
+
{escape(selected_row_label)}
{escape(final_query or 'Completa una búsqueda para continuar.')}
+
+ """, unsafe_allow_html=True)
+
+ if query_variants:
+ chips_html = "".join(
+ f"{escape(v['label'])}" for v in query_variants
+ )
+ st.markdown(f"{chips_html}
", unsafe_allow_html=True)
+
+ button_label = "Generar ranking automático" if selected_api_sources else "Preparar links de búsqueda"
+ run_search = st.button(button_label, type="primary", use_container_width=True)
+
+ with right_col:
+ render_table_toolbar(
+ "Links de verificación manual",
+ "Accesos rápidos para validar precio, stock, datasheets y evidencia sin consumir API.",
+ ["Free-tier", "Catálogos", "Web"],
+ )
+ links_query = manual_query.strip() or selected_query.strip()
+ sources = provider_source_catalog(links_query)
+ selected_names_for_cards = set(locals().get("selected_source_names", []))
+ if selected_names_for_cards:
+ sources = [source for source in sources if source.get("name") in selected_names_for_cards]
+ sources = [source for source in sources if source.get("url")]
+ if links_query:
+ source_cards = []
+ for source in sources:
+ source_url = source.get("url", "")
+ action_html = f"Consultar"
+ source_initial = escape(str(source.get("name", "F"))[:2].upper())
+ source_cards.append(
+ f''
+ f'
{source_initial}
'
+ f'
{escape(source.get("name", "Fuente"))}
'
+ f'
{escape(source.get("kind", ""))}
'
+ f'
{escape(source.get("status", ""))}
'
+ f'
{escape(source.get("detail", ""))}
'
+ f'
{action_html}
'
+ )
+ if source_cards:
+ st.markdown(f"{''.join(source_cards)}
", unsafe_allow_html=True)
+ else:
+ render_empty_state("Sin links manuales seleccionados", "Selecciona Google, catálogos o directorios para abrir búsquedas externas sin consumir API.")
+ else:
+ render_empty_state("Sin búsqueda", "Selecciona un renglón o escribe un producto para activar las fuentes.")
+
+ with st.expander("Opciones pagas para la propuesta"):
+ render_notice_panel(
+ "Motores recomendados cuando la empresa apruebe presupuesto",
+ "SerpApi/DataForSEO para precios web y APIs oficiales de Mouser, DigiKey y Nexar/Octopart para stock, datasheets y distribuidores verificables.",
+ "blue",
+ )
+
+ if run_search:
+ if not base_query:
+ st.error("Escribe una búsqueda o selecciona un renglón con descripción.")
+ elif not selected_search_sources:
+ st.error("Selecciona al menos una fuente de búsqueda.")
+ elif not selected_api_sources:
+ st.info("Listo: usa los links de verificación manual para revisar proveedores, precios, stock y datasheets. Para ranking automático, activa Tavily o Brave Search.")
+ else:
+ with st.status("Buscando proveedores con fuentes free-tier...", expanded=True):
+ try:
+ result_tables = []
+ for variant in query_variants:
+ st.write(f"Ruta: {variant['label']}")
+ if "Tavily" in selected_api_sources:
+ t_client = TavilyClient(api_key=st.session_state.tavily_key)
+ res_tavily = t_client.search(
+ query=variant["query"],
+ search_depth="advanced" if depth_label == "Avanzada" else "basic",
+ max_results=4,
+ )
+ result_tables.append(build_provider_results_table(res_tavily.get("results", []), base_query, f"Tavily - {variant['label']}"))
+ if "Brave Search" in selected_api_sources:
+ brave_results = search_brave_providers(variant["query"], count=4)
+ result_tables.append(build_provider_results_table(brave_results, base_query, f"Brave - {variant['label']}"))
+ provider_results = merge_provider_result_tables(result_tables)
+ st.session_state.provider_results = provider_results
+ st.session_state.provider_last_query = base_query
+ st.toast("Búsqueda de proveedores completada", icon="🔎")
+ except Exception as e:
+ st.session_state.provider_results = pd.DataFrame()
+ 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 preliminar de proveedores",
+ "Candidatos organizados por score, riesgo y evidencia encontrada.",
+ [f"{len(provider_results)} resultados", st.session_state.get("provider_last_query", "Búsqueda")],
+ )
+ st.dataframe(
+ provider_results,
+ column_config={
+ "Score": st.column_config.ProgressColumn("Score", min_value=0, max_value=100),
+ "Riesgo": st.column_config.TextColumn("Riesgo", width="small"),
+ "Fuente": st.column_config.TextColumn("Fuente", width="small"),
+ "Proveedor / resultado": st.column_config.TextColumn("Proveedor / resultado", width="large"),
+ "Evidencia": st.column_config.TextColumn("Evidencia", width="medium"),
+ "Resumen": st.column_config.TextColumn("Resumen", width="large"),
+ "URL": st.column_config.LinkColumn("URL"),
+ "Precio": st.column_config.TextColumn("Precio", width="medium"),
+ },
+ use_container_width=True,
+ hide_index=True,
+ )
+
+ 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''
+ f'
{escape(str(result_row.get("Proveedor / resultado", "Proveedor"))[:80])}
'
+ f'
Score: {escape(str(result_row.get("Score", "")))} | Riesgo: {escape(str(result_row.get("Riesgo", "")))}
'
+ f'
{escape(str(result_row.get("Resumen", ""))[:180])}
'
+ f'
Revisar evidencia'
+ f'
',
+ unsafe_allow_html=True,
+ )
+ else:
+ render_empty_state(
+ "Aún no hay ranking de proveedores",
+ "Ejecuta una búsqueda free-tier para generar candidatos. Los links directos ya están disponibles sin consumir créditos."
+ )
+
+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("", 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()
+ 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}")
+ 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.",
+ )
+ 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 = st.columns([0.18, 0.18, 0.22, 0.42])
+ 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:
+ search_radar = st.text_input("Buscar radar", placeholder="Número, objeto o categoría")
+
+ 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()
+ 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 search_radar:
+ search_l = search_radar.lower()
+ radar_df = radar_df[
+ radar_df.astype(str).apply(lambda row: row.str.lower().str.contains(search_l, na=False)).any(axis=1)
+ ]
+ if not radar_df.empty:
+ radar_df = enrich_radar_with_history(radar_df, hist_radar_df)
+ radar_df = sort_radar_by_dates(radar_df)
+
+ 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": "Ordenada por cierre", "tone": "blue"},
+ {"label": "Decision", "value": "Analizar oportunidad", "tone": "green"},
+ {"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",
+ "Radar ordenado por fecha de cierre, prioridad e historial similar.",
+ [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={
+ "RFQ": st.column_config.TextColumn("RFQ", width="small"),
+ "Objeto": st.column_config.TextColumn("Objeto", width="large"),
+ "Cierre": st.column_config.TextColumn("Cierre", width="medium"),
+ "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}")
+
+ st.markdown(f"""
+
+
Licitacion seleccionada
+
{escape(numero_radar)}
+
{escape(objeto_radar)}
+
+ """, unsafe_allow_html=True)
+ 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)
- st.divider()
+ 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])
@@ -567,20 +2964,22 @@ with tab_acp:
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:
- # buscar la licitacion insertada
- seg_df_new = db.get_seguimientos()
- 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")
- st.toast(f"✅ Licitación {sli_rfq} añadida al monitor.")
- st.rerun()
+ 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:
@@ -592,26 +2991,24 @@ with tab_acp:
st.link_button("Abrir SLI de la ACP", "https://sli.pancanal.com", use_container_width=True)
with col_monitor:
- if seg_df.empty:
- st.markdown("""
-
-
🏛️
-
Sin licitaciones registradas
-
Usa el formulario para registrar la primera licitación ACP en seguimiento.
-
- """, unsafe_allow_html=True)
- 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]
-
- if seg_view.empty:
- st.info("No hay licitaciones en ese estado.")
+ 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')
@@ -623,7 +3020,7 @@ with tab_acp:
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 descripcion"))
+ objeto_safe = escape(str(objeto or "Sin descripción"))
resp_safe = escape(str(resp or "Sin responsable"))
estado_safe = escape(str(estado or ""))
@@ -763,10 +3160,11 @@ with tab_acp:
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)
- st.toast(f"✅ Estado actualizado a '{nuevo_estado}'")
- st.rerun()
+ 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])
@@ -797,7 +3195,7 @@ with tab_acp:
estatus_sli.upper(), estatus_sli)
# --- WATCHDOG DE ENMIENDAS / CAMBIOS ---
- df_hist_prev = db.get_historial_seguimiento(lic_id)
+ 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)
@@ -826,8 +3224,9 @@ with tab_acp:
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")
+ 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]:
@@ -869,10 +3268,11 @@ with tab_acp:
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)
- st.toast(f"Seguimiento {num_lic} eliminado.", icon="🗑️")
- st.rerun()
+ 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("", unsafe_allow_html=True)
@@ -898,11 +3298,11 @@ with tab_acp:
with st.spinner("🎓 El Asesor Carlos Méndez está evaluando el estatus..."):
try:
import google.generativeai as genai
- genai.configure(api_key=st.session_state.gemini_key)
+ genai.configure(api_key=st.session_state.gemini_key, transport="rest")
modelo_seg = genai.GenerativeModel('gemini-2.5-flash')
hist_seg_str = ""
- df_hist_seg = db.get_historial_seguimiento(lic_id)
+ 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:
@@ -972,7 +3372,7 @@ Sé directo y profesional, escribe máximo 100 palabras en total. ¡Usa tu ampli
""", unsafe_allow_html=True)
# Historial colapsable
- df_hist_seg = db.get_historial_seguimiento(lic_id)
+ 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():
@@ -986,8 +3386,14 @@ Sé directo y profesional, escribe máximo 100 palabras en total. ¡Usa tu ampli
""", unsafe_allow_html=True)
st.markdown("---")
-with tab_hist:
- df_history = get_user_history(st.session_state.username)
+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)
@@ -1000,26 +3406,26 @@ with tab_hist:
hk3.metric("Última Actividad", ultima_h)
st.divider()
- if df_history.empty:
- st.markdown("""
-
-
📂
-
Sin registros aun
-
Procesa tu primer pliego para comenzar a construir el historial.
-
- """, unsafe_allow_html=True)
+ 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:
- st.caption(f"Mostrando {len(df_filtrado)} de {len(df_history)} licitaciones — Selecciona una fila para ver opciones")
- ev_hist = st.dataframe(
- df_filtrado,
+ 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",
@@ -1036,7 +3442,7 @@ with tab_hist:
# --- WORKSPACES GUARDADOS ---
es_gerencia = st.session_state.role == "Gerencia"
- df_ws_list = db.get_all_workspaces(st.session_state.username, all_users=es_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.")
@@ -1059,10 +3465,11 @@ with tab_hist:
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)
- st.toast(f"Workspace '{ws_lic}' eliminado.")
- st.rerun()
+ 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()
@@ -1099,10 +3506,11 @@ with tab_hist:
""", unsafe_allow_html=True)
st.markdown("
", 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)
- st.toast(f"Registro {lic_sel} eliminado.", icon="🗑️")
- st.rerun()
+ 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
@@ -1116,194 +3524,462 @@ with tab_hist:
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)
- st.toast("Historial borrado completamente.", icon="✅")
- st.rerun()
-
-with tab_main:
- 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"
-
- st.markdown("
", unsafe_allow_html=True)
- st.markdown(f"""
-
- Centro de Mando
-
- Bienvenido, {st.session_state.username}
- Sistema de Inteligencia de Sourcing — Proyelec
- """, unsafe_allow_html=True)
-
- k1, k2, k3, k4 = st.columns(4)
- k1.metric("Licitaciones Procesadas", total_lic)
- k2.metric("Renglones Analizados", total_reng)
- k3.metric("Ultima Actividad", ultima_act)
- k4.metric("Motor IA", "Gemini 2.5 Flash")
-
- st.markdown("
", unsafe_allow_html=True)
- st.markdown("Para comenzar, sube el pliego en el panel izquierdo y presiona Procesar con IA
", unsafe_allow_html=True)
-
- c1, c2, c3 = st.columns(3)
- with c1:
- st.markdown("""
-
-
📄
-
1. Carga el Pliego
-
Sube el PDF oficial de la licitacion. Soporta pliego principal y anexos tecnicos simultaneamente.
-
- """, unsafe_allow_html=True)
- with c2:
- st.markdown("""
-
-
🧠
-
2. Analisis con IA
-
Gemini extrae renglones, codigos, cantidades y cruza automaticamente con el historico de precios.
-
- """, unsafe_allow_html=True)
- with c3:
- st.markdown("""
-
-
🎯
-
3. Sourcing Global
-
Busca los mejores proveedores, genera RFQs y organiza cotizaciones en tu bandeja inteligente.
-
- """, unsafe_allow_html=True)
-
- else:
- df_render = st.session_state.df_exportar
- cg_render = st.session_state.cg
-
- t1, t2, t3, t4 = st.tabs(["📋 1. Matriz de Productos", "📨 2. Emisión de RFQs", "🤖 3. Centro de Mando AI", "📈 4. Análisis de Costos"])
-
- with t1:
- st.markdown(f"""
-
-
-
Nº Licitación
-
{cg_render.get('numero_licitacion', 'N/A')}
-
-
-
Lugar Entrega
-
{cg_render.get('lugar_de_entrega', 'N/A')}
-
-
-
Tiempo Entrega
-
{cg_render.get('tiempo_de_entrega_global', 'N/A')}
-
-
-
Garantía
-
{cg_render.get('garantia_exigida', 'N/A')}
-
-
-
Req. Prop. Técnica
-
{cg_render.get('propuesta_tecnica_requerida', 'N/A')}
-
-
-
Validez de Oferta
-
{cg_render.get('validez_de_la_oferta', 'N/A')}
-
-
- """, unsafe_allow_html=True)
- st.divider()
-
- col_config = {
- "renglon": st.column_config.TextColumn("Renglón", width="small"),
- "codigo_articulo": st.column_config.TextColumn("Código", width="medium"),
- "cantidad": st.column_config.NumberColumn("Cant.", format="%d"),
- "precio_comp_hist": st.column_config.NumberColumn("Mejor Comp.", format="$ %.2f"),
- "precio_proy_hist": st.column_config.NumberColumn("Proyelec", format="$ %.2f"),
- "margen_$": st.column_config.NumberColumn("Margen", format="$ %.2f"),
- "ficha_tecnica_completa": None, "termino_de_busqueda_corto": None
- }
-
- col_exp, col_xls = st.columns([0.7, 0.3])
- with col_exp:
- st.caption("Selecciona una fila para ver el detalle y buscar proveedores.")
+ 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"{escape(str(label))}{escape(str(value))}
"
+ for label, value in hero_rows
+ )
+ st.markdown(f"""
+
+
+
{escape(role_profile["title"])}
+
Procura operativa
+
Hola {escape(st.session_state.username)}. {escape(role_profile["scope"])}
+
+ Rol: {escape(current_role_mode())}
+ Última actividad: {escape(str(ultima_act))}
+ Motor IA {'activo' if api_online_now else 'apagado'}
+
+
+
+ {hero_rows_html}
+
+
+ """, 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("Rutas de trabajo
", 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"""
+
+
{escape(title)}
+
{escape(body)}
+
+ """, 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("Lectura rápida
", unsafe_allow_html=True)
+ d1, d2 = st.columns([0.55, 0.45])
+ with d1:
+ st.markdown(f"""
+
+
Prioridad recomendada
+
RFQ → Proveedores → Seguimiento
+
El flujo operativo queda separado por módulos para que cada cargo vea solo las decisiones que le corresponden.
+
+ """, unsafe_allow_html=True)
+ with d2:
+ st.markdown(f"""
+
+
Estado operativo
+
Licitaciones prioritarias{radar_prioritarias if role_can("radar") else "N/A"}
+
Monitor activo{en_eval_kpi}
+
Alcance{escape(role_profile["badge"])}
+
+ """, unsafe_allow_html=True)
+
+ st.markdown("Acceso del rol actual
", unsafe_allow_html=True)
+ render_access_snapshot()
+
+ if not df_hist_kpi.empty:
+ st.markdown("Actividad reciente
", 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_view = st.radio(
+ "Vista de licitacion",
+ ["📋 1. Matriz de Productos", "📨 2. Emisión de RFQs", "🤖 3. Centro de Mando AI", "📈 4. Análisis de Costos"],
+ 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"{escape(evidencia_presencia)}" 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"""
+
+
+
+ Nº Licitación
+ {escape(numero_licitacion)}
+
+
+ Garantía
+ {escape(garantia_exigida)}
+
+
+ Lugar Entrega
+ {escape(lugar_entrega)}
+
+
+ Tiempo Entrega
+ {escape(tiempo_entrega)}
+
+
+ Req. Prop. Técnica
+ {escape(propuesta_tecnica)}
+
+
+ Validez de Oferta
+ {escape(validez_oferta)}
+
+
+
+
+ """, 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)
- event = st.dataframe(df_render, column_config=col_config, use_container_width=True, hide_index=True, on_select="rerun", selection_mode="single-row")
-
- if len(event.selection.rows) > 0:
- fila = df_render.iloc[event.selection.rows[0]]
- termino_google = urllib.parse.quote(str(fila.get('termino_de_busqueda_corto', '')))
-
- # Procesar checkboxes para que se vean como una Checklist interactiva
- texto_ficha = str(fila.get('ficha_tecnica_completa', 'Sin descripción'))
- texto_ficha = texto_ficha.replace('- [ ]', '☐').replace('- [x]', '☑').replace('- [X]', '☑')
-
- # --- AQUÍ ESTÁ EL FIX DEL MARKDOWN CON LOS BOTONES DEL TAMAÑO CORRECTO ---
- st.markdown(f"""
-
-
Renglón {fila.get('renglon', '-')} | Cód: {fila.get('codigo_articulo', 'N/A')}
-
{texto_ficha}
-
-
- """, unsafe_allow_html=True)
-
- st.write("")
- if st.button(f"Escaneo profundo - Renglon {fila.get('renglon')}", type="primary"):
- if not st.session_state.tavily_key: st.error("⚠️ Configura la API Key de Tavily en el panel izquierdo.")
- else:
- with st.status(f"Analizando bases de datos B2B para {fila.get('termino_de_busqueda_corto')}...", expanded=True):
- try:
- t_client = TavilyClient(api_key=st.session_state.tavily_key)
- res_tavily = t_client.search(query=f"B2B supplier distributor {fila.get('termino_de_busqueda_corto')} industrial parts", search_depth="advanced", max_results=3)
- st.toast("✅ Búsqueda completada", icon="🔍")
-
- st.markdown("#### 🌐 Mejores Proveedores Detectados:")
- cols = st.columns(3)
- for i, res in enumerate(res_tavily.get('results', [])):
- with cols[i % 3]:
- st.markdown(f"""
-
-
{res.get('title', 'Supplier')}
-
{res.get('content', '')[:120]}...
-
Visitar Website ↗
-
- """, unsafe_allow_html=True)
- except Exception as e: st.error(f"Fallo en scraping: {e}")
-
- st.divider()
- st.markdown("#### 📄 Generador de Fichas Técnicas Automáticas")
- st.caption("Usa la IA para redactar una Ficha Técnica estructurada a partir del contexto del pliego.")
- if st.button(f"Crear ficha tecnica - Renglon {fila.get('renglon')}", type="secondary"):
- if not st.session_state.gemini_key: st.error("⚠️ Verifica tus API Keys en la configuración.")
- else:
- with st.status("Redactando ficha técnica profesional...", expanded=True):
- try:
- payload = {
- "username": st.session_state.username,
- "licitacion": str(cg_render.get('numero_licitacion', '')),
- "codigo_renglon": str(fila.get('codigo_articulo', '')),
- "pliego_context": json.dumps(cg_render),
- "items_context": fila.to_json(),
- "gemini_key": st.session_state.gemini_key
- }
- res_ficha = requests.post(f"{API_URL_BASE}/generar-ficha", data=payload, headers=API_HEADERS)
- if res_ficha.status_code == 200:
- data_ficha = res_ficha.json()
- from_cache = data_ficha.get('from_cache', False)
- label = "✅ Ficha cargada desde caché (sin gasto de tokens)" if from_cache else "✅ ¡Ficha Técnica Generada y guardada!"
- st.success(label)
- st.markdown("", unsafe_allow_html=True)
- st.markdown(data_ficha.get("datasheet_md", ""))
- st.markdown("
", unsafe_allow_html=True)
- else:
- st.error("Error al generar ficha: " + res_ficha.text)
- except Exception as e:
- st.error(f"Fallo en generación: {e}")
-
- with t2:
+ 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", "
")
+ 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)
+ provider_query = provider_query_from_row(fila)
+
+ st.markdown(f"""
+
+
+
+
Renglón seleccionado
+
Renglón {escape(str(fila.get('renglon', '-')))} | {escape(str(fila.get('codigo_articulo', 'N/A')))}
+
{escape(hist_source)}
+
+
+
Checklist del renglón
+ {status_checklist}
+
{escape(row_flags_text)}
+
Especificaciones detectadas
+
{spec_checklist}
+
Texto técnico completo del renglón
+
{texto_ficha_html}
+
+ Fichas técnicas: pendiente para la próxima fase, conectada a búsqueda de datasheets, validación técnica y generación controlada.
+
+
+ """, 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):
+ st.session_state.provider_source_mode = "Búsqueda manual"
+ st.session_state.provider_manual_query = provider_query
+ st.session_state.provider_extra_terms = "price stock datasheet authorized distributor"
+ st.session_state.active_view = "🌐 Proveedores"
+ st.rerun()
+
+ if result_view == "📨 2. Emisión de RFQs":
# ================================================================
# 📨 MOTOR DE RFQs PROFESIONALES — Powered by Gemini AI
# ================================================================
@@ -1347,7 +4023,7 @@ with tab_main:
with st.spinner("📝 Gemini está redactando el RFQ profesional..."):
try:
import google.generativeai as genai
- genai.configure(api_key=st.session_state.gemini_key)
+ genai.configure(api_key=st.session_state.gemini_key, transport="rest")
modelo_rfq = genai.GenerativeModel('gemini-2.5-flash')
cg = cg_render
@@ -1409,87 +4085,142 @@ Use professional business English/Spanish. Format tables using plain text dashes
resp_rfq = modelo_rfq.generate_content(prompt_rfq)
cuerpo_rfq = resp_rfq.text.strip()
- # Guardar en session state para poder editar y descargar
- st.session_state['rfq_generado'] = cuerpo_rfq
- st.session_state['rfq_subject'] = f"[PROY-ACP-{cg.get('numero_licitacion','')}] Request for Quotation — {renglon_seleccionado}"
+ # 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 — Puedes editar el texto antes de descargar")
-
- rfq_editado = st.text_area(
- "📋 Cuerpo del RFQ (editable):",
- value=st.session_state.rfq_generado,
- height=500,
- key="rfq_editor"
- )
-
- st.markdown("---")
-
- # Construir el .eml con el Excel adjunto
- col_dl1, col_dl2 = st.columns([1, 1])
-
- with col_dl1:
- # Generar .eml
- 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()
- msg['Subject'] = st.session_state.get('rfq_subject', 'Request for Quotation')
- msg['From'] = st.session_state.email_user or "procura@proyelec.com"
- msg['To'] = ""
-
- # Cuerpo del email
- msg.attach(MIMEText(rfq_editado, 'plain', 'utf-8'))
-
- # Adjuntar Excel de evaluación si existe
- 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()
-
- st.download_button(
- label="📧 DESCARGAR RFQ (.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
- )
-
- if not excel_adjunto:
- st.warning("⚠️ Excel de evaluación no encontrado. Sube el archivo `PRY-FRPCL-003 Evaluación de Cumplimiento del Proveedor.xlsx` a tu Space para adjuntarlo automáticamente.")
-
- except Exception as e:
- st.error(f"❌ Error generando .eml: {e}")
-
- with col_dl2:
- # Copiar al portapapeles (texto plano)
- st.download_button(
- label="📄 DESCARGAR TEXTO (.txt)",
- data=rfq_editado.encode('utf-8'),
- file_name=f"RFQ_{cg_render.get('numero_licitacion','')}.txt",
- mime="text/plain",
- use_container_width=True
- )
-
-
- with t3:
+ # 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
# ================================================================
@@ -1505,12 +4236,18 @@ Use professional business English/Spanish. Format tables using plain text dashes
""", unsafe_allow_html=True)
- tab_neg, tab_cop = st.tabs(["⚡ Agente Negociador", "💬 Procura Copilot"])
+ ai_view = st.radio(
+ "Modo AI",
+ ["⚡ Agente Negociador", "💬 Procura Copilot"],
+ horizontal=True,
+ label_visibility="collapsed",
+ key="ai_view",
+ )
# ──────────────────────────────────────────────
# ⚡ ASESOR SENIOR DE PROCURA
# ─���────────────────────────────────────────────
- with tab_neg:
+ if ai_view == "⚡ Agente Negociador":
st.markdown("""
@@ -1564,7 +4301,7 @@ Use professional business English/Spanish. Format tables using plain text dashes
with st.spinner("🎓 El Asesor Senior está analizando tu situación..."):
try:
import google.generativeai as genai
- genai.configure(api_key=st.session_state.gemini_key)
+ genai.configure(api_key=st.session_state.gemini_key, transport="rest")
modelo_neg = genai.GenerativeModel('gemini-2.5-flash')
cg_ctx = st.session_state.get('cg', {})
@@ -1651,7 +4388,7 @@ Responde con el siguiente formato estructurado en Markdown:
# ──────────────────────────────────────────────
# 💬 PROCURA COPILOT
# ──────────────────────────────────────────────
- with tab_cop:
+ if ai_view == "💬 Procura Copilot":
st.markdown("""
Hazle preguntas en lenguaje natural al Copilot sobre la licitación activa, los renglones
@@ -1690,7 +4427,7 @@ Responde con el siguiente formato estructurado en Markdown:
with st.spinner("Analizando..."):
try:
import google.generativeai as genai
- genai.configure(api_key=st.session_state.gemini_key)
+ genai.configure(api_key=st.session_state.gemini_key, transport="rest")
modelo_cop = genai.GenerativeModel('gemini-2.5-flash')
# Construir contexto de la licitación activa
@@ -1740,16 +4477,70 @@ Responde de forma clara y profesional. Si puedes dar un número o dato exacto de
- with t4:
- st.markdown("### 📈 Visualización de Costos")
- if 'precio_comp_hist' in df_render.columns and not df_render['precio_comp_hist'].isna().all():
- fig = go.Figure()
- fig.add_trace(go.Bar(x=df_render['renglon'], y=df_render['precio_comp_hist'], name='Competencia', marker_color='#30363D'))
- fig.add_trace(go.Bar(x=df_render['renglon'], y=df_render['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)
- 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.")
+ 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')])