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"", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"]*>", "\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(""" +
+
+ + + + +
+
Acceso restringido. Las sesiones se restauran al refrescar y se limpian al cerrar sesion.
+
+ """, 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"", 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""" +
+
+
{escape(str(eyebrow))}
+

{escape(str(title))}

+
{escape(str(subtitle))}
+
+ {meta_html} +
+ """, 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""" +
+
{label}
+
{value}
+
+ """, 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""" +
+
+
{escape(str(title))}
+
{escape(str(subtitle))}
+
+ {meta_html} +
+ """, 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'
' + f'
{label}{value}
' + ) + 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'))}
+
+ + {meta_rows} +
+
{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""" +
+
+
+
Proyelec International
+
Request for Quotation
+
{escape(str(subject or 'Request for Quotation'))}
+
+
Preview
+
+
{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)} +
+
+
+
+ Encargado ACP + {escape(encargado_licitacion)} +
+
+ Correo + {escape(correo_encargado)} +
+
+ Teléfono + {escape(telefono_encargado)} +
+
+ Presencia local + {escape(presencia_local)}{evidencia_presencia_html} +
+
+ Empresa sugerida + {escape(participacion_sugerida)} + {escape(presencia_nota)} +
+
+
+ """, 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}
-
- 🔍 Google B2B - ⚙️ ThomasNet - 🛒 Alibaba -
-
- """, 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')])