Spaces:
Runtime error
Runtime error
Upload 12 files
Browse files- .gitattributes +1 -0
- ACP DATA LIC PASADAS v2_2.xlsx +3 -0
- Dockerfile +31 -0
- README.md +3 -3
- api.py +716 -0
- app.py +1156 -0
- config.toml +8 -0
- crypto.py +52 -0
- database.py +465 -0
- entrypoint.sh +12 -0
- proyelec_logo.png +0 -0
- requirements.txt +16 -0
- style.css +372 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
ACP[[:space:]]DATA[[:space:]]LIC[[:space:]]PASADAS[[:space:]]v2_2.xlsx filter=lfs diff=lfs merge=lfs -text
|
ACP DATA LIC PASADAS v2_2.xlsx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f4bb7cc4520ec8508c72dfca9e9875d69b7680aa06dc45a2b8e789aaa4500ce2
|
| 3 |
+
size 595577
|
Dockerfile
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Usamos la imagen oficial de Playwright que ya tiene Chromium preinstalado
|
| 2 |
+
FROM mcr.microsoft.com/playwright/python:v1.44.0-jammy
|
| 3 |
+
|
| 4 |
+
# Establecer la carpeta de trabajo dentro del servidor
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Copiar el archivo de requerimientos primero
|
| 8 |
+
COPY requirements.txt .
|
| 9 |
+
|
| 10 |
+
# Instalar los requerimientos de Python
|
| 11 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
+
|
| 13 |
+
# Asegurar que Chromium coincida con la version de Playwright instalada por pip
|
| 14 |
+
RUN playwright install chromium
|
| 15 |
+
|
| 16 |
+
# Copiar todo el resto del código del proyecto al servidor
|
| 17 |
+
COPY . .
|
| 18 |
+
|
| 19 |
+
# Crear configuración de Streamlit para Hugging Face (Evita AxiosError 403 / CORS)
|
| 20 |
+
RUN mkdir -p .streamlit && \
|
| 21 |
+
echo "[server]\nport = 7860\naddress = \"0.0.0.0\"\nenableCORS = false\nenableXsrfProtection = false\n\n[browser]\ngatherUsageStats = false" > .streamlit/config.toml
|
| 22 |
+
|
| 23 |
+
# Dar permisos de ejecución al script de arranque
|
| 24 |
+
RUN chmod +x entrypoint.sh
|
| 25 |
+
|
| 26 |
+
# Exponer los puertos necesarios (7860 es el que exige Hugging Face)
|
| 27 |
+
EXPOSE 7860
|
| 28 |
+
EXPOSE 8000
|
| 29 |
+
|
| 30 |
+
# Comando para iniciar todo
|
| 31 |
+
CMD ["./entrypoint.sh"]
|
README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: green
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Procura Ai Acp
|
| 3 |
+
emoji: 📈
|
| 4 |
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
api.py
ADDED
|
@@ -0,0 +1,716 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException, Form, BackgroundTasks, Header, Depends
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from typing import List
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
import google.generativeai as genai
|
| 6 |
+
import tempfile
|
| 7 |
+
import os
|
| 8 |
+
import json
|
| 9 |
+
import imaplib
|
| 10 |
+
import email
|
| 11 |
+
from email.header import decode_header
|
| 12 |
+
import re
|
| 13 |
+
import hashlib
|
| 14 |
+
import time
|
| 15 |
+
import pandas as pd
|
| 16 |
+
import io
|
| 17 |
+
from urllib.parse import urljoin
|
| 18 |
+
from dotenv import load_dotenv
|
| 19 |
+
import database as db
|
| 20 |
+
import logging
|
| 21 |
+
from logging.handlers import RotatingFileHandler
|
| 22 |
+
from bs4 import BeautifulSoup
|
| 23 |
+
import sys
|
| 24 |
+
import asyncio
|
| 25 |
+
import crypto
|
| 26 |
+
|
| 27 |
+
if sys.platform == "win32":
|
| 28 |
+
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
| 29 |
+
load_dotenv()
|
| 30 |
+
|
| 31 |
+
# --- 1. LOGGING CON ROTACIÓN (max 2MB, 3 backups) ---
|
| 32 |
+
log_handler = RotatingFileHandler('backend.log', maxBytes=2*1024*1024, backupCount=3, encoding='utf-8')
|
| 33 |
+
log_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
| 34 |
+
logger = logging.getLogger(__name__)
|
| 35 |
+
logger.setLevel(logging.INFO)
|
| 36 |
+
logger.addHandler(log_handler)
|
| 37 |
+
|
| 38 |
+
# --- 2. SEGURIDAD Y CIFRADO ---
|
| 39 |
+
# Se utiliza el módulo centralizado `crypto.py`
|
| 40 |
+
INTERNAL_API_TOKEN = os.getenv("INTERNAL_API_TOKEN", "default-dev-token")
|
| 41 |
+
|
| 42 |
+
def verify_internal_token(x_internal_token: str = Header(None)):
|
| 43 |
+
if x_internal_token != INTERNAL_API_TOKEN:
|
| 44 |
+
raise HTTPException(status_code=403, detail="Acceso denegado: Token interno inválido.")
|
| 45 |
+
return x_internal_token
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# --- 3. APP FASTAPI ---
|
| 49 |
+
app = FastAPI(title="Proyelec Core API v6.1")
|
| 50 |
+
|
| 51 |
+
app.add_middleware(
|
| 52 |
+
CORSMiddleware,
|
| 53 |
+
allow_origins=["http://localhost:8501", "http://127.0.0.1:8501"],
|
| 54 |
+
allow_credentials=True,
|
| 55 |
+
allow_methods=["*"],
|
| 56 |
+
allow_headers=["*"],
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
@app.get("/")
|
| 60 |
+
def estado():
|
| 61 |
+
return {"status": "Online", "engine": "Proyelec Core v6.1 — Token-Optimized"}
|
| 62 |
+
|
| 63 |
+
# --- 4. PROMPT ANALISTA DE PLIEGOS (Multi-documento) ---
|
| 64 |
+
PROMPT_ANALISTA_MULTI = """
|
| 65 |
+
Eres un Analista Senior de Procura. Analiza TODO el conjunto de documentos proporcionados (Pliego principal y Anexos Técnicos).
|
| 66 |
+
Cruza la información de todos los documentos para obtener descripciones técnicas exactas.
|
| 67 |
+
Responde ÚNICAMENTE con el siguiente JSON estricto, sin texto adicional:
|
| 68 |
+
{"condiciones_generales": {"numero_licitacion": "", "tiempo_de_entrega_global": "", "garantia_exigida": "", "lugar_de_entrega": "", "validez_de_la_oferta": "", "propuesta_tecnica_requerida": "Si/No"},
|
| 69 |
+
"items": [{"renglon": "", "codigo_articulo": "", "cantidad": 0, "unidad_de_medida": "", "ficha_tecnica_completa": "", "termino_de_busqueda_corto": ""}]}
|
| 70 |
+
"""
|
| 71 |
+
|
| 72 |
+
@app.post("/api/v1/analizar-pliego")
|
| 73 |
+
async def analizar_pliego(archivos_pdf: List[UploadFile] = File(...), gemini_key: str = Form(...), _token: str = Depends(verify_internal_token)):
|
| 74 |
+
api_key_clean = gemini_key.strip()
|
| 75 |
+
try:
|
| 76 |
+
genai.configure(api_key=api_key_clean)
|
| 77 |
+
archivos_subidos = []
|
| 78 |
+
|
| 79 |
+
for archivo in archivos_pdf:
|
| 80 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
|
| 81 |
+
content = await archivo.read()
|
| 82 |
+
tmp.write(content)
|
| 83 |
+
tmp_path = tmp.name
|
| 84 |
+
|
| 85 |
+
uploaded_file = genai.upload_file(path=tmp_path, mime_type="application/pdf")
|
| 86 |
+
archivos_subidos.append(uploaded_file)
|
| 87 |
+
os.remove(tmp_path)
|
| 88 |
+
|
| 89 |
+
# gemini-2.5-flash para análisis complejo de PDFs
|
| 90 |
+
model = genai.GenerativeModel('gemini-2.5-flash', generation_config={"response_mime_type": "application/json"})
|
| 91 |
+
response = model.generate_content([PROMPT_ANALISTA_MULTI, *archivos_subidos])
|
| 92 |
+
|
| 93 |
+
# Limpieza de archivos en la nube de Gemini para evitar llenar la cuota
|
| 94 |
+
for f in archivos_subidos:
|
| 95 |
+
try:
|
| 96 |
+
genai.delete_file(f.name)
|
| 97 |
+
except Exception as e:
|
| 98 |
+
logger.warning(f"No se pudo borrar archivo temporal de Gemini: {e}")
|
| 99 |
+
|
| 100 |
+
logger.info(f"{len(archivos_subidos)} pliego(s) analizados exitosamente.")
|
| 101 |
+
return json.loads(response.text)
|
| 102 |
+
|
| 103 |
+
except Exception as e:
|
| 104 |
+
logger.error(f"Error analizando pliego: {str(e)}")
|
| 105 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 106 |
+
|
| 107 |
+
# --- 5. FUNCIONES DE APOYO PARA CORREOS (HILO SECUNDARIO) ---
|
| 108 |
+
def get_user_credentials(username):
|
| 109 |
+
row = db.get_user_credentials(username)
|
| 110 |
+
return (row[0], crypto.decrypt_data(row[1]), row[2]) if row else (None, None, None)
|
| 111 |
+
|
| 112 |
+
# Prompt compacto: clasifica Y extrae cotizacion en una sola llamada (cero tokens extra)
|
| 113 |
+
PROMPT_CLASIFICADOR_CORREOS = """Eres un asistente de procura. Analiza este correo en relacion a la licitacion {licitacion}.
|
| 114 |
+
Items de referencia: {contexto_items_resumido}
|
| 115 |
+
|
| 116 |
+
Correo:
|
| 117 |
+
Asunto: {asunto}\nRemitente: {remitente}\nCuerpo: {cuerpo}
|
| 118 |
+
|
| 119 |
+
Responde SOLO con este JSON (sin texto adicional):
|
| 120 |
+
{{"relacionado": true/false,
|
| 121 |
+
"resumen": "1 linea de lo que ofrece el proveedor",
|
| 122 |
+
"renglones": "numeros separados por coma ej: 1, 3",
|
| 123 |
+
"borrador_respuesta": "correo de respuesta profesional firmado como Departamento de Compras",
|
| 124 |
+
"cotizaciones": [
|
| 125 |
+
{{"renglon": "1", "precio_unitario": 0.0, "moneda": "USD", "tiempo_entrega": "30 dias", "condiciones": "FOB"}}
|
| 126 |
+
]
|
| 127 |
+
}}
|
| 128 |
+
Si el correo no contiene precios, devuelve cotizaciones como lista vacia [].
|
| 129 |
+
"""
|
| 130 |
+
|
| 131 |
+
def procesar_correos_background(username: str, servidor_imap: str, licitacion_activa: str, contexto_items: str):
|
| 132 |
+
email_user, email_pass, gemini_key = get_user_credentials(username)
|
| 133 |
+
if not email_user or not email_pass:
|
| 134 |
+
logger.warning(f"Sin credenciales de correo para usuario {username}")
|
| 135 |
+
return
|
| 136 |
+
|
| 137 |
+
try:
|
| 138 |
+
genai.configure(api_key=gemini_key)
|
| 139 |
+
# gemini-2.5-flash para clasificación simple de correos
|
| 140 |
+
model = genai.GenerativeModel('gemini-2.5-flash')
|
| 141 |
+
|
| 142 |
+
# Reducir contexto enviado: solo los 3 campos clave, NO la ficha técnica completa
|
| 143 |
+
try:
|
| 144 |
+
df_items = pd.read_json(io.StringIO(contexto_items))
|
| 145 |
+
cols_disponibles = [c for c in ['renglon', 'codigo_articulo', 'termino_de_busqueda_corto'] if c in df_items.columns]
|
| 146 |
+
contexto_resumido = df_items[cols_disponibles].to_json(orient="records", force_ascii=False)
|
| 147 |
+
except Exception:
|
| 148 |
+
contexto_resumido = contexto_items[:500] # fallback seguro
|
| 149 |
+
|
| 150 |
+
mail = imaplib.IMAP4_SSL(servidor_imap)
|
| 151 |
+
mail.login(email_user, email_pass)
|
| 152 |
+
mail.select("inbox")
|
| 153 |
+
|
| 154 |
+
status, mensajes = mail.search(None, 'ALL')
|
| 155 |
+
if not mensajes[0]:
|
| 156 |
+
return
|
| 157 |
+
|
| 158 |
+
# Solo últimos 15 correos para respetar el límite de 15 RPM de Gemini Free
|
| 159 |
+
lista_ids = mensajes[0].split()[-15:]
|
| 160 |
+
|
| 161 |
+
for id_correo in lista_ids:
|
| 162 |
+
res, data = mail.fetch(id_correo, '(RFC822)')
|
| 163 |
+
for part in data:
|
| 164 |
+
if isinstance(part, tuple):
|
| 165 |
+
msg = email.message_from_bytes(part[1])
|
| 166 |
+
subj_raw = decode_header(msg.get("Subject", ""))[0]
|
| 167 |
+
asunto = subj_raw[0].decode(subj_raw[1] or 'utf-8', errors='ignore') if isinstance(subj_raw[0], bytes) else str(subj_raw[0])
|
| 168 |
+
remitente = msg.get("From", "Desconocido")
|
| 169 |
+
|
| 170 |
+
if db.check_email_exists(licitacion_activa, asunto, remitente):
|
| 171 |
+
continue
|
| 172 |
+
|
| 173 |
+
# Pre-filtro local: descarta correos claramente irrelevantes antes de llamar a Gemini
|
| 174 |
+
num_lic_clean = "".join(re.findall(r'\d+', licitacion_activa))
|
| 175 |
+
palabras_clave = ["RFQ", "COTIZA", "QUOTE", "PROCURA", "PRECIO", "OFERTA", "SUMINISTRO"]
|
| 176 |
+
es_relevante = num_lic_clean in asunto or any(p in asunto.upper() for p in palabras_clave)
|
| 177 |
+
if not es_relevante:
|
| 178 |
+
continue
|
| 179 |
+
|
| 180 |
+
cuerpo_crudo = ""
|
| 181 |
+
if msg.is_multipart():
|
| 182 |
+
for p in msg.walk():
|
| 183 |
+
if p.get_content_type() == "text/plain":
|
| 184 |
+
cuerpo_crudo += p.get_payload(decode=True).decode(errors='ignore')
|
| 185 |
+
else:
|
| 186 |
+
cuerpo_crudo = msg.get_payload(decode=True).decode(errors='ignore')
|
| 187 |
+
|
| 188 |
+
cuerpo_limpio = " ".join(cuerpo_crudo.split())
|
| 189 |
+
# Limitar cuerpo a 2000 chars (era 3000) — reduce tokens sin perder contexto
|
| 190 |
+
cuerpo_ia = cuerpo_limpio[:2000]
|
| 191 |
+
|
| 192 |
+
prompt = PROMPT_CLASIFICADOR_CORREOS.format(
|
| 193 |
+
licitacion=licitacion_activa,
|
| 194 |
+
contexto_items_resumido=contexto_resumido,
|
| 195 |
+
asunto=asunto,
|
| 196 |
+
remitente=remitente,
|
| 197 |
+
cuerpo=cuerpo_ia
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
try:
|
| 201 |
+
time.sleep(6) # 6s entre llamadas — respeta 15 RPM de Gemini Free
|
| 202 |
+
res_ia = model.generate_content(prompt)
|
| 203 |
+
texto_ia = res_ia.text.strip().replace("```json", "").replace("```", "").strip()
|
| 204 |
+
datos_ia = json.loads(texto_ia)
|
| 205 |
+
|
| 206 |
+
if datos_ia.get("relacionado"):
|
| 207 |
+
db.insert_smart_inbox(
|
| 208 |
+
licitacion_activa, remitente, asunto,
|
| 209 |
+
msg.get("Date"), datos_ia.get('resumen', ''),
|
| 210 |
+
datos_ia.get('renglones', ''), cuerpo_limpio,
|
| 211 |
+
datos_ia.get('borrador_respuesta', '')
|
| 212 |
+
)
|
| 213 |
+
logger.info(f"Correo guardado: '{asunto}' para licitación {licitacion_activa}")
|
| 214 |
+
|
| 215 |
+
# Guardar cotizaciones extraídas (si las hay) en tabla comparador
|
| 216 |
+
for cot in datos_ia.get('cotizaciones', []):
|
| 217 |
+
renglon = str(cot.get('renglon', '')).strip()
|
| 218 |
+
proveedor = remitente
|
| 219 |
+
precio = float(cot.get('precio_unitario', 0) or 0)
|
| 220 |
+
if renglon and precio > 0:
|
| 221 |
+
if not db.check_cotizacion_exists(licitacion_activa, renglon, proveedor):
|
| 222 |
+
db.insert_cotizacion(
|
| 223 |
+
licitacion_activa, renglon, proveedor,
|
| 224 |
+
precio,
|
| 225 |
+
str(cot.get('moneda', 'USD')),
|
| 226 |
+
str(cot.get('tiempo_entrega', 'N/A')),
|
| 227 |
+
str(cot.get('condiciones', '')),
|
| 228 |
+
str(msg.get('Date', '')),
|
| 229 |
+
asunto
|
| 230 |
+
)
|
| 231 |
+
logger.info(f"Cotizacion guardada: Renglón {renglon} | {proveedor} | ${precio}")
|
| 232 |
+
except Exception as parse_error:
|
| 233 |
+
logger.warning(f"Error procesando correo '{asunto}': {parse_error}")
|
| 234 |
+
continue
|
| 235 |
+
|
| 236 |
+
mail.logout()
|
| 237 |
+
logger.info(f"Escaneo de correos finalizado para usuario {username}.")
|
| 238 |
+
|
| 239 |
+
except Exception as e:
|
| 240 |
+
logger.error(f"Error crítico en hilo de correos: {e}")
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
# --- 6. ENDPOINT ASÍNCRONO DE CORREOS ---
|
| 244 |
+
@app.post("/api/v1/organizar-correos")
|
| 245 |
+
def organizar_correos(
|
| 246 |
+
background_tasks: BackgroundTasks,
|
| 247 |
+
username: str = Form(...),
|
| 248 |
+
servidor_imap: str = Form("mail.proyelec.com"),
|
| 249 |
+
licitacion_activa: str = Form(...),
|
| 250 |
+
contexto_items: str = Form(...),
|
| 251 |
+
_token: str = Depends(verify_internal_token)
|
| 252 |
+
):
|
| 253 |
+
background_tasks.add_task(procesar_correos_background, username, servidor_imap, licitacion_activa, contexto_items)
|
| 254 |
+
return {"status": "success", "mensaje": "🤖 Gemini está escaneando los correos en segundo plano. Los resultados aparecerán en la bandeja en breve."}
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
# --- 7. GENERADOR DE FICHAS TÉCNICAS (CON CACHE) ---
|
| 258 |
+
@app.post("/api/v1/generar-ficha")
|
| 259 |
+
def generar_ficha(
|
| 260 |
+
username: str = Form(...),
|
| 261 |
+
licitacion: str = Form(...),
|
| 262 |
+
codigo_renglon: str = Form(...),
|
| 263 |
+
pliego_context: str = Form(...),
|
| 264 |
+
items_context: str = Form(...),
|
| 265 |
+
gemini_key: str = Form(...),
|
| 266 |
+
_token: str = Depends(verify_internal_token)
|
| 267 |
+
):
|
| 268 |
+
# Verificar cache primero — si ya se generó, devolver sin gastar tokens
|
| 269 |
+
cached = db.get_ficha_cache(username, licitacion, codigo_renglon)
|
| 270 |
+
if cached:
|
| 271 |
+
logger.info(f"Ficha para {codigo_renglon} servida desde cache.")
|
| 272 |
+
return {"status": "success", "datasheet_md": cached, "from_cache": True}
|
| 273 |
+
|
| 274 |
+
try:
|
| 275 |
+
genai.configure(api_key=gemini_key)
|
| 276 |
+
model = genai.GenerativeModel('gemini-2.5-flash')
|
| 277 |
+
prompt = f"""Eres un Ingeniero de Compras especializado. Genera una ficha técnica en formato Markdown para el artículo: {codigo_renglon}.
|
| 278 |
+
Condiciones del Pliego: {pliego_context}
|
| 279 |
+
Detalle del Ítem: {items_context}
|
| 280 |
+
|
| 281 |
+
La ficha debe contener:
|
| 282 |
+
- **Título y Descripción breve**
|
| 283 |
+
- **Tabla de Especificaciones Técnicas**
|
| 284 |
+
- **Requisitos de Calidad / Certificaciones**
|
| 285 |
+
- **Condiciones especiales de la licitación**
|
| 286 |
+
Formato profesional y estructurado."""
|
| 287 |
+
|
| 288 |
+
response = model.generate_content(prompt)
|
| 289 |
+
datasheet = response.text
|
| 290 |
+
|
| 291 |
+
# Guardar en cache para futuras consultas
|
| 292 |
+
db.save_ficha_cache(username, licitacion, codigo_renglon, datasheet)
|
| 293 |
+
logger.info(f"Ficha técnica generada y cacheada para {codigo_renglon}.")
|
| 294 |
+
return {"status": "success", "datasheet_md": datasheet, "from_cache": False}
|
| 295 |
+
|
| 296 |
+
except Exception as e:
|
| 297 |
+
logger.error(f"Error generando ficha: {str(e)}")
|
| 298 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
# --- 8. ENDPOINTS REST ---
|
| 302 |
+
class LoginRequest(BaseModel):
|
| 303 |
+
username: str
|
| 304 |
+
password: str
|
| 305 |
+
|
| 306 |
+
@app.post("/api/v1/login")
|
| 307 |
+
def login(req: LoginRequest, _token: str = Depends(verify_internal_token)):
|
| 308 |
+
user = db.get_user(req.username, req.password)
|
| 309 |
+
if user:
|
| 310 |
+
return {"status": "success", "username": user[0], "role": user[2]}
|
| 311 |
+
raise HTTPException(status_code=401, detail="Credenciales incorrectas")
|
| 312 |
+
|
| 313 |
+
@app.get("/api/v1/workspace/{username}")
|
| 314 |
+
def get_workspace(username: str, _token: str = Depends(verify_internal_token)):
|
| 315 |
+
row = db.load_workspace_state(username)
|
| 316 |
+
if row and row[0] and row[1]:
|
| 317 |
+
df = pd.read_json(io.StringIO(row[0]))
|
| 318 |
+
return {"cg": json.loads(row[1]), "items": df.to_dict(orient="records")}
|
| 319 |
+
return {"cg": None, "items": []}
|
| 320 |
+
|
| 321 |
+
@app.get("/api/v1/history/{username}")
|
| 322 |
+
def get_history(username: str, skip: int = 0, limit: int = 50, _token: str = Depends(verify_internal_token)):
|
| 323 |
+
df = db.get_user_history_df(username)
|
| 324 |
+
return df.iloc[skip : skip+limit].to_dict(orient="records")
|
| 325 |
+
|
| 326 |
+
@app.get("/api/v1/inbox/{licitacion}")
|
| 327 |
+
def get_inbox(licitacion: str, skip: int = 0, limit: int = 50, _token: str = Depends(verify_internal_token)):
|
| 328 |
+
df = db.get_correos_licitacion_df(licitacion)
|
| 329 |
+
return df.iloc[skip : skip+limit].to_dict(orient="records")
|
| 330 |
+
|
| 331 |
+
@app.get("/api/v1/configuracion/{username}")
|
| 332 |
+
def get_config(username: str, _token: str = Depends(verify_internal_token)):
|
| 333 |
+
user_creds = db.get_user_credentials(username)
|
| 334 |
+
if user_creds:
|
| 335 |
+
return {"status": "success", "email_user": user_creds[0], "gemini_key": user_creds[2]}
|
| 336 |
+
return {"status": "error"}
|
| 337 |
+
|
| 338 |
+
@app.post("/api/v1/configuracion")
|
| 339 |
+
def save_config(
|
| 340 |
+
username: str = Form(...),
|
| 341 |
+
gemini_key: str = Form(...),
|
| 342 |
+
email_user: str = Form(""),
|
| 343 |
+
email_pass: str = Form(""),
|
| 344 |
+
_token: str = Depends(verify_internal_token)
|
| 345 |
+
):
|
| 346 |
+
existing = db.get_user_credentials(username)
|
| 347 |
+
if not existing:
|
| 348 |
+
raise HTTPException(status_code=404, detail="Usuario no encontrado")
|
| 349 |
+
|
| 350 |
+
enc_pass = existing[1]
|
| 351 |
+
if email_pass:
|
| 352 |
+
enc_pass = crypto.encrypt_data(email_pass)
|
| 353 |
+
|
| 354 |
+
db.update_user_profile(username, gemini_key, "", email_user, enc_pass)
|
| 355 |
+
return {"status": "success"}
|
| 356 |
+
|
| 357 |
+
# =============================================
|
| 358 |
+
# CONSULTA AUTOMATICA AL SLI DE LA ACP
|
| 359 |
+
# =============================================
|
| 360 |
+
|
| 361 |
+
@app.get("/api/v1/consultar-sli/{rfq_id}")
|
| 362 |
+
def consultar_sli(rfq_id: str, _token: str = Depends(verify_internal_token)):
|
| 363 |
+
rfq_id = "".join(filter(str.isdigit, str(rfq_id or "")))
|
| 364 |
+
if not rfq_id:
|
| 365 |
+
raise HTTPException(
|
| 366 |
+
status_code=400,
|
| 367 |
+
detail={
|
| 368 |
+
"message": "Numero de licitacion invalido.",
|
| 369 |
+
"hint": "Ingresa solo el numero RFQ de la licitacion ACP."
|
| 370 |
+
}
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
SLI_HOME_URL = "https://apps.pancanal.com/sli/LicitacionesBusqueda/Welcome"
|
| 374 |
+
SLI_URL = f"https://apps.pancanal.com/sli/Licitaciones/LicitacionHeader?rfqId={rfq_id}"
|
| 375 |
+
|
| 376 |
+
def extraer_resumen_acta(texto_acta, acta_url):
|
| 377 |
+
texto_acta = re.sub(r"\s+", " ", texto_acta or "").strip()
|
| 378 |
+
if not texto_acta:
|
| 379 |
+
return {
|
| 380 |
+
"disponible": False,
|
| 381 |
+
"url": acta_url,
|
| 382 |
+
"resumen": "",
|
| 383 |
+
"hallazgos": [],
|
| 384 |
+
"error": "El acta no contiene texto legible."
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
palabras_clave = [
|
| 388 |
+
"no cumple", "incumple", "fallo", "falla", "deficiencia",
|
| 389 |
+
"observacion", "observación", "subsan", "tecnico", "técnico",
|
| 390 |
+
"rechaz", "descalific", "no acept", "aclaracion", "aclaración"
|
| 391 |
+
]
|
| 392 |
+
|
| 393 |
+
partes = re.split(r"(?<=[.!?])\s+|\n+", texto_acta)
|
| 394 |
+
hallazgos = []
|
| 395 |
+
|
| 396 |
+
for parte in partes:
|
| 397 |
+
parte_limpia = parte.strip()
|
| 398 |
+
parte_lower = parte_limpia.lower()
|
| 399 |
+
if len(parte_limpia) < 35:
|
| 400 |
+
continue
|
| 401 |
+
if any(palabra in parte_lower for palabra in palabras_clave):
|
| 402 |
+
hallazgos.append(parte_limpia[:450])
|
| 403 |
+
if len(hallazgos) >= 8:
|
| 404 |
+
break
|
| 405 |
+
|
| 406 |
+
if hallazgos:
|
| 407 |
+
resumen = "Se detectaron posibles observaciones tecnicas o comentarios relevantes en el acta."
|
| 408 |
+
elif any(palabra in texto_acta.lower() for palabra in ["cumple", "conforme", "adjudic"]):
|
| 409 |
+
resumen = "No se detectaron fallos tecnicos evidentes en una lectura automatica del acta."
|
| 410 |
+
else:
|
| 411 |
+
resumen = "El acta fue encontrada, pero no se detectaron observaciones tecnicas claras automaticamente."
|
| 412 |
+
|
| 413 |
+
return {
|
| 414 |
+
"disponible": True,
|
| 415 |
+
"url": acta_url,
|
| 416 |
+
"resumen": resumen,
|
| 417 |
+
"hallazgos": hallazgos,
|
| 418 |
+
"texto_muestra": texto_acta[:1200],
|
| 419 |
+
"error": None
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
try:
|
| 423 |
+
from playwright.sync_api import (
|
| 424 |
+
Error as PlaywrightError,
|
| 425 |
+
TimeoutError as PlaywrightTimeoutError,
|
| 426 |
+
sync_playwright,
|
| 427 |
+
)
|
| 428 |
+
except ImportError:
|
| 429 |
+
raise HTTPException(
|
| 430 |
+
status_code=503,
|
| 431 |
+
detail={
|
| 432 |
+
"message": "Playwright no esta instalado.",
|
| 433 |
+
"hint": "Ejecuta: pip install playwright && playwright install chromium"
|
| 434 |
+
}
|
| 435 |
+
)
|
| 436 |
+
|
| 437 |
+
browser = None
|
| 438 |
+
resumen_acta = {
|
| 439 |
+
"disponible": False,
|
| 440 |
+
"url": None,
|
| 441 |
+
"resumen": "",
|
| 442 |
+
"hallazgos": [],
|
| 443 |
+
"error": "No se encontro el boton de resumen de propuestas recibidas."
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
try:
|
| 447 |
+
with sync_playwright() as p:
|
| 448 |
+
try:
|
| 449 |
+
browser = p.chromium.launch(
|
| 450 |
+
headless=True,
|
| 451 |
+
args=[
|
| 452 |
+
"--no-sandbox",
|
| 453 |
+
"--disable-dev-shm-usage",
|
| 454 |
+
"--disable-blink-features=AutomationControlled"
|
| 455 |
+
]
|
| 456 |
+
)
|
| 457 |
+
except PlaywrightError as e:
|
| 458 |
+
msg = str(e)
|
| 459 |
+
if "Executable doesn't exist" in msg or "playwright install" in msg:
|
| 460 |
+
raise HTTPException(
|
| 461 |
+
status_code=503,
|
| 462 |
+
detail={
|
| 463 |
+
"message": "Chromium de Playwright no esta instalado.",
|
| 464 |
+
"hint": "Ejecuta: playwright install chromium"
|
| 465 |
+
}
|
| 466 |
+
)
|
| 467 |
+
raise
|
| 468 |
+
|
| 469 |
+
page = browser.new_page()
|
| 470 |
+
page.set_default_timeout(15000)
|
| 471 |
+
page.set_extra_http_headers({
|
| 472 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124"
|
| 473 |
+
})
|
| 474 |
+
|
| 475 |
+
response = page.goto(SLI_HOME_URL, wait_until="domcontentloaded", timeout=30000)
|
| 476 |
+
if response and response.status >= 500:
|
| 477 |
+
raise HTTPException(
|
| 478 |
+
status_code=502,
|
| 479 |
+
detail={
|
| 480 |
+
"message": f"El SLI respondio con HTTP {response.status}.",
|
| 481 |
+
"hint": "El portal de ACP puede estar caido o inestable. Intenta de nuevo mas tarde."
|
| 482 |
+
}
|
| 483 |
+
)
|
| 484 |
+
|
| 485 |
+
page.wait_for_selector("#rfqId", timeout=15000)
|
| 486 |
+
page.fill("#rfqId", rfq_id)
|
| 487 |
+
|
| 488 |
+
if page.locator("#hfEstatusSeleccionadoID").count() > 0:
|
| 489 |
+
page.evaluate(
|
| 490 |
+
'document.getElementById("hfEstatusSeleccionadoID").value = "TODOS";'
|
| 491 |
+
)
|
| 492 |
+
|
| 493 |
+
page.click("input[type='submit']")
|
| 494 |
+
|
| 495 |
+
try:
|
| 496 |
+
page.wait_for_function(
|
| 497 |
+
"() => document.body.innerText.includes('Detalle de RFQ') || "
|
| 498 |
+
"document.body.innerText.includes('EVALUACI') || "
|
| 499 |
+
"document.body.innerText.includes('No se encontraron') || "
|
| 500 |
+
"document.body.innerText.includes('InternalServer')",
|
| 501 |
+
timeout=20000
|
| 502 |
+
)
|
| 503 |
+
except PlaywrightTimeoutError:
|
| 504 |
+
logger.warning(f"Timeout esperando resultados del SLI para RFQ {rfq_id}")
|
| 505 |
+
|
| 506 |
+
content = page.content()
|
| 507 |
+
SLI_URL = page.url
|
| 508 |
+
|
| 509 |
+
resumen_visible = page.locator(".ResPropRec").count() > 0
|
| 510 |
+
po_header_match = re.search(r"po_header\s*[=:]\s*['\"]?(\d+)", content, re.IGNORECASE)
|
| 511 |
+
if not po_header_match:
|
| 512 |
+
po_header_match = re.search(r"po_header=(\d+)", content, re.IGNORECASE)
|
| 513 |
+
|
| 514 |
+
if resumen_visible and po_header_match:
|
| 515 |
+
po_header = po_header_match.group(1)
|
| 516 |
+
acta_url = urljoin(
|
| 517 |
+
SLI_URL,
|
| 518 |
+
f"../Comunes/ImpresionActaResumen?p_rfq={rfq_id}&po_header={po_header}"
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
try:
|
| 522 |
+
acta_response = page.request.get(
|
| 523 |
+
acta_url,
|
| 524 |
+
headers={"Referer": SLI_URL},
|
| 525 |
+
timeout=30000
|
| 526 |
+
)
|
| 527 |
+
acta_bytes = acta_response.body()
|
| 528 |
+
content_type = (acta_response.headers.get("content-type") or "").lower()
|
| 529 |
+
|
| 530 |
+
if "pdf" in content_type or acta_bytes[:4] == b"%PDF":
|
| 531 |
+
try:
|
| 532 |
+
from pypdf import PdfReader
|
| 533 |
+
|
| 534 |
+
reader = PdfReader(io.BytesIO(acta_bytes))
|
| 535 |
+
texto_acta = "\n".join(
|
| 536 |
+
page_pdf.extract_text() or ""
|
| 537 |
+
for page_pdf in reader.pages
|
| 538 |
+
)
|
| 539 |
+
resumen_acta = extraer_resumen_acta(texto_acta, acta_url)
|
| 540 |
+
except ImportError:
|
| 541 |
+
resumen_acta = {
|
| 542 |
+
"disponible": False,
|
| 543 |
+
"url": acta_url,
|
| 544 |
+
"resumen": "",
|
| 545 |
+
"hallazgos": [],
|
| 546 |
+
"error": "pypdf no esta instalado para leer el PDF del resumen."
|
| 547 |
+
}
|
| 548 |
+
else:
|
| 549 |
+
html_acta = acta_bytes.decode("utf-8", errors="ignore")
|
| 550 |
+
texto_acta = BeautifulSoup(html_acta, "html.parser").get_text(
|
| 551 |
+
separator=" ",
|
| 552 |
+
strip=True
|
| 553 |
+
)
|
| 554 |
+
resumen_acta = extraer_resumen_acta(texto_acta, acta_url)
|
| 555 |
+
|
| 556 |
+
except Exception as e:
|
| 557 |
+
logger.warning(f"No se pudo leer acta resumen SLI {rfq_id}: {e}")
|
| 558 |
+
resumen_acta = {
|
| 559 |
+
"disponible": False,
|
| 560 |
+
"url": acta_url,
|
| 561 |
+
"resumen": "",
|
| 562 |
+
"hallazgos": [],
|
| 563 |
+
"error": "Se encontro el resumen, pero no se pudo leer automaticamente."
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
except HTTPException:
|
| 567 |
+
raise
|
| 568 |
+
except PlaywrightTimeoutError as e:
|
| 569 |
+
logger.warning(f"Timeout consultando SLI {rfq_id}: {e}")
|
| 570 |
+
raise HTTPException(
|
| 571 |
+
status_code=504,
|
| 572 |
+
detail={
|
| 573 |
+
"message": "El SLI tardo demasiado en responder.",
|
| 574 |
+
"hint": "Verifica la conexion o intenta nuevamente en unos minutos."
|
| 575 |
+
}
|
| 576 |
+
)
|
| 577 |
+
except PlaywrightError as e:
|
| 578 |
+
logger.exception(f"Error de Playwright consultando SLI {rfq_id}")
|
| 579 |
+
raise HTTPException(
|
| 580 |
+
status_code=502,
|
| 581 |
+
detail={
|
| 582 |
+
"message": "No se pudo consultar el portal SLI.",
|
| 583 |
+
"hint": "El portal pudo cambiar, bloquear la automatizacion o estar temporalmente fuera de servicio.",
|
| 584 |
+
"technical": str(e)[:500]
|
| 585 |
+
}
|
| 586 |
+
)
|
| 587 |
+
except Exception as e:
|
| 588 |
+
logger.exception(f"Error inesperado consultando SLI {rfq_id}")
|
| 589 |
+
raise HTTPException(
|
| 590 |
+
status_code=500,
|
| 591 |
+
detail={
|
| 592 |
+
"message": "Error inesperado consultando el SLI.",
|
| 593 |
+
"hint": "Revisa backend.log para ver el traceback completo.",
|
| 594 |
+
"technical": str(e)[:500]
|
| 595 |
+
}
|
| 596 |
+
)
|
| 597 |
+
finally:
|
| 598 |
+
if browser:
|
| 599 |
+
try:
|
| 600 |
+
browser.close()
|
| 601 |
+
except Exception:
|
| 602 |
+
pass
|
| 603 |
+
|
| 604 |
+
try:
|
| 605 |
+
|
| 606 |
+
soup_sli = BeautifulSoup(content, "html.parser")
|
| 607 |
+
|
| 608 |
+
texto_sli = soup_sli.get_text(separator="|", strip=True)
|
| 609 |
+
|
| 610 |
+
tokens = [t.strip() for t in texto_sli.split("|") if t.strip()]
|
| 611 |
+
|
| 612 |
+
def buscar_valor(etiquetas):
|
| 613 |
+
|
| 614 |
+
for i, tok in enumerate(tokens):
|
| 615 |
+
|
| 616 |
+
for etiq in etiquetas:
|
| 617 |
+
|
| 618 |
+
if (
|
| 619 |
+
tok.strip().lower() == etiq.lower()
|
| 620 |
+
or tok.strip().lower() == f"{etiq.lower()}:"
|
| 621 |
+
):
|
| 622 |
+
|
| 623 |
+
for j in range(i + 1, min(i + 4, len(tokens))):
|
| 624 |
+
|
| 625 |
+
cand = tokens[j]
|
| 626 |
+
|
| 627 |
+
if (
|
| 628 |
+
cand
|
| 629 |
+
and not any(
|
| 630 |
+
e.lower() == cand.strip().lower()
|
| 631 |
+
for e in etiquetas
|
| 632 |
+
)
|
| 633 |
+
and len(cand) > 2
|
| 634 |
+
):
|
| 635 |
+
return cand
|
| 636 |
+
|
| 637 |
+
return None
|
| 638 |
+
|
| 639 |
+
resultado = {
|
| 640 |
+
"rfq_id": rfq_id,
|
| 641 |
+
"url": SLI_URL,
|
| 642 |
+
"estatus": buscar_valor(["Estatus", "Estado"]),
|
| 643 |
+
"descripcion": buscar_valor(["Descripción", "Descripcion"]),
|
| 644 |
+
"fecha_cierre": buscar_valor([
|
| 645 |
+
"Fecha y hora de cierre",
|
| 646 |
+
"Fecha de cierre",
|
| 647 |
+
"Cierre"
|
| 648 |
+
]),
|
| 649 |
+
"fecha_publicacion": buscar_valor([
|
| 650 |
+
"Fecha de publicación",
|
| 651 |
+
"Publicación",
|
| 652 |
+
"Publicacion"
|
| 653 |
+
]),
|
| 654 |
+
"ultima_revision": buscar_valor([
|
| 655 |
+
"Última revisión",
|
| 656 |
+
"Ultima Revision",
|
| 657 |
+
"Última Revisión"
|
| 658 |
+
]),
|
| 659 |
+
"agente_compras": buscar_valor([
|
| 660 |
+
"Agente de compras",
|
| 661 |
+
"Agente Compras",
|
| 662 |
+
"Purchasing Agent"
|
| 663 |
+
]),
|
| 664 |
+
"resumen_acta": resumen_acta,
|
| 665 |
+
"error": None
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
ESTADOS_SLI = [
|
| 669 |
+
"EVALUACIÓN",
|
| 670 |
+
"EVALUACION",
|
| 671 |
+
"ADJUDICACIÓN",
|
| 672 |
+
"ADJUDICACION",
|
| 673 |
+
"CANCELACIÓN",
|
| 674 |
+
"CANCELACION",
|
| 675 |
+
"ACTO DESIERTO",
|
| 676 |
+
"DESIERTA",
|
| 677 |
+
"ENMENDADA",
|
| 678 |
+
"ANUNCIO VENCIDO",
|
| 679 |
+
"ABIERTA",
|
| 680 |
+
"PRECALIFICACIÓN"
|
| 681 |
+
]
|
| 682 |
+
|
| 683 |
+
if not resultado["estatus"]:
|
| 684 |
+
|
| 685 |
+
texto_upper = texto_sli.upper()
|
| 686 |
+
|
| 687 |
+
for estado in ESTADOS_SLI:
|
| 688 |
+
|
| 689 |
+
if estado in texto_upper:
|
| 690 |
+
resultado["estatus"] = estado.title()
|
| 691 |
+
break
|
| 692 |
+
|
| 693 |
+
if not resultado["estatus"] and not resultado["descripcion"]:
|
| 694 |
+
|
| 695 |
+
resultado["error"] = (
|
| 696 |
+
"No se encontró información. "
|
| 697 |
+
"Verifica el número de licitación o intenta más tarde."
|
| 698 |
+
)
|
| 699 |
+
|
| 700 |
+
logger.info(
|
| 701 |
+
f"Consulta SLI {rfq_id}: "
|
| 702 |
+
f"estatus={resultado['estatus']} | "
|
| 703 |
+
f"desc={resultado['descripcion']}"
|
| 704 |
+
)
|
| 705 |
+
|
| 706 |
+
return resultado
|
| 707 |
+
except Exception as e:
|
| 708 |
+
logger.exception(f"Error parseando respuesta SLI {rfq_id}")
|
| 709 |
+
raise HTTPException(
|
| 710 |
+
status_code=500,
|
| 711 |
+
detail={
|
| 712 |
+
"message": "El SLI respondio, pero no se pudo interpretar la pagina.",
|
| 713 |
+
"hint": "Puede haber cambiado el formato del portal ACP.",
|
| 714 |
+
"technical": str(e)[:500]
|
| 715 |
+
}
|
| 716 |
+
)
|
app.py
ADDED
|
@@ -0,0 +1,1156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import json
|
| 4 |
+
import urllib.parse
|
| 5 |
+
import plotly.graph_objects as go
|
| 6 |
+
from tavily import TavilyClient
|
| 7 |
+
from email.message import EmailMessage
|
| 8 |
+
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
import requests
|
| 11 |
+
import os
|
| 12 |
+
import re
|
| 13 |
+
import io
|
| 14 |
+
from html import escape
|
| 15 |
+
import crypto
|
| 16 |
+
from dotenv import load_dotenv
|
| 17 |
+
import database as db
|
| 18 |
+
|
| 19 |
+
load_dotenv()
|
| 20 |
+
|
| 21 |
+
# --- 1. CONFIGURACIÓN INICIAL Y CIBERSEGURIDAD ---
|
| 22 |
+
st.set_page_config(page_title="Proyelec Sourcing Pro", layout="wide", page_icon="💎", initial_sidebar_state="expanded")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
if "logged_in" not in st.session_state:
|
| 27 |
+
st.session_state.logged_in = False
|
| 28 |
+
st.session_state.username = ""
|
| 29 |
+
st.session_state.role = ""
|
| 30 |
+
st.session_state.gemini_key = ""
|
| 31 |
+
st.session_state.tavily_key = ""
|
| 32 |
+
st.session_state.email_user = ""
|
| 33 |
+
st.session_state.email_pass = ""
|
| 34 |
+
if "procesado" not in st.session_state:
|
| 35 |
+
st.session_state.procesado = False
|
| 36 |
+
if "df_exportar" not in st.session_state:
|
| 37 |
+
st.session_state.df_exportar = pd.DataFrame()
|
| 38 |
+
if "cg" not in st.session_state:
|
| 39 |
+
st.session_state.cg = {}
|
| 40 |
+
|
| 41 |
+
def encrypt_data(text):
|
| 42 |
+
return crypto.encrypt_data(text)
|
| 43 |
+
|
| 44 |
+
def decrypt_data(text):
|
| 45 |
+
return crypto.decrypt_data(text)
|
| 46 |
+
|
| 47 |
+
API_URL_BASE = os.getenv("API_URL_BASE", "http://localhost:8000/api/v1")
|
| 48 |
+
API_HEADERS = {"X-Internal-Token": os.getenv("INTERNAL_API_TOKEN", "default-dev-token")}
|
| 49 |
+
MAPA_ESTADOS_SLI = {
|
| 50 |
+
"EVALUACIÓN": "En Evaluacion Economica",
|
| 51 |
+
"EVALUACION": "En Evaluacion Economica",
|
| 52 |
+
"ADJUDICADA": "Adjudicada",
|
| 53 |
+
"CANCELADA": "No Adjudicada",
|
| 54 |
+
"DESIERTA": "Desierta",
|
| 55 |
+
"CERRADA": "Oferta Enviada al SLI",
|
| 56 |
+
"ABIERTA": "En Preparacion",
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# --- 2. BASE DE DATOS LOCAL Y PERSISTENCIA ---
|
| 61 |
+
def init_db():
|
| 62 |
+
db.init_db()
|
| 63 |
+
|
| 64 |
+
init_db()
|
| 65 |
+
|
| 66 |
+
# Workspace multi
|
| 67 |
+
def save_workspace_state(username, cg_dict, df):
|
| 68 |
+
lic = cg_dict.get('numero_licitacion', 'Sin_Numero')
|
| 69 |
+
db.save_workspace(username, str(lic), df.to_json(orient="records"), json.dumps(cg_dict))
|
| 70 |
+
|
| 71 |
+
def load_workspace_state(username):
|
| 72 |
+
row = db.load_workspace_state(username)
|
| 73 |
+
if row and row[0] and row[1]:
|
| 74 |
+
return pd.read_json(io.StringIO(row[0])), json.loads(row[1])
|
| 75 |
+
return None, None
|
| 76 |
+
|
| 77 |
+
def load_workspace_by_licitacion(username, licitacion):
|
| 78 |
+
row = db.load_workspace(username, licitacion)
|
| 79 |
+
if row and row[0] and row[1]:
|
| 80 |
+
return pd.read_json(io.StringIO(row[0])), json.loads(row[1])
|
| 81 |
+
return None, None
|
| 82 |
+
|
| 83 |
+
def verify_login(username, password):
|
| 84 |
+
user_data = db.get_user(username, password)
|
| 85 |
+
if user_data:
|
| 86 |
+
u_list = list(user_data)
|
| 87 |
+
role = u_list[2]
|
| 88 |
+
|
| 89 |
+
# Compartir llaves de Gerencia con los Analistas si no tienen las suyas propias
|
| 90 |
+
if role == "Analista":
|
| 91 |
+
conn = db.get_connection()
|
| 92 |
+
c = conn.cursor()
|
| 93 |
+
c.execute("SELECT gemini_key, tavily_key, email_user, email_pass_enc FROM users WHERE role='Gerencia' AND gemini_key != '' LIMIT 1")
|
| 94 |
+
gerencia = c.fetchone()
|
| 95 |
+
conn.close()
|
| 96 |
+
|
| 97 |
+
if gerencia:
|
| 98 |
+
if not u_list[3]: u_list[3] = gerencia[0] # gemini_key
|
| 99 |
+
if not u_list[4]: u_list[4] = gerencia[1] # tavily_key
|
| 100 |
+
if not u_list[5]: u_list[5] = gerencia[2] # email_user
|
| 101 |
+
if not u_list[6]: u_list[6] = gerencia[3] # email_pass_enc
|
| 102 |
+
|
| 103 |
+
return tuple(u_list)
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
def update_user_profile(username, gemini, tavily, email, raw_email_pass):
|
| 107 |
+
enc_pass = encrypt_data(raw_email_pass) if raw_email_pass else ""
|
| 108 |
+
db.update_user_profile(username, gemini, tavily, email, enc_pass)
|
| 109 |
+
|
| 110 |
+
def save_history(username, licitacion, items_count):
|
| 111 |
+
db.save_history(username, licitacion, items_count)
|
| 112 |
+
|
| 113 |
+
def get_user_history(username):
|
| 114 |
+
return db.get_user_history_df(username)
|
| 115 |
+
|
| 116 |
+
def obtener_correos_licitacion(licitacion):
|
| 117 |
+
return db.get_correos_licitacion_df(licitacion)
|
| 118 |
+
|
| 119 |
+
def activar_organizacion_imap():
|
| 120 |
+
try:
|
| 121 |
+
items_json = st.session_state.df_exportar.to_json(orient="records") if not st.session_state.df_exportar.empty else "[]"
|
| 122 |
+
num_lic = "".join(re.findall(r'\d+', str(st.session_state.cg.get('numero_licitacion', ''))))
|
| 123 |
+
datos = {"username": st.session_state.username, "licitacion_activa": num_lic, "contexto_items": items_json}
|
| 124 |
+
res = requests.post(f"{API_URL_BASE}/organizar-correos", data=datos, headers=API_HEADERS)
|
| 125 |
+
if res.status_code == 200: return True, res.json().get("mensaje", "Organización completada.")
|
| 126 |
+
else: return False, f"Error del servidor: {res.text}"
|
| 127 |
+
except Exception as e: return False, str(e)
|
| 128 |
+
|
| 129 |
+
# --- 3. CSS PREMIUM Y NAVBAR FULL-WIDTH ---
|
| 130 |
+
try:
|
| 131 |
+
with open('style.css', 'r') as f:
|
| 132 |
+
st.markdown(f'<style>{f.read()}</style>', unsafe_allow_html=True)
|
| 133 |
+
except:
|
| 134 |
+
pass
|
| 135 |
+
|
| 136 |
+
# --- 4. LOGIN CON IDENTIDAD PROYELEC ---
|
| 137 |
+
if not st.session_state.logged_in:
|
| 138 |
+
st.markdown("""
|
| 139 |
+
<style>
|
| 140 |
+
[data-testid="stAppViewContainer"] {
|
| 141 |
+
background: radial-gradient(ellipse at 20% 50%, #0d1f3c 0%, #0A0E1A 60%);
|
| 142 |
+
}
|
| 143 |
+
[data-testid="stSidebar"] { display: none; }
|
| 144 |
+
[data-testid="stHeader"] { display: none; }
|
| 145 |
+
.block-container {
|
| 146 |
+
padding-top: 5vh !important;
|
| 147 |
+
max-width: 460px !important;
|
| 148 |
+
}
|
| 149 |
+
.login-title {
|
| 150 |
+
font-size: 24px; font-weight: 800; color: #F0F6FC;
|
| 151 |
+
letter-spacing: -0.5px; margin: 12px 0 2px 0; text-align: center;
|
| 152 |
+
}
|
| 153 |
+
.login-subtitle {
|
| 154 |
+
font-size: 12px; color: #58A6FF; font-weight: 500;
|
| 155 |
+
letter-spacing: 1.5px; text-transform: uppercase; margin-bottom: 24px; text-align: center;
|
| 156 |
+
}
|
| 157 |
+
.login-footer {
|
| 158 |
+
font-size: 11px; color: #484f58; margin-top: 24px; text-align: center;
|
| 159 |
+
}
|
| 160 |
+
</style>
|
| 161 |
+
""", unsafe_allow_html=True)
|
| 162 |
+
|
| 163 |
+
col_logo = st.columns([1,3,1])[1]
|
| 164 |
+
with col_logo:
|
| 165 |
+
try:
|
| 166 |
+
st.image("proyelec_logo.png", use_container_width=True)
|
| 167 |
+
except Exception:
|
| 168 |
+
st.markdown("<div style='text-align:center; font-size:48px;'>⚡</div>", unsafe_allow_html=True)
|
| 169 |
+
|
| 170 |
+
st.markdown("""
|
| 171 |
+
<div class="login-title">PROYELEC</div>
|
| 172 |
+
<div class="login-subtitle">Sourcing Intelligence Pro</div>
|
| 173 |
+
""", unsafe_allow_html=True)
|
| 174 |
+
|
| 175 |
+
user_input = st.text_input("Usuario", placeholder="Usuario corporativo", label_visibility="collapsed")
|
| 176 |
+
pass_input = st.text_input("Contraseña", type="password", placeholder="Contraseña", label_visibility="collapsed")
|
| 177 |
+
st.write("")
|
| 178 |
+
if st.button("Ingresar al Sistema", type="primary", use_container_width=True):
|
| 179 |
+
user_data = verify_login(user_input, pass_input)
|
| 180 |
+
if user_data:
|
| 181 |
+
st.session_state.logged_in = True
|
| 182 |
+
st.session_state.username = user_data[0]
|
| 183 |
+
st.session_state.role = user_data[2] if user_data[2] else "Analista"
|
| 184 |
+
st.session_state.gemini_key = user_data[3] if user_data[3] else ""
|
| 185 |
+
st.session_state.tavily_key = user_data[4] if user_data[4] else ""
|
| 186 |
+
st.session_state.email_user = user_data[5] if user_data[5] else ""
|
| 187 |
+
st.session_state.email_pass = decrypt_data(user_data[6]) if user_data[6] else ""
|
| 188 |
+
df_saved, cg_saved = load_workspace_state(user_data[0])
|
| 189 |
+
if df_saved is not None:
|
| 190 |
+
st.session_state.df_exportar = df_saved
|
| 191 |
+
st.session_state.cg = cg_saved
|
| 192 |
+
st.session_state.procesado = True
|
| 193 |
+
st.rerun()
|
| 194 |
+
else:
|
| 195 |
+
st.error("Credenciales incorrectas. Intenta de nuevo.")
|
| 196 |
+
|
| 197 |
+
st.markdown('<div class="login-footer">Acceso restringido — Solo personal autorizado Proyelec</div>', unsafe_allow_html=True)
|
| 198 |
+
st.stop()
|
| 199 |
+
|
| 200 |
+
# --- NAVBAR RENDER ---
|
| 201 |
+
st.markdown(f"""
|
| 202 |
+
<div class="top-navbar">
|
| 203 |
+
<div style="font-size: 20px; font-weight: 800; color: #58A6FF; letter-spacing: -0.5px;">💎 PROYELEC <span style="color:white; font-weight:300;">SOURCING PRO</span></div>
|
| 204 |
+
<div style="font-size: 13px; color: #8B949E;">Usuario: <b style="color:#F0F6FC;">{st.session_state.username}</b> <span style="background:#1E2A3A;color:#58A6FF;padding:2px 8px;border-radius:10px;font-size:11px;">{st.session_state.role}</span> | {datetime.now().strftime("%d/%m/%Y")}</div>
|
| 205 |
+
</div>
|
| 206 |
+
<div class="main-content-spacer"></div>
|
| 207 |
+
""", unsafe_allow_html=True)
|
| 208 |
+
|
| 209 |
+
# --- PANEL DE ADMINISTRACION GLOBAL ---
|
| 210 |
+
if st.session_state.username == "admin":
|
| 211 |
+
st.markdown("""<style>[data-testid="stSidebar"] { display: none !important; }</style>""", unsafe_allow_html=True)
|
| 212 |
+
st.title("🛡️ Panel de Control Global")
|
| 213 |
+
st.markdown("Gestión de usuarios y monitorización del sistema central.")
|
| 214 |
+
|
| 215 |
+
tab_users, tab_stats = st.tabs(["👥 Gestión de Personal", "📊 Estadísticas del Sistema"])
|
| 216 |
+
|
| 217 |
+
with tab_users:
|
| 218 |
+
col1, col2 = st.columns([0.6, 0.4])
|
| 219 |
+
with col1:
|
| 220 |
+
st.subheader("Personal Registrado")
|
| 221 |
+
df_users = db.get_all_users()
|
| 222 |
+
st.dataframe(df_users, use_container_width=True, hide_index=True)
|
| 223 |
+
|
| 224 |
+
st.markdown("---")
|
| 225 |
+
st.subheader("Acciones Rápidas")
|
| 226 |
+
ac1, ac2 = st.columns(2)
|
| 227 |
+
with ac1:
|
| 228 |
+
del_user = st.text_input("Eliminar Usuario", placeholder="Nombre de usuario")
|
| 229 |
+
if st.button("🗑️ Eliminar Acceso", type="secondary"):
|
| 230 |
+
if del_user == "admin": st.error("No puedes eliminar al administrador maestro.")
|
| 231 |
+
elif del_user:
|
| 232 |
+
if db.delete_user(del_user):
|
| 233 |
+
st.success(f"Usuario {del_user} eliminado.")
|
| 234 |
+
st.rerun()
|
| 235 |
+
else: st.warning("Usuario no encontrado.")
|
| 236 |
+
with ac2:
|
| 237 |
+
reset_user = st.text_input("Resetear Contraseña", placeholder="Nombre de usuario")
|
| 238 |
+
new_pass = st.text_input("Nueva contraseña", type="password")
|
| 239 |
+
if st.button("🔄 Cambiar Contraseña", type="primary"):
|
| 240 |
+
if reset_user and new_pass:
|
| 241 |
+
db.reset_user_password(reset_user, new_pass)
|
| 242 |
+
st.success("Contraseña actualizada.")
|
| 243 |
+
st.rerun()
|
| 244 |
+
|
| 245 |
+
with col2:
|
| 246 |
+
st.markdown("""
|
| 247 |
+
<div style='background:#161B22; border:1px solid #30363D; padding:20px; border-radius:12px;'>
|
| 248 |
+
<h3 style='margin-top:0; color:#58A6FF;'>➕ Nuevo Usuario</h3>
|
| 249 |
+
""", unsafe_allow_html=True)
|
| 250 |
+
new_u = st.text_input("Nombre de Usuario (Login)")
|
| 251 |
+
new_p = st.text_input("Contraseña Temporal", type="password")
|
| 252 |
+
new_r = st.selectbox("Nivel de Acceso", ["Analista", "Gerencia"])
|
| 253 |
+
if st.button("Crear Cuenta", use_container_width=True, type="primary"):
|
| 254 |
+
if new_u and new_p:
|
| 255 |
+
if db.create_user(new_u, new_p, new_r):
|
| 256 |
+
st.success(f"✅ Cuenta de {new_r} creada para '{new_u}'")
|
| 257 |
+
st.rerun()
|
| 258 |
+
else:
|
| 259 |
+
st.error("⚠️ El usuario ya existe.")
|
| 260 |
+
else:
|
| 261 |
+
st.warning("Completa los campos.")
|
| 262 |
+
st.markdown("</div>", unsafe_allow_html=True)
|
| 263 |
+
|
| 264 |
+
st.markdown("<br>", unsafe_allow_html=True)
|
| 265 |
+
with st.expander("🔑 Configurar API Keys por Usuario"):
|
| 266 |
+
target_u = st.selectbox("Seleccionar Usuario", [""] + df_users['Usuario'].tolist())
|
| 267 |
+
if target_u:
|
| 268 |
+
conn = db.get_connection()
|
| 269 |
+
c = conn.cursor()
|
| 270 |
+
c.execute("SELECT gemini_key, tavily_key, email_user, email_pass_enc FROM users WHERE username=?", (target_u,))
|
| 271 |
+
curr = c.fetchone()
|
| 272 |
+
conn.close()
|
| 273 |
+
|
| 274 |
+
if curr:
|
| 275 |
+
new_g = st.text_input("Gemini API Key", value=curr[0], type="password", key="g_key")
|
| 276 |
+
new_t = st.text_input("Tavily API Key", value=curr[1], type="password", key="t_key")
|
| 277 |
+
new_e = st.text_input("Correo (IMAP/SMTP)", value=curr[2], key="e_key")
|
| 278 |
+
new_p = st.text_input("Contraseña de Correo", type="password", key="p_key", help="Solo escribe aquí si deseas cambiarla o configurarla por primera vez.")
|
| 279 |
+
|
| 280 |
+
if st.button("Guardar Llaves", type="primary", use_container_width=True):
|
| 281 |
+
# Si se escribió algo en new_p, lo encriptamos. Si no, mantenemos el actual (curr[3])
|
| 282 |
+
final_p_enc = encrypt_data(new_p) if new_p else curr[3]
|
| 283 |
+
db.update_user_profile(target_u, new_g, new_t, new_e, final_p_enc)
|
| 284 |
+
st.success(f"Configuración guardada para {target_u}")
|
| 285 |
+
st.rerun()
|
| 286 |
+
|
| 287 |
+
with tab_stats:
|
| 288 |
+
st.subheader("Salud del Sistema")
|
| 289 |
+
conn = db.get_connection()
|
| 290 |
+
try:
|
| 291 |
+
total_lic_monitor = pd.read_sql_query("SELECT COUNT(*) as c FROM seguimiento_licitaciones", conn).iloc[0]['c']
|
| 292 |
+
total_historial = pd.read_sql_query("SELECT COUNT(*) as c FROM history", conn).iloc[0]['c']
|
| 293 |
+
total_fichas = pd.read_sql_query("SELECT COUNT(*) as c FROM fichas_cache", conn).iloc[0]['c']
|
| 294 |
+
except Exception:
|
| 295 |
+
total_lic_monitor, total_historial, total_fichas = 0, 0, 0
|
| 296 |
+
conn.close()
|
| 297 |
+
|
| 298 |
+
s1, s2, s3 = st.columns(3)
|
| 299 |
+
s1.metric("Licitaciones en Monitor ACP", total_lic_monitor)
|
| 300 |
+
s2.metric("Pliegos Analizados (Histórico)", total_historial)
|
| 301 |
+
s3.metric("Fichas Técnicas Generadas", total_fichas)
|
| 302 |
+
|
| 303 |
+
st.markdown("---")
|
| 304 |
+
if st.button("Cerrar Sesión Segura", type="primary", use_container_width=True):
|
| 305 |
+
st.session_state.clear()
|
| 306 |
+
st.rerun()
|
| 307 |
+
|
| 308 |
+
st.stop()
|
| 309 |
+
|
| 310 |
+
# --- 5. SIDEBAR ---
|
| 311 |
+
with st.sidebar:
|
| 312 |
+
st.markdown("<br><br>", unsafe_allow_html=True)
|
| 313 |
+
|
| 314 |
+
st.title("📄 1. Cargar Requerimiento")
|
| 315 |
+
st.caption("Sube el pliego principal y los anexos técnicos en formato PDF.")
|
| 316 |
+
archivos_pdf = st.file_uploader("", type=["pdf"], label_visibility="collapsed", accept_multiple_files=True)
|
| 317 |
+
|
| 318 |
+
if st.button("🚀 Procesar con IA", type="primary", use_container_width=True):
|
| 319 |
+
if not st.session_state.gemini_key: st.error("⚠️ Verifica tus API Keys en la configuración.")
|
| 320 |
+
elif not archivos_pdf: st.error("⚠️ Falta subir al menos un documento.")
|
| 321 |
+
else:
|
| 322 |
+
with st.status("Conectando con Motor IA...", expanded=True) as status:
|
| 323 |
+
try:
|
| 324 |
+
archivos = [("archivos_pdf", (f.name, f.getvalue(), "application/pdf")) for f in archivos_pdf]
|
| 325 |
+
datos_formulario = {"gemini_key": st.session_state.gemini_key}
|
| 326 |
+
respuesta_api = requests.post(f"{API_URL_BASE}/analizar-pliego", files=archivos, data=datos_formulario, headers=API_HEADERS)
|
| 327 |
+
|
| 328 |
+
if respuesta_api.status_code == 200:
|
| 329 |
+
datos_crudos = respuesta_api.json()
|
| 330 |
+
cg = datos_crudos.get("condiciones_generales", {})
|
| 331 |
+
df_exportar = pd.DataFrame(datos_crudos.get("items", []))
|
| 332 |
+
|
| 333 |
+
try:
|
| 334 |
+
if not os.path.exists("ACP DATA LIC PASADAS v2_2.xlsx"):
|
| 335 |
+
st.warning("⚠️ No se encontró el histórico 'ACP DATA LIC PASADAS v2_2.xlsx'. Procesando sin precios base.")
|
| 336 |
+
else:
|
| 337 |
+
df_historico = pd.read_excel("ACP DATA LIC PASADAS v2_2.xlsx", skiprows=8).rename(columns=lambda x: str(x).strip())
|
| 338 |
+
|
| 339 |
+
# Limpiar códigos aislando solo el ID puro (Primera palabra, sin basura de la IA) y haciendo alfanumérico
|
| 340 |
+
df_historico['codigo_match'] = df_historico['CODIGO ACP'].astype(str).str.replace(r'[^a-zA-Z0-9]', '', regex=True).str.upper()
|
| 341 |
+
df_exportar['codigo_match'] = df_exportar['codigo_articulo'].astype(str).str.split().str[0].str.replace(r'[^a-zA-Z0-9]', '', regex=True).str.upper()
|
| 342 |
+
|
| 343 |
+
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')
|
| 344 |
+
df_cruzado = df_cruzado.drop(columns=['codigo_match']).rename(columns={'PRECIO COMPETENCIA': "precio_comp_hist", 'PRECIO PROYELEC': "precio_proy_hist"})
|
| 345 |
+
|
| 346 |
+
df_cruzado['precio_comp_hist'] = pd.to_numeric(df_cruzado['precio_comp_hist'], errors='coerce')
|
| 347 |
+
df_cruzado['precio_proy_hist'] = pd.to_numeric(df_cruzado['precio_proy_hist'], errors='coerce')
|
| 348 |
+
df_cruzado['margen_$'] = df_cruzado['precio_proy_hist'] - df_cruzado['precio_comp_hist']
|
| 349 |
+
df_exportar = df_cruzado
|
| 350 |
+
except Exception as excel_e:
|
| 351 |
+
st.warning(f"⚠️ Error leyendo Excel histórico: {excel_e}")
|
| 352 |
+
|
| 353 |
+
save_history(st.session_state.username, str(cg.get('numero_licitacion', 'Desconocida')), len(df_exportar))
|
| 354 |
+
save_workspace_state(st.session_state.username, cg, df_exportar)
|
| 355 |
+
|
| 356 |
+
st.session_state.df_exportar, st.session_state.cg, st.session_state.procesado = df_exportar, cg, True
|
| 357 |
+
status.update(label="✅ Análisis Completado", state="complete", expanded=False)
|
| 358 |
+
st.rerun()
|
| 359 |
+
else: status.update(label=f"❌ Error en API: {respuesta_api.text}", state="error")
|
| 360 |
+
except Exception as e: status.update(label=f"❌ Error crítico: {e}", state="error")
|
| 361 |
+
|
| 362 |
+
st.divider()
|
| 363 |
+
|
| 364 |
+
st.caption("Opciones del Sistema")
|
| 365 |
+
|
| 366 |
+
# Indicador de estado de la API
|
| 367 |
+
try:
|
| 368 |
+
_r = requests.get(f"{API_URL_BASE.replace('/api/v1','')}/", timeout=2, headers=API_HEADERS)
|
| 369 |
+
if _r.status_code == 200:
|
| 370 |
+
st.markdown("<div style='background:#1a3a1a;border:1px solid #238636;border-radius:8px;padding:8px 12px;font-size:12px;color:#3fb950;margin-bottom:10px;'>🟢 Motor IA <b>Activo</b></div>", unsafe_allow_html=True)
|
| 371 |
+
else:
|
| 372 |
+
st.markdown("<div style='background:#3a1a1a;border:1px solid #f85149;border-radius:8px;padding:8px 12px;font-size:12px;color:#f85149;margin-bottom:10px;'>🔴 Motor IA <b>Sin respuesta</b></div>", unsafe_allow_html=True)
|
| 373 |
+
except Exception:
|
| 374 |
+
st.markdown("<div style='background:#3a1a1a;border:1px solid #f85149;border-radius:8px;padding:8px 12px;font-size:12px;color:#f85149;margin-bottom:10px;'>🔴 Motor IA <b>Apagado</b> — Ejecuta run.bat</div>", unsafe_allow_html=True)
|
| 375 |
+
|
| 376 |
+
if st.session_state.role == "Gerencia":
|
| 377 |
+
with st.expander("⚙️ Configuración y Llaves", expanded=False):
|
| 378 |
+
nueva_gemini = st.text_input("Gemini API Key", value=st.session_state.gemini_key, type="password")
|
| 379 |
+
nueva_tavily = st.text_input("Tavily API Key", value=st.session_state.tavily_key, type="password")
|
| 380 |
+
nuevo_email = st.text_input("Correo Proyelec", value=st.session_state.email_user)
|
| 381 |
+
nuevo_email_pass = st.text_input("Contraseña", value=st.session_state.email_pass, type="password")
|
| 382 |
+
|
| 383 |
+
if st.button("Guardar Cambios", use_container_width=True):
|
| 384 |
+
update_user_profile(st.session_state.username, nueva_gemini, nueva_tavily, nuevo_email, nuevo_email_pass)
|
| 385 |
+
st.session_state.gemini_key, st.session_state.tavily_key = nueva_gemini, nueva_tavily
|
| 386 |
+
st.session_state.email_user, st.session_state.email_pass = nuevo_email, nuevo_email_pass
|
| 387 |
+
st.toast("✅ Configuración guardada.")
|
| 388 |
+
else:
|
| 389 |
+
st.markdown("""
|
| 390 |
+
<div style='background:#0D1117;border:1px solid #1E2A3A;border-radius:8px;
|
| 391 |
+
padding:10px 14px;font-size:12px;color:#484f58;margin-bottom:8px;'>
|
| 392 |
+
⚙️ Configuración administrada por Gerencia
|
| 393 |
+
</div>
|
| 394 |
+
""", unsafe_allow_html=True)
|
| 395 |
+
|
| 396 |
+
# --- PANEL DE ADMINISTRACIÓN (solo Gerencia) ---
|
| 397 |
+
if st.session_state.role == "Gerencia":
|
| 398 |
+
with st.expander("👥 Gestión de Usuarios", expanded=False):
|
| 399 |
+
st.caption("Panel exclusivo de Gerencia")
|
| 400 |
+
|
| 401 |
+
df_users = db.get_all_users()
|
| 402 |
+
if not df_users.empty:
|
| 403 |
+
for _, u in df_users.iterrows():
|
| 404 |
+
u_col1, u_col2, u_col3 = st.columns([0.45, 0.3, 0.25])
|
| 405 |
+
with u_col1:
|
| 406 |
+
st.markdown(f"<div style='font-size:13px;color:#F0F6FC;padding-top:6px;'><b>{u['username']}</b></div>", unsafe_allow_html=True)
|
| 407 |
+
with u_col2:
|
| 408 |
+
roles_opt = ["Gerencia", "Analista"]
|
| 409 |
+
idx = roles_opt.index(u['role']) if u['role'] in roles_opt else 1
|
| 410 |
+
nuevo_rol = st.selectbox("", roles_opt, index=idx, key=f"rol_{u['username']}", label_visibility="collapsed")
|
| 411 |
+
if nuevo_rol != u['role']:
|
| 412 |
+
db.update_user_role(u['username'], nuevo_rol)
|
| 413 |
+
st.toast(f"Rol de {u['username']} actualizado.")
|
| 414 |
+
st.rerun()
|
| 415 |
+
with u_col3:
|
| 416 |
+
if u['username'] != st.session_state.username:
|
| 417 |
+
if st.button("🗑️", key=f"del_{u['username']}", help=f"Eliminar {u['username']}"):
|
| 418 |
+
db.delete_user(u['username'])
|
| 419 |
+
st.toast(f"Usuario {u['username']} eliminado.")
|
| 420 |
+
st.rerun()
|
| 421 |
+
|
| 422 |
+
st.divider()
|
| 423 |
+
st.caption("Crear nuevo usuario")
|
| 424 |
+
new_u = st.text_input("Username", key="new_username", placeholder="ej: analista2")
|
| 425 |
+
new_p = st.text_input("Contraseña", key="new_password", type="password", placeholder="mínimo 6 caracteres")
|
| 426 |
+
new_r = st.selectbox("Rol", ["Analista", "Gerencia"], key="new_role")
|
| 427 |
+
if st.button("➕ Crear Usuario", use_container_width=True):
|
| 428 |
+
if new_u and new_p and len(new_p) >= 6:
|
| 429 |
+
ok = db.create_user(new_u, new_p, new_r)
|
| 430 |
+
if ok:
|
| 431 |
+
st.toast(f"✅ Usuario '{new_u}' creado con rol {new_r}.")
|
| 432 |
+
st.rerun()
|
| 433 |
+
else:
|
| 434 |
+
st.error(f"El usuario '{new_u}' ya existe.")
|
| 435 |
+
else:
|
| 436 |
+
st.warning("Username y contraseña (mín. 6 chars) son requeridos.")
|
| 437 |
+
|
| 438 |
+
st.divider()
|
| 439 |
+
st.caption("Resetear contraseña")
|
| 440 |
+
reset_u = st.selectbox("Usuario", df_users['username'].tolist() if not df_users.empty else [], key="reset_user")
|
| 441 |
+
reset_p = st.text_input("Nueva contraseña", key="reset_pass", type="password")
|
| 442 |
+
if st.button("🔑 Resetear", use_container_width=True):
|
| 443 |
+
if reset_u and reset_p and len(reset_p) >= 6:
|
| 444 |
+
db.reset_user_password(reset_u, reset_p)
|
| 445 |
+
st.toast(f"✅ Contraseña de '{reset_u}' actualizada.")
|
| 446 |
+
else:
|
| 447 |
+
st.warning("Selecciona usuario y escribe la nueva contraseña.")
|
| 448 |
+
|
| 449 |
+
if st.button("🚪 Cerrar Sesión", use_container_width=True):
|
| 450 |
+
st.session_state.logged_in = False
|
| 451 |
+
st.session_state.procesado = False
|
| 452 |
+
st.session_state.role = ""
|
| 453 |
+
st.rerun()
|
| 454 |
+
|
| 455 |
+
# --- 7. RENDERIZADO VISUAL PRINCIPAL ---
|
| 456 |
+
# Colores y emojis por estado ACP
|
| 457 |
+
ESTADO_CONFIG = {
|
| 458 |
+
"En Preparacion": ("🔵", "#1E3A5F", "#58A6FF"),
|
| 459 |
+
"Oferta Enviada al SLI": ("🟡", "#3D2E00", "#E3B341"),
|
| 460 |
+
"Cumple Tecnicamente": ("🟢", "#1A3A1A", "#3FB950"),
|
| 461 |
+
"No Cumple Tecnicamente": ("🔴", "#3A1A1A", "#F85149"),
|
| 462 |
+
"En Evaluacion Economica":("🟠", "#3A2A00", "#F0883E"),
|
| 463 |
+
"Adjudicada": ("🏆", "#1A3A2A", "#10B981"),
|
| 464 |
+
"No Adjudicada": ("❌", "#2A1A1A", "#6E7681"),
|
| 465 |
+
"Desierta": ("🚫", "#2A2A2A", "#484F58"),
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
# Redefinicion visual: badges sobrios para el tablero operativo.
|
| 469 |
+
ESTADO_CONFIG.update({
|
| 470 |
+
"En Preparacion": ("", "#101A2A", "#4F9CF9"),
|
| 471 |
+
"Oferta Enviada al SLI": ("", "#261F0B", "#F6B44B"),
|
| 472 |
+
"Cumple Tecnicamente": ("", "#0E241A", "#31C48D"),
|
| 473 |
+
"No Cumple Tecnicamente": ("", "#2A1111", "#EF5B5B"),
|
| 474 |
+
"En Evaluacion Economica":("", "#2A1D0F", "#F08A3C"),
|
| 475 |
+
"Adjudicada": ("", "#0E241A", "#31C48D"),
|
| 476 |
+
"No Adjudicada": ("", "#171C24", "#748091"),
|
| 477 |
+
"Desierta": ("", "#171C24", "#748091"),
|
| 478 |
+
})
|
| 479 |
+
|
| 480 |
+
tab_main, tab_hist, tab_acp = st.tabs([
|
| 481 |
+
"🚀 Tablero de Operaciones",
|
| 482 |
+
"📚 Base de Conocimiento",
|
| 483 |
+
"🏛️ Monitor ACP"
|
| 484 |
+
])
|
| 485 |
+
|
| 486 |
+
with tab_acp:
|
| 487 |
+
st.markdown("<div class='section-title'>Monitor ACP</div>", unsafe_allow_html=True)
|
| 488 |
+
st.markdown("<div class='section-subtitle'>Seguimiento de licitaciones, estados SLI y alertas de evaluacion.</div>", unsafe_allow_html=True)
|
| 489 |
+
seg_df = db.get_seguimientos()
|
| 490 |
+
|
| 491 |
+
# --- KPIs del monitor ---
|
| 492 |
+
total_seg = len(seg_df)
|
| 493 |
+
adjudicadas = len(seg_df[seg_df['estado'] == 'Adjudicada']) if not seg_df.empty else 0
|
| 494 |
+
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
|
| 495 |
+
tasa = f"{int(adjudicadas/total_seg*100)}%" if total_seg > 0 else "—"
|
| 496 |
+
|
| 497 |
+
mk1, mk2, mk3, mk4 = st.columns(4)
|
| 498 |
+
mk1.metric("Total Licitaciones", total_seg)
|
| 499 |
+
mk2.metric("En Evaluación", en_proceso)
|
| 500 |
+
mk3.metric("Adjudicadas", adjudicadas)
|
| 501 |
+
mk4.metric("Tasa de Éxito", tasa)
|
| 502 |
+
st.divider()
|
| 503 |
+
|
| 504 |
+
col_monitor, col_form = st.columns([0.62, 0.38])
|
| 505 |
+
|
| 506 |
+
with col_form:
|
| 507 |
+
with st.expander("➕ Añadir Licitación al Seguimiento", expanded=(total_seg == 0)):
|
| 508 |
+
f_num = st.text_input("Nº Licitación ACP (RFQ)*", placeholder="Ej: 213330", key="seg_num")
|
| 509 |
+
f_nota = st.text_area("Notas internas (opcional)", key="seg_nota", height=60)
|
| 510 |
+
|
| 511 |
+
if st.button("Añadir al Monitor", type="primary", use_container_width=True):
|
| 512 |
+
if f_num:
|
| 513 |
+
sli_rfq = "".join(filter(str.isdigit, f_num))
|
| 514 |
+
with st.status(f"Obteniendo datos del SLI para {sli_rfq}...", expanded=True):
|
| 515 |
+
try:
|
| 516 |
+
# Hacer peticion a la API local para consultar el SLI
|
| 517 |
+
resp_sli = requests.get(f"{API_URL_BASE}/consultar-sli/{sli_rfq}", timeout=45, headers=API_HEADERS)
|
| 518 |
+
|
| 519 |
+
f_obj = f"Licitación {sli_rfq}"
|
| 520 |
+
f_link = f"https://apps.pancanal.com/sli/Licitaciones/LicitacionHeader?rfqId={sli_rfq}"
|
| 521 |
+
f_estatus = ""
|
| 522 |
+
|
| 523 |
+
if resp_sli.status_code == 200:
|
| 524 |
+
datos_sli = resp_sli.json()
|
| 525 |
+
if datos_sli.get("descripcion"):
|
| 526 |
+
f_obj = datos_sli.get("descripcion")
|
| 527 |
+
if datos_sli.get("url"):
|
| 528 |
+
f_link = datos_sli.get("url")
|
| 529 |
+
f_estatus = datos_sli.get("estatus", "")
|
| 530 |
+
|
| 531 |
+
ok = db.crear_seguimiento(
|
| 532 |
+
sli_rfq, f_obj, "", "",
|
| 533 |
+
0.0, "USD", f_link, f_nota, st.session_state.username)
|
| 534 |
+
|
| 535 |
+
if ok:
|
| 536 |
+
# buscar la licitacion insertada
|
| 537 |
+
seg_df_new = db.get_seguimientos()
|
| 538 |
+
lic_row = seg_df_new[seg_df_new['numero_licitacion'] == sli_rfq]
|
| 539 |
+
if not lic_row.empty and f_estatus:
|
| 540 |
+
lic_id = int(lic_row.iloc[0]['id'])
|
| 541 |
+
estado_mapeado = MAPA_ESTADOS_SLI.get(f_estatus.upper(), f_estatus)
|
| 542 |
+
db.actualizar_estado(lic_id, estado_mapeado, f"Estado inicial desde SLI: {f_estatus}", "Sistema SLI")
|
| 543 |
+
st.toast(f"✅ Licitación {sli_rfq} añadida al monitor.")
|
| 544 |
+
st.rerun()
|
| 545 |
+
else:
|
| 546 |
+
st.error(f"La licitación '{sli_rfq}' ya existe en el sistema.")
|
| 547 |
+
except Exception as e:
|
| 548 |
+
st.error(f"Error conectando con la API: {e}")
|
| 549 |
+
else:
|
| 550 |
+
st.warning("El número de licitación es obligatorio.")
|
| 551 |
+
|
| 552 |
+
st.caption("🔗 Acceso rápido al portal")
|
| 553 |
+
st.link_button("Abrir SLI de la ACP", "https://sli.pancanal.com", use_container_width=True)
|
| 554 |
+
|
| 555 |
+
with col_monitor:
|
| 556 |
+
if seg_df.empty:
|
| 557 |
+
st.markdown("""
|
| 558 |
+
<div style='text-align:center;padding:50px 20px;background:rgba(22,27,34,0.6);
|
| 559 |
+
border-radius:16px;border:1px dashed #30363D;'>
|
| 560 |
+
<div style='font-size:48px;margin-bottom:12px;'>🏛️</div>
|
| 561 |
+
<h3 style='color:#8B949E;font-weight:500;'>Sin licitaciones registradas</h3>
|
| 562 |
+
<p style='color:#484f58;font-size:13px;'>Usa el formulario para registrar la primera licitación ACP en seguimiento.</p>
|
| 563 |
+
</div>
|
| 564 |
+
""", unsafe_allow_html=True)
|
| 565 |
+
else:
|
| 566 |
+
estado_filtro = st.selectbox(
|
| 567 |
+
"Filtrar por estado",
|
| 568 |
+
["Todos"] + list(db.ESTADOS_ACP),
|
| 569 |
+
label_visibility="collapsed",
|
| 570 |
+
key="monitor_estado_filtro"
|
| 571 |
+
)
|
| 572 |
+
seg_view = seg_df if estado_filtro == "Todos" else seg_df[seg_df["estado"] == estado_filtro]
|
| 573 |
+
|
| 574 |
+
if seg_view.empty:
|
| 575 |
+
st.info("No hay licitaciones en ese estado.")
|
| 576 |
+
|
| 577 |
+
for _, row in seg_view.iterrows():
|
| 578 |
+
estado = row.get('estado', 'En Preparacion')
|
| 579 |
+
emoji, bg_color, txt_color = ESTADO_CONFIG.get(estado, ("🔵", "#1E3A5F", "#58A6FF"))
|
| 580 |
+
lic_id = int(row['id'])
|
| 581 |
+
num_lic = row.get('numero_licitacion', '')
|
| 582 |
+
objeto = row.get('objeto', '')[:80]
|
| 583 |
+
resp = row.get('responsable', '')
|
| 584 |
+
link_sli = row.get('link_sli', '')
|
| 585 |
+
monto = row.get('monto_ofertado', 0) or 0
|
| 586 |
+
moneda = row.get('moneda', 'USD')
|
| 587 |
+
objeto_safe = escape(str(objeto or "Sin descripcion"))
|
| 588 |
+
resp_safe = escape(str(resp or "Sin responsable"))
|
| 589 |
+
estado_safe = escape(str(estado or ""))
|
| 590 |
+
|
| 591 |
+
# Calcular días desde envío oferta
|
| 592 |
+
dias_txt = ""
|
| 593 |
+
try:
|
| 594 |
+
f_env_dt = datetime.strptime(str(row.get('fecha_envio_oferta', ''))[:10], "%Y-%m-%d")
|
| 595 |
+
dias_el = (datetime.now() - f_env_dt).days
|
| 596 |
+
alerta = " ⚠️" if dias_el > 60 and estado in ["Oferta Enviada al SLI", "En Evaluacion Economica"] else ""
|
| 597 |
+
dias_txt = f"{dias_el} días en evaluación{alerta}"
|
| 598 |
+
except Exception:
|
| 599 |
+
dias_txt = ""
|
| 600 |
+
|
| 601 |
+
with st.container():
|
| 602 |
+
st.markdown(f"""
|
| 603 |
+
<div class='monitor-row' style='--status-color:{txt_color};'>
|
| 604 |
+
<div class='monitor-row-head'>
|
| 605 |
+
<div>
|
| 606 |
+
<div class='monitor-owner'>{resp_safe}</div>
|
| 607 |
+
<div class='monitor-id'>{num_lic}</div>
|
| 608 |
+
<div class='monitor-object'>{objeto_safe}</div>
|
| 609 |
+
</div>
|
| 610 |
+
<div>
|
| 611 |
+
<div class='status-badge'><span class='status-dot'></span>{estado_safe}</div>
|
| 612 |
+
<div class='monitor-meta'>
|
| 613 |
+
<span class='mini-pill'>{dias_txt or "Sin fecha base"}</span>
|
| 614 |
+
<span class='mini-pill'>{moneda} {monto:,.2f}</span>
|
| 615 |
+
</div>
|
| 616 |
+
</div>
|
| 617 |
+
</div>
|
| 618 |
+
</div>
|
| 619 |
+
""", unsafe_allow_html=True)
|
| 620 |
+
|
| 621 |
+
# Controles — fila 1: manual
|
| 622 |
+
resumen_acta_cache = st.session_state.get(f"sli_resumen_acta_{lic_id}")
|
| 623 |
+
if resumen_acta_cache:
|
| 624 |
+
resumen_txt = escape(str(resumen_acta_cache.get("resumen", "Resumen de propuestas consultado.")))
|
| 625 |
+
st.markdown(f"""
|
| 626 |
+
<div class='sli-summary'>
|
| 627 |
+
<div class='sli-summary-title'>Resumen SLI</div>
|
| 628 |
+
<div class='sli-summary-body'>{resumen_txt}</div>
|
| 629 |
+
</div>
|
| 630 |
+
""", unsafe_allow_html=True)
|
| 631 |
+
hallazgos_cache = resumen_acta_cache.get("hallazgos", [])
|
| 632 |
+
if hallazgos_cache:
|
| 633 |
+
with st.expander("Ver observaciones detectadas en resumen SLI"):
|
| 634 |
+
for hallazgo in hallazgos_cache:
|
| 635 |
+
st.markdown(f"- {hallazgo}")
|
| 636 |
+
if resumen_acta_cache.get("url"):
|
| 637 |
+
st.link_button("Abrir resumen SLI", resumen_acta_cache.get("url"))
|
| 638 |
+
|
| 639 |
+
ac1, ac2, ac3 = st.columns([0.4, 0.35, 0.25])
|
| 640 |
+
nuevo_estado = ac1.selectbox("Estado manual", db.ESTADOS_ACP,
|
| 641 |
+
index=db.ESTADOS_ACP.index(estado) if estado in db.ESTADOS_ACP else 0,
|
| 642 |
+
key=f"est_{lic_id}", label_visibility="collapsed")
|
| 643 |
+
nota_upd = ac2.text_input("Nota", key=f"nota_{lic_id}",
|
| 644 |
+
placeholder="Observación...", label_visibility="collapsed")
|
| 645 |
+
with ac3:
|
| 646 |
+
if st.button("Guardar", key=f"upd_{lic_id}", use_container_width=True):
|
| 647 |
+
db.actualizar_estado(lic_id, nuevo_estado, nota_upd, st.session_state.username)
|
| 648 |
+
st.toast(f"✅ Estado actualizado a '{nuevo_estado}'")
|
| 649 |
+
st.rerun()
|
| 650 |
+
|
| 651 |
+
# Controles — fila 2: consulta automática SLI
|
| 652 |
+
sli_col1, sli_col2, sli_col3 = st.columns([0.38, 0.34, 0.28])
|
| 653 |
+
with sli_col1:
|
| 654 |
+
sli_rfq = "".join(filter(str.isdigit, num_lic))
|
| 655 |
+
if st.button("Consultar SLI", key=f"sli_{lic_id}", use_container_width=True, type="primary"):
|
| 656 |
+
with st.status(f"Consultando SLI para licitación {num_lic}...", expanded=True):
|
| 657 |
+
try:
|
| 658 |
+
resp_sli = requests.get(
|
| 659 |
+
f"{API_URL_BASE}/consultar-sli/{sli_rfq}",
|
| 660 |
+
timeout=75,
|
| 661 |
+
headers=API_HEADERS
|
| 662 |
+
)
|
| 663 |
+
if resp_sli.status_code == 200:
|
| 664 |
+
datos_sli = resp_sli.json()
|
| 665 |
+
estatus_sli = datos_sli.get("estatus")
|
| 666 |
+
desc_sli = datos_sli.get("descripcion", "")
|
| 667 |
+
cierre_sli = datos_sli.get("fecha_cierre", "")
|
| 668 |
+
rev_sli = datos_sli.get("ultima_revision", "")
|
| 669 |
+
resumen_acta = datos_sli.get("resumen_acta", {})
|
| 670 |
+
err_sli = datos_sli.get("error")
|
| 671 |
+
|
| 672 |
+
if err_sli:
|
| 673 |
+
st.warning(f"⚠️ {err_sli}")
|
| 674 |
+
elif estatus_sli:
|
| 675 |
+
# Mapear estatus SLI al catálogo interno
|
| 676 |
+
estado_mapeado = MAPA_ESTADOS_SLI.get(
|
| 677 |
+
estatus_sli.upper(), estatus_sli)
|
| 678 |
+
nota_auto = f"[SLI Auto] Estatus: {estatus_sli}"
|
| 679 |
+
if cierre_sli:
|
| 680 |
+
nota_auto += f" | Cierre: {cierre_sli}"
|
| 681 |
+
if rev_sli:
|
| 682 |
+
nota_auto += f" | Última rev: {rev_sli}"
|
| 683 |
+
if resumen_acta.get("disponible"):
|
| 684 |
+
st.session_state[f"sli_resumen_acta_{lic_id}"] = resumen_acta
|
| 685 |
+
nota_auto += f" | Resumen: {resumen_acta.get('resumen', '')}"
|
| 686 |
+
if resumen_acta.get("hallazgos"):
|
| 687 |
+
nota_auto += " | Observaciones: " + " / ".join(resumen_acta.get("hallazgos", [])[:3])
|
| 688 |
+
elif resumen_acta.get("error"):
|
| 689 |
+
st.session_state.pop(f"sli_resumen_acta_{lic_id}", None)
|
| 690 |
+
nota_auto += f" | Resumen SLI: {resumen_acta.get('error')}"
|
| 691 |
+
db.actualizar_estado(lic_id, estado_mapeado,
|
| 692 |
+
nota_auto, "Sistema SLI")
|
| 693 |
+
if resumen_acta.get("disponible"):
|
| 694 |
+
st.info(resumen_acta.get("resumen", "Resumen de propuestas consultado."))
|
| 695 |
+
for hallazgo in resumen_acta.get("hallazgos", [])[:3]:
|
| 696 |
+
st.markdown(f"- {hallazgo}")
|
| 697 |
+
st.success(f"✅ Estado SLI: **{estatus_sli}** — Registro actualizado")
|
| 698 |
+
st.rerun()
|
| 699 |
+
else:
|
| 700 |
+
st.warning("El SLI no devolvió un estatus reconocible.")
|
| 701 |
+
elif resp_sli.status_code == 503:
|
| 702 |
+
try:
|
| 703 |
+
err_detail = resp_sli.json().get("detail", {})
|
| 704 |
+
except Exception:
|
| 705 |
+
err_detail = {}
|
| 706 |
+
if isinstance(err_detail, dict):
|
| 707 |
+
st.error(f"⚠️ {err_detail.get('message', 'Servicio SLI no disponible')}")
|
| 708 |
+
if err_detail.get("hint"):
|
| 709 |
+
st.caption(err_detail.get("hint"))
|
| 710 |
+
else:
|
| 711 |
+
st.error(f"⚠️ {err_detail or 'Servicio SLI no disponible'}")
|
| 712 |
+
else:
|
| 713 |
+
try:
|
| 714 |
+
err_detail = resp_sli.json().get("detail", {})
|
| 715 |
+
except Exception:
|
| 716 |
+
err_detail = {}
|
| 717 |
+
if isinstance(err_detail, dict):
|
| 718 |
+
st.error(f"Error SLI {resp_sli.status_code}: {err_detail.get('message', 'No se pudo consultar el SLI')}")
|
| 719 |
+
if err_detail.get("hint"):
|
| 720 |
+
st.caption(err_detail.get("hint"))
|
| 721 |
+
if err_detail.get("technical"):
|
| 722 |
+
with st.expander("Detalle tecnico"):
|
| 723 |
+
st.code(err_detail.get("technical"))
|
| 724 |
+
else:
|
| 725 |
+
st.error(f"Error SLI {resp_sli.status_code}: {err_detail or 'No se pudo consultar el SLI'}")
|
| 726 |
+
except requests.exceptions.Timeout:
|
| 727 |
+
st.error("⏱️ El SLI tardó demasiado. Intenta de nuevo.")
|
| 728 |
+
except Exception as e_sli:
|
| 729 |
+
st.error(f"Error: {e_sli}")
|
| 730 |
+
with sli_col2:
|
| 731 |
+
sli_url_directo = link_sli or f"https://apps.pancanal.com/sli/Licitaciones/LicitacionHeader?rfqId={sli_rfq}"
|
| 732 |
+
st.link_button("Ver en SLI", sli_url_directo, use_container_width=True)
|
| 733 |
+
with sli_col3:
|
| 734 |
+
if st.button("Eliminar", key=f"del_seg_{lic_id}", help="Eliminar del Monitor ACP", use_container_width=True):
|
| 735 |
+
db.eliminar_seguimiento(lic_id)
|
| 736 |
+
st.toast(f"Seguimiento {num_lic} eliminado.", icon="🗑️")
|
| 737 |
+
st.rerun()
|
| 738 |
+
|
| 739 |
+
|
| 740 |
+
# Historial colapsable
|
| 741 |
+
df_hist_seg = db.get_historial_seguimiento(lic_id)
|
| 742 |
+
if not df_hist_seg.empty:
|
| 743 |
+
with st.expander(f"📋 Historial ({len(df_hist_seg)} actualizaciones)"):
|
| 744 |
+
for _, h in df_hist_seg.iterrows():
|
| 745 |
+
h_emoji, _, h_color = ESTADO_CONFIG.get(h['estado_nuevo'], ("🔵","","#58A6FF"))
|
| 746 |
+
st.markdown(f"""
|
| 747 |
+
<div style='border-left:3px solid {h_color};padding:6px 12px;margin-bottom:6px;'>
|
| 748 |
+
<div style='font-size:11px;color:#484f58;'>{h['fecha'][:16]} — {h['registrado_por']}</div>
|
| 749 |
+
<div style='font-size:13px;color:#F0F6FC;'>{h_emoji} <b>{h['estado_nuevo']}</b></div>
|
| 750 |
+
<div style='font-size:12px;color:#8B949E;'>{h['nota'] or ''}</div>
|
| 751 |
+
</div>
|
| 752 |
+
""", unsafe_allow_html=True)
|
| 753 |
+
st.markdown("---")
|
| 754 |
+
|
| 755 |
+
with tab_hist:
|
| 756 |
+
df_history = get_user_history(st.session_state.username)
|
| 757 |
+
|
| 758 |
+
# --- STATS GLOBALES ---
|
| 759 |
+
total_lic_h = len(df_history)
|
| 760 |
+
total_reng_h = int(df_history['Renglones'].sum()) if not df_history.empty and 'Renglones' in df_history.columns else 0
|
| 761 |
+
ultima_h = df_history['Fecha Proceso'].iloc[0][:10] if not df_history.empty else "—"
|
| 762 |
+
|
| 763 |
+
hk1, hk2, hk3 = st.columns(3)
|
| 764 |
+
hk1.metric("Total Licitaciones Procesadas", total_lic_h)
|
| 765 |
+
hk2.metric("Total Renglones Analizados", total_reng_h)
|
| 766 |
+
hk3.metric("Última Actividad", ultima_h)
|
| 767 |
+
st.divider()
|
| 768 |
+
|
| 769 |
+
if df_history.empty:
|
| 770 |
+
st.markdown("""
|
| 771 |
+
<div style='text-align:center; padding:60px 20px; background:rgba(22,27,34,0.6);
|
| 772 |
+
border-radius:16px; border:1px dashed #30363D;'>
|
| 773 |
+
<div style='font-size:48px; margin-bottom:16px;'>📂</div>
|
| 774 |
+
<h3 style='color:#8B949E; font-weight:500;'>Sin registros aun</h3>
|
| 775 |
+
<p style='color:#484f58; font-size:14px;'>Procesa tu primer pliego para comenzar a construir el historial.</p>
|
| 776 |
+
</div>
|
| 777 |
+
""", unsafe_allow_html=True)
|
| 778 |
+
else:
|
| 779 |
+
# --- BUSCADOR ---
|
| 780 |
+
busqueda = st.text_input("🔍 Buscar por número de licitación", placeholder="Ej: ACP-2024-001", label_visibility="collapsed")
|
| 781 |
+
df_filtrado = df_history[df_history['Nº Licitación'].str.contains(busqueda, case=False, na=False)] if busqueda else df_history
|
| 782 |
+
|
| 783 |
+
col_tabla, col_acciones = st.columns([0.65, 0.35])
|
| 784 |
+
|
| 785 |
+
with col_tabla:
|
| 786 |
+
st.caption(f"Mostrando {len(df_filtrado)} de {len(df_history)} licitaciones — Selecciona una fila para ver opciones")
|
| 787 |
+
ev_hist = st.dataframe(
|
| 788 |
+
df_filtrado,
|
| 789 |
+
use_container_width=True,
|
| 790 |
+
hide_index=True,
|
| 791 |
+
on_select="rerun",
|
| 792 |
+
selection_mode="single-row",
|
| 793 |
+
column_config={
|
| 794 |
+
"Nº Licitación": st.column_config.TextColumn("Nº Licitación", width="medium"),
|
| 795 |
+
"Fecha Proceso": st.column_config.TextColumn("Fecha", width="medium"),
|
| 796 |
+
"Renglones": st.column_config.NumberColumn("Renglones", width="small"),
|
| 797 |
+
}
|
| 798 |
+
)
|
| 799 |
+
|
| 800 |
+
with col_acciones:
|
| 801 |
+
st.caption("Acciones")
|
| 802 |
+
|
| 803 |
+
# --- WORKSPACES GUARDADOS ---
|
| 804 |
+
es_gerencia = st.session_state.role == "Gerencia"
|
| 805 |
+
df_ws_list = db.get_all_workspaces(st.session_state.username, all_users=es_gerencia)
|
| 806 |
+
|
| 807 |
+
if df_ws_list.empty:
|
| 808 |
+
st.info("No hay workspaces guardados.")
|
| 809 |
+
else:
|
| 810 |
+
st.caption(f"{'Todos los workspaces del equipo' if es_gerencia else 'Mis workspaces guardados'} ({len(df_ws_list)})")
|
| 811 |
+
for _, ws_row in df_ws_list.iterrows():
|
| 812 |
+
ws_lic = ws_row['licitacion']
|
| 813 |
+
ws_user = ws_row.get('username', st.session_state.username)
|
| 814 |
+
ws_fecha = ws_row.get('fecha_guardado', '')[:10]
|
| 815 |
+
wc1, wc2, wc3 = st.columns([0.5, 0.25, 0.25])
|
| 816 |
+
with wc1:
|
| 817 |
+
st.markdown(f"<div style='font-size:12px;color:#F0F6FC;padding-top:6px;'><b>{ws_lic}</b><br><span style='color:#484f58;font-size:10px;'>{ws_user} · {ws_fecha}</span></div>", unsafe_allow_html=True)
|
| 818 |
+
with wc2:
|
| 819 |
+
if st.button("Cargar", key=f"ws_load_{ws_user}_{ws_lic}", use_container_width=True):
|
| 820 |
+
df_ws_load, cg_ws_load = load_workspace_by_licitacion(ws_user, ws_lic)
|
| 821 |
+
if df_ws_load is not None:
|
| 822 |
+
st.session_state.df_exportar = df_ws_load
|
| 823 |
+
st.session_state.cg = cg_ws_load
|
| 824 |
+
st.session_state.procesado = True
|
| 825 |
+
st.toast(f"✅ Workspace '{ws_lic}' cargado.", icon="🔄")
|
| 826 |
+
st.rerun()
|
| 827 |
+
with wc3:
|
| 828 |
+
if st.button("🗑️", key=f"ws_del_{ws_user}_{ws_lic}", help="Eliminar workspace"):
|
| 829 |
+
db.delete_workspace(ws_user, ws_lic)
|
| 830 |
+
st.toast(f"Workspace '{ws_lic}' eliminado.")
|
| 831 |
+
st.rerun()
|
| 832 |
+
|
| 833 |
+
st.divider()
|
| 834 |
+
|
| 835 |
+
# Si seleccionaron una fila — ver sus correos
|
| 836 |
+
if len(ev_hist.selection.rows) > 0:
|
| 837 |
+
fila_hist = df_filtrado.iloc[ev_hist.selection.rows[0]]
|
| 838 |
+
lic_sel = str(fila_hist.get('Nº Licitación', ''))
|
| 839 |
+
num_sel = "".join(re.findall(r'\d+', lic_sel))
|
| 840 |
+
|
| 841 |
+
st.markdown(f"""
|
| 842 |
+
<div style='background:#161B22; border:1px solid #238636; border-radius:10px; padding:14px; margin-bottom:12px;'>
|
| 843 |
+
<div style='font-size:11px; color:#3fb950; text-transform:uppercase; letter-spacing:1px;'>Seleccionada</div>
|
| 844 |
+
<div style='font-size:14px; font-weight:700; color:#F0F6FC; margin-top:4px;'>{lic_sel}</div>
|
| 845 |
+
<div style='font-size:11px; color:#8B949E;'>{int(fila_hist.get('Renglones', 0))} renglones procesados</div>
|
| 846 |
+
</div>
|
| 847 |
+
""", unsafe_allow_html=True)
|
| 848 |
+
|
| 849 |
+
df_correos_hist = obtener_correos_licitacion(num_sel)
|
| 850 |
+
n_correos = len(df_correos_hist)
|
| 851 |
+
st.metric("Correos en Bandeja", n_correos)
|
| 852 |
+
|
| 853 |
+
if n_correos > 0:
|
| 854 |
+
with st.expander(f"📥 Ver {n_correos} correo(s) de esta licitación"):
|
| 855 |
+
for _, ch in df_correos_hist.iterrows():
|
| 856 |
+
st.markdown(f"""
|
| 857 |
+
<div class='email-card'>
|
| 858 |
+
<div style='display:flex;justify-content:space-between;color:#8B949E;font-size:11px;margin-bottom:6px;'>
|
| 859 |
+
<strong>{ch.get('remitente','')[:40]}</strong>
|
| 860 |
+
<span>{ch.get('fecha','')}</span>
|
| 861 |
+
</div>
|
| 862 |
+
<div style='color:#E0E0E0;font-size:14px;font-weight:600;margin-bottom:6px;'>{ch.get('asunto','')}</div>
|
| 863 |
+
<div style='color:#C9D1D9;font-size:12px;'>💡 {ch.get('resumen','')}</div>
|
| 864 |
+
</div>
|
| 865 |
+
""", unsafe_allow_html=True)
|
| 866 |
+
|
| 867 |
+
st.markdown("<br>", unsafe_allow_html=True)
|
| 868 |
+
if st.button("🗑️ Eliminar este Registro", key=f"del_hist_{lic_sel}", use_container_width=True):
|
| 869 |
+
db.delete_history_entry(st.session_state.username, lic_sel)
|
| 870 |
+
st.toast(f"Registro {lic_sel} eliminado.", icon="🗑️")
|
| 871 |
+
st.rerun()
|
| 872 |
+
|
| 873 |
+
st.divider()
|
| 874 |
+
# Exportar historial completo
|
| 875 |
+
buf_hist = io.BytesIO()
|
| 876 |
+
df_history.to_excel(buf_hist, index=False, engine='openpyxl')
|
| 877 |
+
|
| 878 |
+
col_exp, col_del = st.columns([0.7, 0.3])
|
| 879 |
+
with col_exp:
|
| 880 |
+
st.download_button("📥 Exportar Historial a Excel", data=buf_hist.getvalue(),
|
| 881 |
+
file_name="Historial_PROCURA.xlsx",
|
| 882 |
+
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 883 |
+
use_container_width=True)
|
| 884 |
+
with col_del:
|
| 885 |
+
if st.button("⚠️ Borrar Todo", use_container_width=True, type="secondary"):
|
| 886 |
+
db.clear_all_history(st.session_state.username)
|
| 887 |
+
st.toast("Historial borrado completamente.", icon="✅")
|
| 888 |
+
st.rerun()
|
| 889 |
+
|
| 890 |
+
with tab_main:
|
| 891 |
+
if not st.session_state.procesado:
|
| 892 |
+
# --- KPIs REALES DEL SISTEMA ---
|
| 893 |
+
df_hist_kpi = get_user_history(st.session_state.username)
|
| 894 |
+
total_lic = len(df_hist_kpi)
|
| 895 |
+
total_reng = int(df_hist_kpi['Renglones'].sum()) if not df_hist_kpi.empty and 'Renglones' in df_hist_kpi.columns else 0
|
| 896 |
+
ultima_act = df_hist_kpi['Fecha Proceso'].iloc[0][:10] if not df_hist_kpi.empty else "Sin actividad"
|
| 897 |
+
|
| 898 |
+
st.markdown("<br>", unsafe_allow_html=True)
|
| 899 |
+
st.markdown(f"""
|
| 900 |
+
<div style='text-align:center; margin-bottom: 8px;'>
|
| 901 |
+
<span style='font-size:13px; color:#58A6FF; font-weight:600; letter-spacing:2px; text-transform:uppercase;'>Centro de Mando</span>
|
| 902 |
+
</div>
|
| 903 |
+
<h2 style='text-align:center; color:#F0F6FC; margin:0 0 6px 0; font-weight:800;'>Bienvenido, {st.session_state.username}</h2>
|
| 904 |
+
<p style='text-align:center; color:#8B949E; margin-bottom:28px; font-size:14px;'>Sistema de Inteligencia de Sourcing — Proyelec</p>
|
| 905 |
+
""", unsafe_allow_html=True)
|
| 906 |
+
|
| 907 |
+
k1, k2, k3, k4 = st.columns(4)
|
| 908 |
+
k1.metric("Licitaciones Procesadas", total_lic)
|
| 909 |
+
k2.metric("Renglones Analizados", total_reng)
|
| 910 |
+
k3.metric("Ultima Actividad", ultima_act)
|
| 911 |
+
k4.metric("Motor IA", "Gemini 2.5 Flash")
|
| 912 |
+
|
| 913 |
+
st.markdown("<br>", unsafe_allow_html=True)
|
| 914 |
+
st.markdown("<p style='text-align:center;color:#484f58;font-size:13px;margin-bottom:20px;'>Para comenzar, sube el pliego en el panel izquierdo y presiona <b style=\'color:#58A6FF\'>Procesar con IA</b></p>", unsafe_allow_html=True)
|
| 915 |
+
|
| 916 |
+
c1, c2, c3 = st.columns(3)
|
| 917 |
+
with c1:
|
| 918 |
+
st.markdown("""
|
| 919 |
+
<div class='step-card'>
|
| 920 |
+
<div class='step-icon'>📄</div>
|
| 921 |
+
<h4>1. Carga el Pliego</h4>
|
| 922 |
+
<p style='color:#8B949E; font-size:14px;'>Sube el PDF oficial de la licitacion. Soporta pliego principal y anexos tecnicos simultaneamente.</p>
|
| 923 |
+
</div>
|
| 924 |
+
""", unsafe_allow_html=True)
|
| 925 |
+
with c2:
|
| 926 |
+
st.markdown("""
|
| 927 |
+
<div class='step-card'>
|
| 928 |
+
<div class='step-icon'>🧠</div>
|
| 929 |
+
<h4>2. Analisis con IA</h4>
|
| 930 |
+
<p style='color:#8B949E; font-size:14px;'>Gemini extrae renglones, codigos, cantidades y cruza automaticamente con el historico de precios.</p>
|
| 931 |
+
</div>
|
| 932 |
+
""", unsafe_allow_html=True)
|
| 933 |
+
with c3:
|
| 934 |
+
st.markdown("""
|
| 935 |
+
<div class='step-card'>
|
| 936 |
+
<div class='step-icon'>🎯</div>
|
| 937 |
+
<h4>3. Sourcing Global</h4>
|
| 938 |
+
<p style='color:#8B949E; font-size:14px;'>Busca los mejores proveedores, genera RFQs y organiza cotizaciones en tu bandeja inteligente.</p>
|
| 939 |
+
</div>
|
| 940 |
+
""", unsafe_allow_html=True)
|
| 941 |
+
|
| 942 |
+
else:
|
| 943 |
+
df_render = st.session_state.df_exportar
|
| 944 |
+
cg_render = st.session_state.cg
|
| 945 |
+
|
| 946 |
+
t1, t2, t3, t4 = st.tabs(["📋 1. Matriz de Productos", "✉️ 2. Emisión de RFQs", "📥 3. Bandeja Inteligente", "📈 4. Análisis de Costos"])
|
| 947 |
+
|
| 948 |
+
with t1:
|
| 949 |
+
st.markdown(f"""
|
| 950 |
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 10px;">
|
| 951 |
+
<div style="background:#161B22; padding:15px; border-radius:8px; border:1px solid #30363D;">
|
| 952 |
+
<div style="font-size:11px; color:#8B949E; text-transform:uppercase;">Nº Licitación</div>
|
| 953 |
+
<div style="font-size:16px; color:#58A6FF; font-weight:bold; margin-top:5px;">{cg_render.get('numero_licitacion', 'N/A')}</div>
|
| 954 |
+
</div>
|
| 955 |
+
<div style="background:#161B22; padding:15px; border-radius:8px; border:1px solid #30363D;">
|
| 956 |
+
<div style="font-size:11px; color:#8B949E; text-transform:uppercase;">Lugar Entrega</div>
|
| 957 |
+
<div style="font-size:14px; color:#E6EDF3; margin-top:5px; word-wrap:break-word;">{cg_render.get('lugar_de_entrega', 'N/A')}</div>
|
| 958 |
+
</div>
|
| 959 |
+
<div style="background:#161B22; padding:15px; border-radius:8px; border:1px solid #30363D;">
|
| 960 |
+
<div style="font-size:11px; color:#8B949E; text-transform:uppercase;">Tiempo Entrega</div>
|
| 961 |
+
<div style="font-size:14px; color:#E6EDF3; margin-top:5px; word-wrap:break-word;">{cg_render.get('tiempo_de_entrega_global', 'N/A')}</div>
|
| 962 |
+
</div>
|
| 963 |
+
<div style="background:#161B22; padding:15px; border-radius:8px; border:1px solid #30363D;">
|
| 964 |
+
<div style="font-size:11px; color:#8B949E; text-transform:uppercase;">Garantía</div>
|
| 965 |
+
<div style="font-size:14px; color:#E6EDF3; margin-top:5px; word-wrap:break-word;">{cg_render.get('garantia_exigida', 'N/A')}</div>
|
| 966 |
+
</div>
|
| 967 |
+
<div style="background:#161B22; padding:15px; border-radius:8px; border:1px solid #30363D;">
|
| 968 |
+
<div style="font-size:11px; color:#8B949E; text-transform:uppercase;">Req. Prop. Técnica</div>
|
| 969 |
+
<div style="font-size:14px; color:#3FB950; margin-top:5px; font-weight:bold;">{cg_render.get('propuesta_tecnica_requerida', 'N/A')}</div>
|
| 970 |
+
</div>
|
| 971 |
+
<div style="background:#161B22; padding:15px; border-radius:8px; border:1px solid #30363D;">
|
| 972 |
+
<div style="font-size:11px; color:#8B949E; text-transform:uppercase;">Validez de Oferta</div>
|
| 973 |
+
<div style="font-size:14px; color:#F0883E; margin-top:5px; font-weight:bold;">{cg_render.get('validez_de_la_oferta', 'N/A')}</div>
|
| 974 |
+
</div>
|
| 975 |
+
</div>
|
| 976 |
+
""", unsafe_allow_html=True)
|
| 977 |
+
st.divider()
|
| 978 |
+
|
| 979 |
+
col_config = {
|
| 980 |
+
"renglon": st.column_config.TextColumn("Renglón", width="small"),
|
| 981 |
+
"codigo_articulo": st.column_config.TextColumn("Código", width="medium"),
|
| 982 |
+
"cantidad": st.column_config.NumberColumn("Cant.", format="%d"),
|
| 983 |
+
"precio_comp_hist": st.column_config.NumberColumn("Mejor Comp.", format="$ %.2f"),
|
| 984 |
+
"precio_proy_hist": st.column_config.NumberColumn("Proyelec", format="$ %.2f"),
|
| 985 |
+
"margen_$": st.column_config.NumberColumn("Margen", format="$ %.2f"),
|
| 986 |
+
"ficha_tecnica_completa": None, "termino_de_busqueda_corto": None
|
| 987 |
+
}
|
| 988 |
+
|
| 989 |
+
col_exp, col_xls = st.columns([0.7, 0.3])
|
| 990 |
+
with col_exp:
|
| 991 |
+
st.caption("Selecciona una fila para ver el detalle y buscar proveedores.")
|
| 992 |
+
with col_xls:
|
| 993 |
+
_buf = io.BytesIO()
|
| 994 |
+
df_render.to_excel(_buf, index=False, engine='openpyxl')
|
| 995 |
+
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)
|
| 996 |
+
event = st.dataframe(df_render, column_config=col_config, use_container_width=True, hide_index=True, on_select="rerun", selection_mode="single-row")
|
| 997 |
+
|
| 998 |
+
if len(event.selection.rows) > 0:
|
| 999 |
+
fila = df_render.iloc[event.selection.rows[0]]
|
| 1000 |
+
termino_google = urllib.parse.quote(str(fila.get('termino_de_busqueda_corto', '')))
|
| 1001 |
+
|
| 1002 |
+
# --- AQUÍ ESTÁ EL FIX DEL MARKDOWN CON LOS BOTONES DEL TAMAÑO CORRECTO ---
|
| 1003 |
+
st.markdown(f"""
|
| 1004 |
+
<div class="detail-card">
|
| 1005 |
+
<h3 style="color:#58A6FF; margin-top:0;">Renglón {fila.get('renglon', '-')} | Cód: {fila.get('codigo_articulo', 'N/A')}</h3>
|
| 1006 |
+
<div style="background-color:#0D1117; padding:15px; border-radius:8px; color:#C9D1D9; font-family:monospace; margin-bottom:15px; border: 1px solid #30363D;">{fila.get('ficha_tecnica_completa', 'Sin descripción')}</div>
|
| 1007 |
+
<div style="display: flex; gap: 15px; flex-wrap: wrap; margin-top: 20px;">
|
| 1008 |
+
<a style="background-color: #4285F4; color: white; padding: 12px 20px; border-radius: 8px; text-decoration: none; font-weight: 700; font-size: 15px; text-align: center; flex-grow: 1; min-width: 180px; box-shadow: 0 4px 6px rgba(0,0,0,0.3);" href="https://www.google.com/search?q={termino_google}+supplier+distributor" target="_blank">🔍 Google B2B</a>
|
| 1009 |
+
<a style="background-color: #0033A0; color: white; padding: 12px 20px; border-radius: 8px; text-decoration: none; font-weight: 700; font-size: 15px; text-align: center; flex-grow: 1; min-width: 180px; box-shadow: 0 4px 6px rgba(0,0,0,0.3);" href="https://www.thomasnet.com/search.html?cov=NA&what={termino_google}" target="_blank">⚙️ ThomasNet</a>
|
| 1010 |
+
<a style="background-color: #FF6A00; color: white; padding: 12px 20px; border-radius: 8px; text-decoration: none; font-weight: 700; font-size: 15px; text-align: center; flex-grow: 1; min-width: 180px; box-shadow: 0 4px 6px rgba(0,0,0,0.3);" href="https://www.alibaba.com/trade/search?SearchText={termino_google}" target="_blank">🛒 Alibaba</a>
|
| 1011 |
+
</div>
|
| 1012 |
+
</div>
|
| 1013 |
+
""", unsafe_allow_html=True)
|
| 1014 |
+
|
| 1015 |
+
st.write("")
|
| 1016 |
+
if st.button(f"Escaneo profundo - Renglon {fila.get('renglon')}", type="primary"):
|
| 1017 |
+
if not st.session_state.tavily_key: st.error("⚠️ Configura la API Key de Tavily en el panel izquierdo.")
|
| 1018 |
+
else:
|
| 1019 |
+
with st.status(f"Analizando bases de datos B2B para {fila.get('termino_de_busqueda_corto')}...", expanded=True):
|
| 1020 |
+
try:
|
| 1021 |
+
t_client = TavilyClient(api_key=st.session_state.tavily_key)
|
| 1022 |
+
res_tavily = t_client.search(query=f"B2B supplier distributor {fila.get('termino_de_busqueda_corto')} industrial parts", search_depth="advanced", max_results=3)
|
| 1023 |
+
st.toast("✅ Búsqueda completada", icon="🔍")
|
| 1024 |
+
|
| 1025 |
+
st.markdown("#### 🌐 Mejores Proveedores Detectados:")
|
| 1026 |
+
cols = st.columns(3)
|
| 1027 |
+
for i, res in enumerate(res_tavily.get('results', [])):
|
| 1028 |
+
with cols[i % 3]:
|
| 1029 |
+
st.markdown(f"""
|
| 1030 |
+
<div class="provider-card">
|
| 1031 |
+
<h4 style="color:#58A6FF; margin-top:0;">{res.get('title', 'Supplier')}</h4>
|
| 1032 |
+
<p style="font-size:12px; color:#8B949E;">{res.get('content', '')[:120]}...</p>
|
| 1033 |
+
<a style="color:#10B981; font-weight:bold; text-decoration:none;" href="{res.get('url', '#')}" target="_blank">Visitar Website ↗</a>
|
| 1034 |
+
</div>
|
| 1035 |
+
""", unsafe_allow_html=True)
|
| 1036 |
+
except Exception as e: st.error(f"Fallo en scraping: {e}")
|
| 1037 |
+
|
| 1038 |
+
st.divider()
|
| 1039 |
+
st.markdown("#### 📄 Generador de Fichas Técnicas Automáticas")
|
| 1040 |
+
st.caption("Usa la IA para redactar una Ficha Técnica estructurada a partir del contexto del pliego.")
|
| 1041 |
+
if st.button(f"Crear ficha tecnica - Renglon {fila.get('renglon')}", type="secondary"):
|
| 1042 |
+
if not st.session_state.gemini_key: st.error("⚠️ Verifica tus API Keys en la configuración.")
|
| 1043 |
+
else:
|
| 1044 |
+
with st.status("Redactando ficha técnica profesional...", expanded=True):
|
| 1045 |
+
try:
|
| 1046 |
+
payload = {
|
| 1047 |
+
"username": st.session_state.username,
|
| 1048 |
+
"licitacion": str(cg_render.get('numero_licitacion', '')),
|
| 1049 |
+
"codigo_renglon": str(fila.get('codigo_articulo', '')),
|
| 1050 |
+
"pliego_context": json.dumps(cg_render),
|
| 1051 |
+
"items_context": fila.to_json(),
|
| 1052 |
+
"gemini_key": st.session_state.gemini_key
|
| 1053 |
+
}
|
| 1054 |
+
res_ficha = requests.post(f"{API_URL_BASE}/generar-ficha", data=payload, headers=API_HEADERS)
|
| 1055 |
+
if res_ficha.status_code == 200:
|
| 1056 |
+
data_ficha = res_ficha.json()
|
| 1057 |
+
from_cache = data_ficha.get('from_cache', False)
|
| 1058 |
+
label = "✅ Ficha cargada desde caché (sin gasto de tokens)" if from_cache else "✅ ¡Ficha Técnica Generada y guardada!"
|
| 1059 |
+
st.success(label)
|
| 1060 |
+
st.markdown("<div style='background-color:#0D1117; padding:20px; border-radius:10px; border:1px solid #30363D;'>", unsafe_allow_html=True)
|
| 1061 |
+
st.markdown(data_ficha.get("datasheet_md", ""))
|
| 1062 |
+
st.markdown("</div>", unsafe_allow_html=True)
|
| 1063 |
+
else:
|
| 1064 |
+
st.error("Error al generar ficha: " + res_ficha.text)
|
| 1065 |
+
except Exception as e:
|
| 1066 |
+
st.error(f"Fallo en generación: {e}")
|
| 1067 |
+
|
| 1068 |
+
with t2:
|
| 1069 |
+
st.markdown("### ✉️ Motor de Generación de Cotizaciones (RFQs)")
|
| 1070 |
+
plantilla = st.selectbox("Modelo de Documento", ["Internacional (Inglés)", "Local (Español)"])
|
| 1071 |
+
txt_base = "Dear Supplier,\n\nPlease quote for Bid {{NUMERO_LICITACION}}:\n{{LISTA_ITEMS}}\nDelivery: {{LUGAR_ENTREGA}}" if "Inglés" in plantilla else "Estimado Proveedor,\n\nFavor cotizar los siguientes renglones para la licitación {{NUMERO_LICITACION}}:\n{{LISTA_ITEMS}}\nLugar de Entrega: {{LUGAR_ENTREGA}}"
|
| 1072 |
+
|
| 1073 |
+
items_str = "".join([f"- {r.get('codigo_articulo','')} | Qty: {r.get('cantidad','')}\n" for _, r in df_render.iterrows()])
|
| 1074 |
+
cuerpo = txt_base.replace("{{NUMERO_LICITACION}}", str(cg_render.get('numero_licitacion'))).replace("{{LISTA_ITEMS}}", items_str).replace("{{LUGAR_ENTREGA}}", str(cg_render.get('lugar_de_entrega')))
|
| 1075 |
+
|
| 1076 |
+
st.text_area("Borrador Master", value=cuerpo, height=200)
|
| 1077 |
+
msg = EmailMessage()
|
| 1078 |
+
msg.set_content(cuerpo)
|
| 1079 |
+
msg['Subject'], msg['From'] = f"[PROY-ACP-{cg_render.get('numero_licitacion')}] RFQ", st.session_state.email_user
|
| 1080 |
+
|
| 1081 |
+
c1, c2 = st.columns([0.3, 0.7])
|
| 1082 |
+
with c1: st.download_button("📦 DESCARGAR MASTER (.eml)", data=msg.as_bytes(), file_name="Master_RFQ.eml", type="primary", use_container_width=True)
|
| 1083 |
+
|
| 1084 |
+
with t3:
|
| 1085 |
+
st.markdown("### 📥 Centro de Mensajería y Respuestas")
|
| 1086 |
+
st.caption("Centraliza los correos de proveedores entrantes y los clasifica por renglón usando Inteligencia Artificial.")
|
| 1087 |
+
|
| 1088 |
+
col_filt, col_btn = st.columns([0.7, 0.3])
|
| 1089 |
+
|
| 1090 |
+
num_clean = "".join(re.findall(r'\d+', str(cg_render.get('numero_licitacion', ''))))
|
| 1091 |
+
with col_filt:
|
| 1092 |
+
filtro = st.selectbox("🎯 Filtrar Bandeja por Renglón:", ["Ver Todos los Correos"] + [f"Renglón {r.get('renglon')} - {r.get('termino_de_busqueda_corto')}" for _, r in df_render.iterrows()])
|
| 1093 |
+
|
| 1094 |
+
with col_btn:
|
| 1095 |
+
st.write("")
|
| 1096 |
+
if st.button("🔄 Escanear Nuevos Correos", use_container_width=True):
|
| 1097 |
+
if not st.session_state.email_user or not st.session_state.email_pass: st.error("⚠️ Configura el correo corporativo en el panel lateral.")
|
| 1098 |
+
else:
|
| 1099 |
+
with st.status("Leyendo bandeja con Gemini AI...", expanded=True):
|
| 1100 |
+
exito, msj = activar_organizacion_imap()
|
| 1101 |
+
if exito:
|
| 1102 |
+
st.toast("✅ Proceso iniciado en backend.", icon="📥")
|
| 1103 |
+
st.success("Bandeja actualizada")
|
| 1104 |
+
st.rerun()
|
| 1105 |
+
else:
|
| 1106 |
+
st.error(msj)
|
| 1107 |
+
|
| 1108 |
+
st.divider()
|
| 1109 |
+
|
| 1110 |
+
df_correos = obtener_correos_licitacion(num_clean)
|
| 1111 |
+
if df_correos.empty:
|
| 1112 |
+
st.info("📭 Bandeja limpia. Haz clic en 'Escanear Nuevos Correos' para revisar si hay cotizaciones de proveedores.")
|
| 1113 |
+
else:
|
| 1114 |
+
for _, c in df_correos.iterrows():
|
| 1115 |
+
tags = str(c.get('renglones_relacionados', '')).split(',')
|
| 1116 |
+
mostrar = True if filtro == "Ver Todos los Correos" else (filtro.split(" ")[1] in [t.strip() for t in tags])
|
| 1117 |
+
|
| 1118 |
+
if mostrar:
|
| 1119 |
+
tags_html = "".join([f"<span class='tag-badge'>Renglón {t.strip()}</span>" for t in tags if t.strip()])
|
| 1120 |
+
st.markdown(f"""
|
| 1121 |
+
<div class="email-card">
|
| 1122 |
+
<div style="display:flex; justify-content:space-between; margin-bottom:8px; color:#8B949E; font-size:12px;">
|
| 1123 |
+
<strong>{c.get('remitente', 'Desconocido')}</strong><span>{c.get('fecha', '')}</span>
|
| 1124 |
+
</div>
|
| 1125 |
+
<div style="color:#E0E0E0; font-size:15px; font-weight:bold; margin-bottom:10px;">{c.get('asunto', '')}</div>
|
| 1126 |
+
<div style="margin-bottom:10px;">{tags_html}</div>
|
| 1127 |
+
<div style="color:#C9D1D9; font-size:13px; background:#0D1117; padding:10px; border-radius:6px; border:1px solid #30363D;">
|
| 1128 |
+
💡 <b>Resumen IA:</b> {c.get('resumen', '')}
|
| 1129 |
+
</div>
|
| 1130 |
+
</div>
|
| 1131 |
+
""", unsafe_allow_html=True)
|
| 1132 |
+
|
| 1133 |
+
# --- AQUÍ AÑADIMOS EL DESPLEGABLE PARA LEER EL CORREO ORIGINAL ---
|
| 1134 |
+
with st.expander("📄 Leer correo original completo"):
|
| 1135 |
+
st.write(c.get('cuerpo', 'El cuerpo del correo no se pudo cargar.'))
|
| 1136 |
+
|
| 1137 |
+
# --- BORRADOR DE RESPUESTA IA ---
|
| 1138 |
+
if c.get('borrador_respuesta') and c.get('borrador_respuesta').strip():
|
| 1139 |
+
st.markdown("##### 🤖 Borrador de Respuesta Propuesto por IA")
|
| 1140 |
+
st.text_area("Puedes editar y copiar este borrador para enviarlo al proveedor:", value=c.get('borrador_respuesta', ''), height=150, key=f"draft_{c.get('id', c.name)}")
|
| 1141 |
+
|
| 1142 |
+
with t4:
|
| 1143 |
+
st.markdown("### 📈 Visualización de Costos")
|
| 1144 |
+
if 'precio_comp_hist' in df_render.columns and not df_render['precio_comp_hist'].isna().all():
|
| 1145 |
+
fig = go.Figure()
|
| 1146 |
+
fig.add_trace(go.Bar(x=df_render['renglon'], y=df_render['precio_comp_hist'], name='Competencia', marker_color='#30363D'))
|
| 1147 |
+
fig.add_trace(go.Bar(x=df_render['renglon'], y=df_render['precio_proy_hist'], name='Proyelec', marker_color='#58A6FF'))
|
| 1148 |
+
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)')
|
| 1149 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 1150 |
+
else:
|
| 1151 |
+
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.")
|
| 1152 |
+
|
| 1153 |
+
st.markdown("#### Volumen Solicitado por Renglón")
|
| 1154 |
+
fig = go.Figure(data=[go.Bar(x=df_render['renglon'], y=df_render['cantidad'], marker_color='#238636')])
|
| 1155 |
+
fig.update_layout(template="plotly_dark", plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)')
|
| 1156 |
+
st.plotly_chart(fig, use_container_width=True)
|
config.toml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[server]
|
| 2 |
+
port = 7860
|
| 3 |
+
address = "0.0.0.0"
|
| 4 |
+
enableCORS = false
|
| 5 |
+
enableXsrfProtection = false
|
| 6 |
+
|
| 7 |
+
[browser]
|
| 8 |
+
gatherUsageStats = false
|
crypto.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from cryptography.fernet import Fernet
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
load_dotenv()
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
# Inicialización segura de la clave Fernet compartida
|
| 10 |
+
_enc_key_str = os.getenv("ENCRYPTION_KEY", "").strip()
|
| 11 |
+
|
| 12 |
+
if not _enc_key_str:
|
| 13 |
+
_enc_key_str = Fernet.generate_key().decode()
|
| 14 |
+
try:
|
| 15 |
+
with open(".env", "a") as f:
|
| 16 |
+
f.write(f"\nENCRYPTION_KEY={_enc_key_str}\n")
|
| 17 |
+
except Exception as e:
|
| 18 |
+
logger.warning(f"No se pudo escribir en .env: {e}")
|
| 19 |
+
os.environ["ENCRYPTION_KEY"] = _enc_key_str
|
| 20 |
+
logger.warning("ENCRYPTION_KEY no encontrada — se generó una nueva.")
|
| 21 |
+
|
| 22 |
+
# Extraer solo la llave si el usuario pegó "ENCRYPTION_KEY=..." por error
|
| 23 |
+
if _enc_key_str.startswith("ENCRYPTION_KEY="):
|
| 24 |
+
_enc_key_str = _enc_key_str.split("=", 1)[1].strip()
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
ENCRYPTION_KEY = _enc_key_str.encode()
|
| 28 |
+
cipher_suite = Fernet(ENCRYPTION_KEY)
|
| 29 |
+
except Exception as e:
|
| 30 |
+
logger.error(f"Error inicializando Fernet con la clave proporcionada: {e}")
|
| 31 |
+
# Fallback seguro en caso de clave inválida (evita que la app crashee, pero las contraseñas fallarán)
|
| 32 |
+
cipher_suite = Fernet(Fernet.generate_key())
|
| 33 |
+
|
| 34 |
+
def encrypt_data(text: str) -> str:
|
| 35 |
+
"""Cifra un texto plano utilizando la clave maestra."""
|
| 36 |
+
if not text:
|
| 37 |
+
return ""
|
| 38 |
+
try:
|
| 39 |
+
return cipher_suite.encrypt(text.encode()).decode()
|
| 40 |
+
except Exception as e:
|
| 41 |
+
logger.error(f"Error al cifrar datos: {e}")
|
| 42 |
+
return ""
|
| 43 |
+
|
| 44 |
+
def decrypt_data(text: str) -> str:
|
| 45 |
+
"""Descifra un texto cifrado utilizando la clave maestra."""
|
| 46 |
+
if not text:
|
| 47 |
+
return ""
|
| 48 |
+
try:
|
| 49 |
+
return cipher_suite.decrypt(text.encode()).decode()
|
| 50 |
+
except Exception as e:
|
| 51 |
+
logger.error(f"Error al descifrar datos: {e}")
|
| 52 |
+
return ""
|
database.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sqlite3
|
| 2 |
+
import pandas as pd
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
import os
|
| 5 |
+
import bcrypt
|
| 6 |
+
|
| 7 |
+
DB_PATH = os.getenv("DB_PATH", "proyelec_crm.db")
|
| 8 |
+
|
| 9 |
+
def get_connection(db_path=DB_PATH):
|
| 10 |
+
conn = sqlite3.connect(db_path, check_same_thread=False)
|
| 11 |
+
if db_path != ":memory:":
|
| 12 |
+
conn.execute('PRAGMA journal_mode=WAL;')
|
| 13 |
+
return conn
|
| 14 |
+
|
| 15 |
+
def init_db():
|
| 16 |
+
conn = get_connection()
|
| 17 |
+
c = conn.cursor()
|
| 18 |
+
|
| 19 |
+
# Usuarios
|
| 20 |
+
c.execute('''CREATE TABLE IF NOT EXISTS users (
|
| 21 |
+
username TEXT PRIMARY KEY,
|
| 22 |
+
password TEXT,
|
| 23 |
+
role TEXT,
|
| 24 |
+
gemini_key TEXT,
|
| 25 |
+
tavily_key TEXT,
|
| 26 |
+
email_user TEXT,
|
| 27 |
+
email_pass_enc TEXT
|
| 28 |
+
)''')
|
| 29 |
+
|
| 30 |
+
# Historial de análisis
|
| 31 |
+
c.execute('''CREATE TABLE IF NOT EXISTS history (
|
| 32 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 33 |
+
username TEXT,
|
| 34 |
+
licitacion TEXT,
|
| 35 |
+
fecha TEXT,
|
| 36 |
+
items INTEGER
|
| 37 |
+
)''')
|
| 38 |
+
|
| 39 |
+
# Bandeja inteligente de correos
|
| 40 |
+
c.execute('''CREATE TABLE IF NOT EXISTS smart_inbox (
|
| 41 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 42 |
+
licitacion TEXT,
|
| 43 |
+
remitente TEXT,
|
| 44 |
+
asunto TEXT,
|
| 45 |
+
fecha TEXT,
|
| 46 |
+
resumen TEXT,
|
| 47 |
+
renglones_relacionados TEXT,
|
| 48 |
+
cuerpo TEXT,
|
| 49 |
+
borrador_respuesta TEXT
|
| 50 |
+
)''')
|
| 51 |
+
|
| 52 |
+
# Cache de fichas técnicas (ahorra tokens Gemini)
|
| 53 |
+
c.execute('''CREATE TABLE IF NOT EXISTS fichas_cache (
|
| 54 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 55 |
+
username TEXT,
|
| 56 |
+
licitacion TEXT,
|
| 57 |
+
codigo_renglon TEXT,
|
| 58 |
+
datasheet_md TEXT,
|
| 59 |
+
fecha TEXT,
|
| 60 |
+
UNIQUE(username, licitacion, codigo_renglon)
|
| 61 |
+
)''')
|
| 62 |
+
|
| 63 |
+
# Tabla de cotizaciones extraídas de correos (para uso futuro)
|
| 64 |
+
c.execute('''CREATE TABLE IF NOT EXISTS cotizaciones (
|
| 65 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 66 |
+
licitacion TEXT,
|
| 67 |
+
renglon TEXT,
|
| 68 |
+
proveedor TEXT,
|
| 69 |
+
precio_unitario REAL,
|
| 70 |
+
moneda TEXT,
|
| 71 |
+
tiempo_entrega TEXT,
|
| 72 |
+
condiciones TEXT,
|
| 73 |
+
fecha TEXT,
|
| 74 |
+
email_asunto TEXT
|
| 75 |
+
)''')
|
| 76 |
+
|
| 77 |
+
# === MÚLTIPLES WORKSPACES POR USUARIO ===
|
| 78 |
+
c.execute('''CREATE TABLE IF NOT EXISTS workspaces (
|
| 79 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 80 |
+
username TEXT NOT NULL,
|
| 81 |
+
licitacion TEXT NOT NULL,
|
| 82 |
+
data_json TEXT,
|
| 83 |
+
cg_json TEXT,
|
| 84 |
+
fecha_guardado TEXT,
|
| 85 |
+
UNIQUE(username, licitacion)
|
| 86 |
+
)''')
|
| 87 |
+
|
| 88 |
+
# app_state legacy (se mantiene para compatibilidad)
|
| 89 |
+
c.execute('''CREATE TABLE IF NOT EXISTS app_state (
|
| 90 |
+
username TEXT PRIMARY KEY,
|
| 91 |
+
last_licitacion TEXT,
|
| 92 |
+
last_data TEXT,
|
| 93 |
+
last_cg TEXT
|
| 94 |
+
)''')
|
| 95 |
+
|
| 96 |
+
# === MONITOR DE LICITACIONES ACP ===
|
| 97 |
+
c.execute('''CREATE TABLE IF NOT EXISTS seguimiento_licitaciones (
|
| 98 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 99 |
+
numero_licitacion TEXT UNIQUE NOT NULL,
|
| 100 |
+
objeto TEXT,
|
| 101 |
+
fecha_asignacion TEXT,
|
| 102 |
+
fecha_envio_oferta TEXT,
|
| 103 |
+
monto_ofertado REAL,
|
| 104 |
+
moneda TEXT DEFAULT "USD",
|
| 105 |
+
estado TEXT DEFAULT "En Preparacion",
|
| 106 |
+
link_sli TEXT,
|
| 107 |
+
notas TEXT,
|
| 108 |
+
responsable TEXT,
|
| 109 |
+
fecha_registro TEXT
|
| 110 |
+
)''')
|
| 111 |
+
c.execute('''CREATE TABLE IF NOT EXISTS seguimiento_historial (
|
| 112 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 113 |
+
licitacion_id INTEGER,
|
| 114 |
+
fecha TEXT,
|
| 115 |
+
estado_nuevo TEXT,
|
| 116 |
+
nota TEXT,
|
| 117 |
+
registrado_por TEXT,
|
| 118 |
+
FOREIGN KEY(licitacion_id) REFERENCES seguimiento_licitaciones(id)
|
| 119 |
+
)''')
|
| 120 |
+
|
| 121 |
+
# --- Migrar workspace legacy a nueva tabla si existe ---
|
| 122 |
+
c.execute("SELECT username, last_licitacion, last_data, last_cg FROM app_state")
|
| 123 |
+
legacy_rows = c.fetchall()
|
| 124 |
+
for row in legacy_rows:
|
| 125 |
+
uname, lic, data, cg = row
|
| 126 |
+
if lic and data and cg:
|
| 127 |
+
c.execute("""INSERT OR IGNORE INTO workspaces (username, licitacion, data_json, cg_json, fecha_guardado)
|
| 128 |
+
VALUES (?, ?, ?, ?, ?)""",
|
| 129 |
+
(uname, lic, data, cg, datetime.now().strftime("%Y-%m-%d %H:%M:%S")))
|
| 130 |
+
|
| 131 |
+
# Usuario admin por defecto (contraseña: admin)
|
| 132 |
+
c.execute("SELECT * FROM users WHERE username='admin'")
|
| 133 |
+
if not c.fetchone():
|
| 134 |
+
salt = bcrypt.gensalt()
|
| 135 |
+
hashed_pw = bcrypt.hashpw(b"admin", salt).decode('utf-8')
|
| 136 |
+
c.execute("INSERT INTO users VALUES ('admin', ?, 'Gerencia', '', '', '', '')", (hashed_pw,))
|
| 137 |
+
|
| 138 |
+
conn.commit()
|
| 139 |
+
conn.close()
|
| 140 |
+
|
| 141 |
+
# =============================================
|
| 142 |
+
# WORKSPACES (MÚLTIPLES POR USUARIO)
|
| 143 |
+
# =============================================
|
| 144 |
+
|
| 145 |
+
def save_workspace(username, licitacion, data_json, cg_json):
|
| 146 |
+
"""Guarda o actualiza el workspace de una licitación específica."""
|
| 147 |
+
conn = get_connection()
|
| 148 |
+
c = conn.cursor()
|
| 149 |
+
fecha = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 150 |
+
c.execute("""INSERT OR REPLACE INTO workspaces (username, licitacion, data_json, cg_json, fecha_guardado)
|
| 151 |
+
VALUES (?, ?, ?, ?, ?)""",
|
| 152 |
+
(username, licitacion, data_json, cg_json, fecha))
|
| 153 |
+
conn.commit()
|
| 154 |
+
conn.close()
|
| 155 |
+
|
| 156 |
+
def get_all_workspaces(username, all_users=False):
|
| 157 |
+
"""Retorna todos los workspaces. Si all_users=True, retorna de todos los usuarios (para Gerencia)."""
|
| 158 |
+
conn = get_connection()
|
| 159 |
+
if all_users:
|
| 160 |
+
df = pd.read_sql_query(
|
| 161 |
+
"""SELECT username, licitacion, fecha_guardado FROM workspaces
|
| 162 |
+
ORDER BY fecha_guardado DESC""", conn)
|
| 163 |
+
else:
|
| 164 |
+
df = pd.read_sql_query(
|
| 165 |
+
"""SELECT username, licitacion, fecha_guardado FROM workspaces
|
| 166 |
+
WHERE username=? ORDER BY fecha_guardado DESC""",
|
| 167 |
+
conn, params=(username,))
|
| 168 |
+
conn.close()
|
| 169 |
+
return df
|
| 170 |
+
|
| 171 |
+
def load_workspace(username, licitacion):
|
| 172 |
+
"""Carga un workspace específico por licitación."""
|
| 173 |
+
conn = get_connection()
|
| 174 |
+
c = conn.cursor()
|
| 175 |
+
c.execute("SELECT data_json, cg_json FROM workspaces WHERE username=? AND licitacion=?",
|
| 176 |
+
(username, licitacion))
|
| 177 |
+
row = c.fetchone()
|
| 178 |
+
conn.close()
|
| 179 |
+
return row # (data_json, cg_json) o None
|
| 180 |
+
|
| 181 |
+
def delete_workspace(username, licitacion):
|
| 182 |
+
"""Elimina un workspace guardado."""
|
| 183 |
+
conn = get_connection()
|
| 184 |
+
c = conn.cursor()
|
| 185 |
+
c.execute("DELETE FROM workspaces WHERE username=? AND licitacion=?", (username, licitacion))
|
| 186 |
+
conn.commit()
|
| 187 |
+
conn.close()
|
| 188 |
+
|
| 189 |
+
# Legacy — mantener compatibilidad con código existente
|
| 190 |
+
def save_workspace_state(username, licitacion, data_json, cg_json):
|
| 191 |
+
save_workspace(username, licitacion, data_json, cg_json)
|
| 192 |
+
|
| 193 |
+
def load_workspace_state(username):
|
| 194 |
+
"""Carga el workspace más reciente del usuario (compatibilidad legacy)."""
|
| 195 |
+
conn = get_connection()
|
| 196 |
+
c = conn.cursor()
|
| 197 |
+
c.execute("SELECT data_json, cg_json FROM workspaces WHERE username=? ORDER BY fecha_guardado DESC LIMIT 1",
|
| 198 |
+
(username,))
|
| 199 |
+
row = c.fetchone()
|
| 200 |
+
conn.close()
|
| 201 |
+
return row
|
| 202 |
+
|
| 203 |
+
# =============================================
|
| 204 |
+
# GESTIÓN DE USUARIOS (ADMIN)
|
| 205 |
+
# =============================================
|
| 206 |
+
|
| 207 |
+
def get_all_users():
|
| 208 |
+
"""Retorna todos los usuarios del sistema para el panel de administración."""
|
| 209 |
+
conn = get_connection()
|
| 210 |
+
df = pd.read_sql_query(
|
| 211 |
+
"SELECT username as Usuario, role as Nivel, email_user as Correo, gemini_key as Clave_Gemini, tavily_key as Clave_Tavily FROM users ORDER BY role, Usuario", conn)
|
| 212 |
+
conn.close()
|
| 213 |
+
|
| 214 |
+
# Enmascarar las llaves visualmente para seguridad (opcional, pero recomendado)
|
| 215 |
+
df['Clave_Gemini'] = df['Clave_Gemini'].apply(lambda x: f"{x[:12]}...{x[-4:]}" if x and len(x) > 15 else ("Sin configurar" if not x else x))
|
| 216 |
+
df['Clave_Tavily'] = df['Clave_Tavily'].apply(lambda x: f"{x[:12]}...{x[-4:]}" if x and len(x) > 15 else ("Sin configurar" if not x else x))
|
| 217 |
+
df['Correo'] = df['Correo'].apply(lambda x: x if x else "Sin configurar")
|
| 218 |
+
|
| 219 |
+
return df
|
| 220 |
+
|
| 221 |
+
def create_user(username, password_plain, role):
|
| 222 |
+
"""Crea un nuevo usuario. Retorna True si exitoso, False si el username ya existe."""
|
| 223 |
+
conn = get_connection()
|
| 224 |
+
c = conn.cursor()
|
| 225 |
+
salt = bcrypt.gensalt()
|
| 226 |
+
hashed = bcrypt.hashpw(password_plain.encode(), salt).decode('utf-8')
|
| 227 |
+
try:
|
| 228 |
+
c.execute("INSERT INTO users (username, password, role, gemini_key, tavily_key, email_user, email_pass_enc) VALUES (?, ?, ?, '', '', '', '')",
|
| 229 |
+
(username, hashed, role))
|
| 230 |
+
conn.commit()
|
| 231 |
+
conn.close()
|
| 232 |
+
return True
|
| 233 |
+
except sqlite3.IntegrityError:
|
| 234 |
+
conn.close()
|
| 235 |
+
return False
|
| 236 |
+
|
| 237 |
+
def delete_user(username):
|
| 238 |
+
"""Elimina un usuario. No permite eliminar al admin principal."""
|
| 239 |
+
if username == 'admin':
|
| 240 |
+
return False
|
| 241 |
+
conn = get_connection()
|
| 242 |
+
c = conn.cursor()
|
| 243 |
+
c.execute("DELETE FROM users WHERE username=?", (username,))
|
| 244 |
+
conn.commit()
|
| 245 |
+
conn.close()
|
| 246 |
+
return True
|
| 247 |
+
|
| 248 |
+
def update_user_role(username, new_role):
|
| 249 |
+
"""Cambia el rol de un usuario."""
|
| 250 |
+
conn = get_connection()
|
| 251 |
+
c = conn.cursor()
|
| 252 |
+
c.execute("UPDATE users SET role=? WHERE username=?", (new_role, username))
|
| 253 |
+
conn.commit()
|
| 254 |
+
conn.close()
|
| 255 |
+
|
| 256 |
+
def reset_user_password(username, new_password_plain):
|
| 257 |
+
"""Resetea la contraseña de un usuario."""
|
| 258 |
+
conn = get_connection()
|
| 259 |
+
c = conn.cursor()
|
| 260 |
+
salt = bcrypt.gensalt()
|
| 261 |
+
hashed = bcrypt.hashpw(new_password_plain.encode(), salt).decode('utf-8')
|
| 262 |
+
c.execute("UPDATE users SET password=? WHERE username=?", (hashed, username))
|
| 263 |
+
conn.commit()
|
| 264 |
+
conn.close()
|
| 265 |
+
|
| 266 |
+
# =============================================
|
| 267 |
+
# FUNCIONES EXISTENTES (sin cambios)
|
| 268 |
+
# =============================================
|
| 269 |
+
|
| 270 |
+
def get_user(username, password_plain):
|
| 271 |
+
conn = get_connection()
|
| 272 |
+
c = conn.cursor()
|
| 273 |
+
c.execute("SELECT * FROM users WHERE LOWER(username)=LOWER(?)", (username,))
|
| 274 |
+
user = c.fetchone()
|
| 275 |
+
conn.close()
|
| 276 |
+
if user:
|
| 277 |
+
stored_hash = user[1]
|
| 278 |
+
try:
|
| 279 |
+
if bcrypt.checkpw(password_plain.encode(), stored_hash.encode('utf-8')):
|
| 280 |
+
return user
|
| 281 |
+
except ValueError:
|
| 282 |
+
# En caso de hashes antiguos SHA-256 sin sal
|
| 283 |
+
pass
|
| 284 |
+
return None
|
| 285 |
+
|
| 286 |
+
def update_user_profile(username, gemini, tavily, email, enc_pass):
|
| 287 |
+
conn = get_connection()
|
| 288 |
+
c = conn.cursor()
|
| 289 |
+
c.execute("UPDATE users SET gemini_key=?, tavily_key=?, email_user=?, email_pass_enc=? WHERE username=?",
|
| 290 |
+
(gemini, tavily, email, enc_pass, username))
|
| 291 |
+
conn.commit()
|
| 292 |
+
conn.close()
|
| 293 |
+
|
| 294 |
+
def save_history(username, licitacion, items_count):
|
| 295 |
+
conn = get_connection()
|
| 296 |
+
c = conn.cursor()
|
| 297 |
+
fecha_actual = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 298 |
+
c.execute("INSERT INTO history (username, licitacion, fecha, items) VALUES (?, ?, ?, ?)",
|
| 299 |
+
(username, licitacion, fecha_actual, items_count))
|
| 300 |
+
conn.commit()
|
| 301 |
+
conn.close()
|
| 302 |
+
|
| 303 |
+
def get_user_history_df(username):
|
| 304 |
+
conn = get_connection()
|
| 305 |
+
df = pd.read_sql_query(
|
| 306 |
+
"SELECT licitacion as 'Nº Licitación', fecha as 'Fecha Proceso', items as 'Renglones' FROM history WHERE username=? ORDER BY id DESC",
|
| 307 |
+
conn, params=(username,))
|
| 308 |
+
conn.close()
|
| 309 |
+
return df
|
| 310 |
+
|
| 311 |
+
def delete_history_entry(username, licitacion):
|
| 312 |
+
conn = get_connection()
|
| 313 |
+
c = conn.cursor()
|
| 314 |
+
c.execute("DELETE FROM history WHERE username=? AND licitacion=?", (username, licitacion))
|
| 315 |
+
conn.commit()
|
| 316 |
+
conn.close()
|
| 317 |
+
|
| 318 |
+
def clear_all_history(username):
|
| 319 |
+
conn = get_connection()
|
| 320 |
+
c = conn.cursor()
|
| 321 |
+
c.execute("DELETE FROM history WHERE username=?", (username,))
|
| 322 |
+
conn.commit()
|
| 323 |
+
conn.close()
|
| 324 |
+
|
| 325 |
+
def get_correos_licitacion_df(licitacion):
|
| 326 |
+
conn = get_connection()
|
| 327 |
+
try:
|
| 328 |
+
df = pd.read_sql_query(
|
| 329 |
+
"SELECT id, remitente, asunto, fecha, resumen, renglones_relacionados, cuerpo, borrador_respuesta FROM smart_inbox WHERE licitacion=? ORDER BY id DESC",
|
| 330 |
+
conn, params=(licitacion,))
|
| 331 |
+
except Exception:
|
| 332 |
+
df = pd.DataFrame()
|
| 333 |
+
conn.close()
|
| 334 |
+
return df
|
| 335 |
+
|
| 336 |
+
def get_user_credentials(username):
|
| 337 |
+
conn = get_connection()
|
| 338 |
+
c = conn.cursor()
|
| 339 |
+
c.execute("SELECT email_user, email_pass_enc, gemini_key FROM users WHERE username=?", (username,))
|
| 340 |
+
row = c.fetchone()
|
| 341 |
+
conn.close()
|
| 342 |
+
return row
|
| 343 |
+
|
| 344 |
+
def check_email_exists(licitacion, asunto, remitente):
|
| 345 |
+
conn = get_connection()
|
| 346 |
+
c = conn.cursor()
|
| 347 |
+
c.execute("SELECT id FROM smart_inbox WHERE licitacion=? AND asunto=? AND remitente=?",
|
| 348 |
+
(licitacion, asunto, remitente))
|
| 349 |
+
exists = c.fetchone() is not None
|
| 350 |
+
conn.close()
|
| 351 |
+
return exists
|
| 352 |
+
|
| 353 |
+
def insert_smart_inbox(licitacion, remitente, asunto, fecha, resumen, renglones, cuerpo, borrador=""):
|
| 354 |
+
conn = get_connection()
|
| 355 |
+
c = conn.cursor()
|
| 356 |
+
c.execute(
|
| 357 |
+
"INSERT INTO smart_inbox (licitacion, remitente, asunto, fecha, resumen, renglones_relacionados, cuerpo, borrador_respuesta) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
| 358 |
+
(licitacion, remitente, asunto, fecha, resumen, renglones, cuerpo, borrador))
|
| 359 |
+
conn.commit()
|
| 360 |
+
conn.close()
|
| 361 |
+
|
| 362 |
+
def insert_cotizacion(licitacion, renglon, proveedor, precio_unitario, moneda, tiempo_entrega, condiciones, fecha, email_asunto):
|
| 363 |
+
conn = get_connection()
|
| 364 |
+
c = conn.cursor()
|
| 365 |
+
c.execute(
|
| 366 |
+
"INSERT INTO cotizaciones (licitacion, renglon, proveedor, precio_unitario, moneda, tiempo_entrega, condiciones, fecha, email_asunto) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
| 367 |
+
(licitacion, renglon, proveedor, precio_unitario, moneda, tiempo_entrega, condiciones, fecha, email_asunto))
|
| 368 |
+
conn.commit()
|
| 369 |
+
conn.close()
|
| 370 |
+
|
| 371 |
+
def check_cotizacion_exists(licitacion, renglon, proveedor):
|
| 372 |
+
conn = get_connection()
|
| 373 |
+
c = conn.cursor()
|
| 374 |
+
c.execute("SELECT id FROM cotizaciones WHERE licitacion=? AND renglon=? AND proveedor=?",
|
| 375 |
+
(licitacion, renglon, proveedor))
|
| 376 |
+
exists = c.fetchone() is not None
|
| 377 |
+
conn.close()
|
| 378 |
+
return exists
|
| 379 |
+
|
| 380 |
+
def get_ficha_cache(username, licitacion, codigo_renglon):
|
| 381 |
+
conn = get_connection()
|
| 382 |
+
c = conn.cursor()
|
| 383 |
+
c.execute("SELECT datasheet_md FROM fichas_cache WHERE username=? AND licitacion=? AND codigo_renglon=?",
|
| 384 |
+
(username, licitacion, codigo_renglon))
|
| 385 |
+
row = c.fetchone()
|
| 386 |
+
conn.close()
|
| 387 |
+
return row[0] if row else None
|
| 388 |
+
|
| 389 |
+
def save_ficha_cache(username, licitacion, codigo_renglon, datasheet_md):
|
| 390 |
+
conn = get_connection()
|
| 391 |
+
c = conn.cursor()
|
| 392 |
+
fecha = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 393 |
+
c.execute(
|
| 394 |
+
"INSERT OR REPLACE INTO fichas_cache (username, licitacion, codigo_renglon, datasheet_md, fecha) VALUES (?, ?, ?, ?, ?)",
|
| 395 |
+
(username, licitacion, codigo_renglon, datasheet_md, fecha))
|
| 396 |
+
conn.commit()
|
| 397 |
+
conn.close()
|
| 398 |
+
|
| 399 |
+
# =============================================
|
| 400 |
+
# MONITOR DE LICITACIONES ACP
|
| 401 |
+
# =============================================
|
| 402 |
+
|
| 403 |
+
ESTADOS_ACP = [
|
| 404 |
+
"En Preparacion",
|
| 405 |
+
"Oferta Enviada al SLI",
|
| 406 |
+
"Cumple Tecnicamente",
|
| 407 |
+
"No Cumple Tecnicamente",
|
| 408 |
+
"En Evaluacion Economica",
|
| 409 |
+
"Adjudicada",
|
| 410 |
+
"No Adjudicada",
|
| 411 |
+
"Desierta",
|
| 412 |
+
]
|
| 413 |
+
|
| 414 |
+
def crear_seguimiento(numero_licitacion, objeto, fecha_asignacion, fecha_envio_oferta,
|
| 415 |
+
monto_ofertado, moneda, link_sli, notas, responsable):
|
| 416 |
+
conn = get_connection()
|
| 417 |
+
c = conn.cursor()
|
| 418 |
+
fecha = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 419 |
+
try:
|
| 420 |
+
c.execute("""INSERT INTO seguimiento_licitaciones
|
| 421 |
+
(numero_licitacion, objeto, fecha_asignacion, fecha_envio_oferta,
|
| 422 |
+
monto_ofertado, moneda, estado, link_sli, notas, responsable, fecha_registro)
|
| 423 |
+
VALUES (?, ?, ?, ?, ?, ?, 'En Preparacion', ?, ?, ?, ?)""",
|
| 424 |
+
(numero_licitacion, objeto, fecha_asignacion, fecha_envio_oferta,
|
| 425 |
+
monto_ofertado, moneda, link_sli, notas, responsable, fecha))
|
| 426 |
+
conn.commit()
|
| 427 |
+
conn.close()
|
| 428 |
+
return True
|
| 429 |
+
except Exception:
|
| 430 |
+
conn.close()
|
| 431 |
+
return False
|
| 432 |
+
|
| 433 |
+
def get_seguimientos():
|
| 434 |
+
conn = get_connection()
|
| 435 |
+
df = pd.read_sql_query(
|
| 436 |
+
"SELECT * FROM seguimiento_licitaciones ORDER BY fecha_registro DESC", conn)
|
| 437 |
+
conn.close()
|
| 438 |
+
return df
|
| 439 |
+
|
| 440 |
+
def actualizar_estado(licitacion_id, nuevo_estado, nota, registrado_por):
|
| 441 |
+
conn = get_connection()
|
| 442 |
+
c = conn.cursor()
|
| 443 |
+
fecha = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 444 |
+
c.execute("UPDATE seguimiento_licitaciones SET estado=? WHERE id=?", (nuevo_estado, licitacion_id))
|
| 445 |
+
c.execute("""INSERT INTO seguimiento_historial (licitacion_id, fecha, estado_nuevo, nota, registrado_por)
|
| 446 |
+
VALUES (?, ?, ?, ?, ?)""", (licitacion_id, fecha, nuevo_estado, nota, registrado_por))
|
| 447 |
+
conn.commit()
|
| 448 |
+
conn.close()
|
| 449 |
+
|
| 450 |
+
def get_historial_seguimiento(licitacion_id):
|
| 451 |
+
conn = get_connection()
|
| 452 |
+
df = pd.read_sql_query(
|
| 453 |
+
"SELECT fecha, estado_nuevo, nota, registrado_por FROM seguimiento_historial WHERE licitacion_id=? ORDER BY id DESC",
|
| 454 |
+
conn, params=(licitacion_id,))
|
| 455 |
+
conn.close()
|
| 456 |
+
return df
|
| 457 |
+
|
| 458 |
+
def eliminar_seguimiento(licitacion_id):
|
| 459 |
+
conn = get_connection()
|
| 460 |
+
c = conn.cursor()
|
| 461 |
+
c.execute("DELETE FROM seguimiento_historial WHERE licitacion_id=?", (licitacion_id,))
|
| 462 |
+
c.execute("DELETE FROM seguimiento_licitaciones WHERE id=?", (licitacion_id,))
|
| 463 |
+
conn.commit()
|
| 464 |
+
conn.close()
|
| 465 |
+
|
entrypoint.sh
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
|
| 3 |
+
# Iniciar la API en segundo plano
|
| 4 |
+
echo "Iniciando Motor IA (FastAPI)..."
|
| 5 |
+
uvicorn api:app --host 0.0.0.0 --port 8000 &
|
| 6 |
+
|
| 7 |
+
# Esperar unos segundos para que la API levante bien
|
| 8 |
+
sleep 3
|
| 9 |
+
|
| 10 |
+
# Iniciar Streamlit en el puerto principal
|
| 11 |
+
echo "Iniciando Centro de Mando (Streamlit)..."
|
| 12 |
+
streamlit run app.py --server.port 7860 --server.address 0.0.0.0
|
proyelec_logo.png
ADDED
|
requirements.txt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit
|
| 2 |
+
pandas
|
| 3 |
+
plotly
|
| 4 |
+
tavily-python
|
| 5 |
+
google-generativeai
|
| 6 |
+
cryptography
|
| 7 |
+
requests
|
| 8 |
+
fastapi
|
| 9 |
+
uvicorn
|
| 10 |
+
python-multipart
|
| 11 |
+
python-dotenv
|
| 12 |
+
openpyxl
|
| 13 |
+
playwright
|
| 14 |
+
beautifulsoup4
|
| 15 |
+
pypdf
|
| 16 |
+
bcrypt
|
style.css
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
| 2 |
+
|
| 3 |
+
/* ===== BASE ===== */
|
| 4 |
+
html, body, [data-testid="stAppViewContainer"] {
|
| 5 |
+
background-color: #0A0E1A;
|
| 6 |
+
color: #E0E6F0;
|
| 7 |
+
font-family: 'Inter', sans-serif;
|
| 8 |
+
}
|
| 9 |
+
[data-testid="stSidebar"] {
|
| 10 |
+
background: linear-gradient(180deg, #0D1117 0%, #0A0E1A 100%);
|
| 11 |
+
border-right: 1px solid #1E2A3A;
|
| 12 |
+
}
|
| 13 |
+
[data-testid="stHeader"] { background-color: transparent !important; }
|
| 14 |
+
[data-testid="stAppViewBlockContainer"] { padding-top: 10px; }
|
| 15 |
+
|
| 16 |
+
/* ===== NAVBAR ===== */
|
| 17 |
+
.top-navbar {
|
| 18 |
+
position: fixed;
|
| 19 |
+
top: 0; left: 0;
|
| 20 |
+
width: 100%;
|
| 21 |
+
background: rgba(13, 17, 23, 0.85);
|
| 22 |
+
backdrop-filter: blur(12px);
|
| 23 |
+
-webkit-backdrop-filter: blur(12px);
|
| 24 |
+
padding: 14px 60px;
|
| 25 |
+
border-bottom: 1px solid rgba(88, 166, 255, 0.3);
|
| 26 |
+
display: flex;
|
| 27 |
+
justify-content: space-between;
|
| 28 |
+
align-items: center;
|
| 29 |
+
z-index: 9999;
|
| 30 |
+
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5);
|
| 31 |
+
}
|
| 32 |
+
.main-content-spacer { margin-top: 65px; }
|
| 33 |
+
|
| 34 |
+
/* ===== MÉTRICAS ===== */
|
| 35 |
+
div[data-testid="metric-container"] {
|
| 36 |
+
background: linear-gradient(135deg, #161B22 0%, #0D1117 100%);
|
| 37 |
+
border: 1px solid #1E2A3A;
|
| 38 |
+
padding: 18px;
|
| 39 |
+
border-radius: 14px;
|
| 40 |
+
transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
|
| 41 |
+
}
|
| 42 |
+
div[data-testid="metric-container"]:hover {
|
| 43 |
+
transform: translateY(-4px);
|
| 44 |
+
border-color: #58A6FF;
|
| 45 |
+
box-shadow: 0 8px 25px rgba(88, 166, 255, 0.15);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/* ===== CARDS ===== */
|
| 49 |
+
.detail-card {
|
| 50 |
+
background: linear-gradient(135deg, #161B22 0%, #0D1117 100%);
|
| 51 |
+
padding: 25px;
|
| 52 |
+
border-radius: 16px;
|
| 53 |
+
border-top: 3px solid #58A6FF;
|
| 54 |
+
border-left: 1px solid #1E2A3A;
|
| 55 |
+
border-right: 1px solid #1E2A3A;
|
| 56 |
+
border-bottom: 1px solid #1E2A3A;
|
| 57 |
+
margin-top: 20px;
|
| 58 |
+
animation: fadeInUp 0.3s ease;
|
| 59 |
+
}
|
| 60 |
+
.provider-card {
|
| 61 |
+
border: 1px solid #1E2A3A;
|
| 62 |
+
border-radius: 14px;
|
| 63 |
+
padding: 20px;
|
| 64 |
+
background: linear-gradient(135deg, #0D1117 0%, #0A0E1A 100%);
|
| 65 |
+
border-left: 3px solid #58A6FF;
|
| 66 |
+
height: 100%;
|
| 67 |
+
transition: all 0.25s ease;
|
| 68 |
+
margin-top: 15px;
|
| 69 |
+
}
|
| 70 |
+
.provider-card:hover {
|
| 71 |
+
border-left-color: #10B981;
|
| 72 |
+
transform: translateY(-4px);
|
| 73 |
+
box-shadow: 0 10px 30px rgba(16, 185, 129, 0.15);
|
| 74 |
+
}
|
| 75 |
+
.email-card {
|
| 76 |
+
background: linear-gradient(135deg, #161B22 0%, #0D1117 100%);
|
| 77 |
+
border-left: 3px solid #10B981;
|
| 78 |
+
padding: 18px;
|
| 79 |
+
border-radius: 12px;
|
| 80 |
+
margin-bottom: 12px;
|
| 81 |
+
border-top: 1px solid #1E2A3A;
|
| 82 |
+
border-right: 1px solid #1E2A3A;
|
| 83 |
+
border-bottom: 1px solid #1E2A3A;
|
| 84 |
+
transition: all 0.2s ease;
|
| 85 |
+
animation: fadeInUp 0.3s ease;
|
| 86 |
+
}
|
| 87 |
+
.email-card:hover {
|
| 88 |
+
border-left-color: #58A6FF;
|
| 89 |
+
box-shadow: 0 4px 20px rgba(88, 166, 255, 0.1);
|
| 90 |
+
}
|
| 91 |
+
.step-card {
|
| 92 |
+
background: linear-gradient(135deg, #161B22 0%, #0D1117 100%);
|
| 93 |
+
border: 1px solid #1E2A3A;
|
| 94 |
+
padding: 30px 20px;
|
| 95 |
+
border-radius: 18px;
|
| 96 |
+
text-align: center;
|
| 97 |
+
height: 100%;
|
| 98 |
+
transition: all 0.3s ease;
|
| 99 |
+
animation: fadeInUp 0.4s ease;
|
| 100 |
+
}
|
| 101 |
+
.step-card:hover {
|
| 102 |
+
border-color: #58A6FF;
|
| 103 |
+
transform: translateY(-5px);
|
| 104 |
+
box-shadow: 0 15px 40px rgba(88, 166, 255, 0.12);
|
| 105 |
+
}
|
| 106 |
+
.step-icon { font-size: 44px; margin-bottom: 15px; display: block; }
|
| 107 |
+
|
| 108 |
+
/* ===== BADGES ===== */
|
| 109 |
+
.tag-badge {
|
| 110 |
+
background: linear-gradient(90deg, #1a3a1a, #238636);
|
| 111 |
+
color: #3fb950;
|
| 112 |
+
border: 1px solid #238636;
|
| 113 |
+
padding: 3px 10px;
|
| 114 |
+
border-radius: 20px;
|
| 115 |
+
font-size: 11px;
|
| 116 |
+
font-weight: 600;
|
| 117 |
+
letter-spacing: 0.5px;
|
| 118 |
+
margin-right: 4px;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
/* ===== BOTONES LEGACY ===== */
|
| 122 |
+
.action-btn {
|
| 123 |
+
display: inline-block;
|
| 124 |
+
margin-top: 10px; margin-right: 10px;
|
| 125 |
+
background: linear-gradient(90deg, #238636, #2ea043);
|
| 126 |
+
color: white !important;
|
| 127 |
+
padding: 10px 18px;
|
| 128 |
+
border-radius: 8px;
|
| 129 |
+
text-decoration: none;
|
| 130 |
+
font-weight: 700;
|
| 131 |
+
font-size: 13px;
|
| 132 |
+
transition: all 0.25s ease;
|
| 133 |
+
box-shadow: 0 2px 8px rgba(35, 134, 54, 0.3);
|
| 134 |
+
}
|
| 135 |
+
.action-btn:hover {
|
| 136 |
+
transform: translateY(-2px);
|
| 137 |
+
box-shadow: 0 8px 20px rgba(35, 134, 54, 0.4);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
/* ===== ANIMACIONES ===== */
|
| 141 |
+
@keyframes fadeInUp {
|
| 142 |
+
from { opacity: 0; transform: translateY(12px); }
|
| 143 |
+
to { opacity: 1; transform: translateY(0); }
|
| 144 |
+
}
|
| 145 |
+
@keyframes pulse-blue {
|
| 146 |
+
0%, 100% { box-shadow: 0 0 0 0 rgba(88, 166, 255, 0.3); }
|
| 147 |
+
50% { box-shadow: 0 0 0 8px rgba(88, 166, 255, 0); }
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/* ===== SCROLLBAR ===== */
|
| 151 |
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 152 |
+
::-webkit-scrollbar-track { background: #0A0E1A; }
|
| 153 |
+
::-webkit-scrollbar-thumb { background: #1E2A3A; border-radius: 4px; }
|
| 154 |
+
::-webkit-scrollbar-thumb:hover { background: #58A6FF; }
|
| 155 |
+
|
| 156 |
+
/* ===== DATAFRAME ===== */
|
| 157 |
+
[data-testid="stDataFrame"] { border-radius: 12px; overflow: hidden; }
|
| 158 |
+
|
| 159 |
+
/* ===== OPERATIONS UI REFRESH ===== */
|
| 160 |
+
:root {
|
| 161 |
+
--bg-main: #080C12;
|
| 162 |
+
--surface-1: #0F141C;
|
| 163 |
+
--surface-2: #151B24;
|
| 164 |
+
--line-soft: #242C38;
|
| 165 |
+
--text-main: #E8EDF5;
|
| 166 |
+
--text-muted: #8C98A8;
|
| 167 |
+
--blue: #4F9CF9;
|
| 168 |
+
--green: #31C48D;
|
| 169 |
+
--amber: #F6B44B;
|
| 170 |
+
--orange: #F08A3C;
|
| 171 |
+
--red: #EF5B5B;
|
| 172 |
+
--gray: #748091;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
div[data-testid="metric-container"] {
|
| 176 |
+
background: var(--surface-1);
|
| 177 |
+
border: 1px solid var(--line-soft);
|
| 178 |
+
border-radius: 8px;
|
| 179 |
+
box-shadow: none;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
div[data-testid="metric-container"]:hover {
|
| 183 |
+
transform: none;
|
| 184 |
+
border-color: #344255;
|
| 185 |
+
box-shadow: none;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.section-title {
|
| 189 |
+
margin: 2px 0 12px 0;
|
| 190 |
+
color: var(--text-main);
|
| 191 |
+
font-size: 18px;
|
| 192 |
+
font-weight: 750;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.section-subtitle {
|
| 196 |
+
margin: -8px 0 16px 0;
|
| 197 |
+
color: var(--text-muted);
|
| 198 |
+
font-size: 13px;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.monitor-list {
|
| 202 |
+
display: flex;
|
| 203 |
+
flex-direction: column;
|
| 204 |
+
gap: 10px;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.monitor-row {
|
| 208 |
+
background: var(--surface-1);
|
| 209 |
+
border: 1px solid var(--line-soft);
|
| 210 |
+
border-left: 4px solid var(--status-color, var(--blue));
|
| 211 |
+
border-radius: 8px;
|
| 212 |
+
padding: 12px 14px;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.monitor-row-head {
|
| 216 |
+
display: flex;
|
| 217 |
+
align-items: flex-start;
|
| 218 |
+
justify-content: space-between;
|
| 219 |
+
gap: 14px;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.monitor-id {
|
| 223 |
+
color: var(--text-main);
|
| 224 |
+
font-size: 16px;
|
| 225 |
+
font-weight: 800;
|
| 226 |
+
line-height: 1.2;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.monitor-owner {
|
| 230 |
+
color: var(--text-muted);
|
| 231 |
+
font-size: 11px;
|
| 232 |
+
text-transform: uppercase;
|
| 233 |
+
letter-spacing: 0.04em;
|
| 234 |
+
margin-bottom: 3px;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
.monitor-object {
|
| 238 |
+
color: #B8C2D0;
|
| 239 |
+
font-size: 12px;
|
| 240 |
+
line-height: 1.35;
|
| 241 |
+
margin-top: 5px;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.monitor-meta {
|
| 245 |
+
display: flex;
|
| 246 |
+
justify-content: flex-end;
|
| 247 |
+
flex-wrap: wrap;
|
| 248 |
+
gap: 6px;
|
| 249 |
+
margin-top: 8px;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
.status-badge {
|
| 253 |
+
display: inline-flex;
|
| 254 |
+
align-items: center;
|
| 255 |
+
gap: 6px;
|
| 256 |
+
background: color-mix(in srgb, var(--status-color, var(--blue)) 14%, transparent);
|
| 257 |
+
color: var(--status-color, var(--blue));
|
| 258 |
+
border: 1px solid color-mix(in srgb, var(--status-color, var(--blue)) 42%, transparent);
|
| 259 |
+
border-radius: 999px;
|
| 260 |
+
padding: 3px 9px;
|
| 261 |
+
font-size: 11px;
|
| 262 |
+
font-weight: 700;
|
| 263 |
+
white-space: nowrap;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.status-dot {
|
| 267 |
+
width: 7px;
|
| 268 |
+
height: 7px;
|
| 269 |
+
border-radius: 50%;
|
| 270 |
+
background: var(--status-color, var(--blue));
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
.mini-pill {
|
| 274 |
+
background: #0B1017;
|
| 275 |
+
border: 1px solid var(--line-soft);
|
| 276 |
+
border-radius: 999px;
|
| 277 |
+
color: var(--text-muted);
|
| 278 |
+
padding: 3px 9px;
|
| 279 |
+
font-size: 11px;
|
| 280 |
+
white-space: nowrap;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.sli-summary {
|
| 284 |
+
margin: 8px 0 10px 0;
|
| 285 |
+
background: #0B1017;
|
| 286 |
+
border: 1px solid var(--line-soft);
|
| 287 |
+
border-left: 3px solid var(--amber);
|
| 288 |
+
border-radius: 8px;
|
| 289 |
+
padding: 10px 12px;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.sli-summary-title {
|
| 293 |
+
color: var(--amber);
|
| 294 |
+
font-size: 11px;
|
| 295 |
+
font-weight: 800;
|
| 296 |
+
letter-spacing: 0.04em;
|
| 297 |
+
text-transform: uppercase;
|
| 298 |
+
margin-bottom: 4px;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
.sli-summary-body {
|
| 302 |
+
color: #D9E2EF;
|
| 303 |
+
font-size: 12px;
|
| 304 |
+
line-height: 1.35;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
.work-panel {
|
| 308 |
+
background: var(--surface-1);
|
| 309 |
+
border: 1px solid var(--line-soft);
|
| 310 |
+
border-radius: 8px;
|
| 311 |
+
padding: 14px;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.info-grid {
|
| 315 |
+
display: grid;
|
| 316 |
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
| 317 |
+
gap: 8px;
|
| 318 |
+
margin-bottom: 14px;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
.info-tile {
|
| 322 |
+
background: var(--surface-1);
|
| 323 |
+
border: 1px solid var(--line-soft);
|
| 324 |
+
border-radius: 8px;
|
| 325 |
+
padding: 12px;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
.info-label {
|
| 329 |
+
color: var(--text-muted);
|
| 330 |
+
font-size: 10px;
|
| 331 |
+
text-transform: uppercase;
|
| 332 |
+
letter-spacing: 0.05em;
|
| 333 |
+
margin-bottom: 5px;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
.info-value {
|
| 337 |
+
color: var(--text-main);
|
| 338 |
+
font-size: 13px;
|
| 339 |
+
font-weight: 650;
|
| 340 |
+
line-height: 1.3;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.detail-card {
|
| 344 |
+
background: var(--surface-1);
|
| 345 |
+
border: 1px solid var(--line-soft);
|
| 346 |
+
border-left: 4px solid var(--blue);
|
| 347 |
+
border-radius: 8px;
|
| 348 |
+
padding: 18px;
|
| 349 |
+
box-shadow: none;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.provider-card, .email-card, .step-card {
|
| 353 |
+
border-radius: 8px;
|
| 354 |
+
background: var(--surface-1);
|
| 355 |
+
border-color: var(--line-soft);
|
| 356 |
+
box-shadow: none;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
.stButton > button,
|
| 360 |
+
.stDownloadButton > button,
|
| 361 |
+
[data-testid="stBaseButton-secondary"],
|
| 362 |
+
[data-testid="stBaseButton-primary"] {
|
| 363 |
+
border-radius: 7px !important;
|
| 364 |
+
min-height: 36px;
|
| 365 |
+
font-weight: 650;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
.stButton > button[kind="primary"],
|
| 369 |
+
[data-testid="stBaseButton-primary"] {
|
| 370 |
+
background: #2F7FD8 !important;
|
| 371 |
+
border-color: #2F7FD8 !important;
|
| 372 |
+
}
|