Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -8,7 +8,6 @@ from fpdf import FPDF
|
|
| 8 |
import gradio as gr
|
| 9 |
from playwright.sync_api import sync_playwright
|
| 10 |
import time
|
| 11 |
-
import random
|
| 12 |
import requests
|
| 13 |
from PIL import Image
|
| 14 |
import io
|
|
@@ -61,20 +60,48 @@ def descargar_imagen(url, idx):
|
|
| 61 |
except: return None
|
| 62 |
return None
|
| 63 |
|
|
|
|
| 64 |
def construir_urls_final(operacion, barrio, ciudad, tipo, hab, ban, park, antiguedad, m2_min, m2_max, ascensor, piscina):
|
| 65 |
mapa_ant = {"Menos de 1 año": "de-0-a-1-anos", "1 a 8 años": "de-1-a-8-anos", "9 a 15 años": "de-9-a-15-anos", "16 a 30 años": "de-16-a-30-anos", "Más de 30 años": "mas-de-30-anos"}
|
| 66 |
slug_ant = mapa_ant.get(antiguedad, "de-1-a-8-anos")
|
| 67 |
slug_park = f"{int(park)}-parqueadero" if int(park) == 1 else f"{int(park)}-parqueaderos"
|
|
|
|
| 68 |
b_slug = barrio.lower().strip().replace(" ", "-")
|
| 69 |
c_slug = ciudad.lower().strip().replace(" ", "-")
|
| 70 |
op_slug = operacion.lower().strip()
|
| 71 |
tipo_slug = tipo.lower().strip()
|
| 72 |
-
tipo_fr = "casas-y-apartamentos-y-apartaestudios" if tipo_slug in ["apartamento", "casa"] else tipo_slug + "s"
|
| 73 |
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
return url_fr_base, url_mc
|
| 79 |
|
| 80 |
def extraer_precio(texto, operacion):
|
|
@@ -91,7 +118,7 @@ def extraer_ubicacion(texto):
|
|
| 91 |
for linea in lineas:
|
| 92 |
if "$" in linea or re.search(r'(?i)(hab|baño|m2|m²)', linea): continue
|
| 93 |
if "," in linea or " en " in linea.lower():
|
| 94 |
-
limpio = re.sub(r'(?i)(apartamento|casa|bodega|lote|oficina)\s+en\s+(arriendo|venta)\s+(en\s+)?', '', linea)
|
| 95 |
return limpio[:60].strip()
|
| 96 |
for linea in lineas[1:4]:
|
| 97 |
if "$" not in linea and not re.search(r'\d', linea): return linea[:60]
|
|
@@ -159,7 +186,7 @@ def motor_tramitia_visual(operacion, barrio, ciudad, area, m2_min, m2_max, tipo,
|
|
| 159 |
browser = p.chromium.launch(headless=True, args=['--disable-blink-features=AutomationControlled', '--no-sandbox'])
|
| 160 |
context = browser.new_context(viewport={'width': 1366, 'height': 768}, user_agent=ua.random)
|
| 161 |
|
| 162 |
-
# 1. EXTRACCIÓN
|
| 163 |
try:
|
| 164 |
page = context.new_page(); page.goto(url_fr, wait_until="domcontentloaded", timeout=60000)
|
| 165 |
for _ in range(3): page.mouse.wheel(0, 1000); page.wait_for_timeout(2000)
|
|
@@ -185,12 +212,12 @@ def motor_tramitia_visual(operacion, barrio, ciudad, area, m2_min, m2_max, tipo,
|
|
| 185 |
page.close(); log_visible += f"✅ FR: {cont_fr} inmuebles.\n"
|
| 186 |
except Exception as e: log_visible += f"⚠️ Error FR.\n"
|
| 187 |
|
| 188 |
-
# 2. EXTRACCIÓN
|
| 189 |
try:
|
| 190 |
page = context.new_page(); page.goto(url_mc, wait_until="domcontentloaded", timeout=60000)
|
| 191 |
for _ in range(3): page.mouse.wheel(0, 1000); page.wait_for_timeout(2000)
|
| 192 |
elementos = page.query_selector_all("a"); cont_mc = 0
|
| 193 |
-
for el in
|
| 194 |
if cont_mc >= 12: break
|
| 195 |
try:
|
| 196 |
href = el.get_attribute("href")
|
|
@@ -211,41 +238,29 @@ def motor_tramitia_visual(operacion, barrio, ciudad, area, m2_min, m2_max, tipo,
|
|
| 211 |
page.close(); log_visible += f"✅ MC: {cont_mc} inmuebles.\n"
|
| 212 |
except Exception as e: log_visible += f"⚠️ Error MC.\n"
|
| 213 |
|
| 214 |
-
# 3.
|
| 215 |
log_visible += f"\n🔄 Ejecutando Módulo Financiero (Buscando {op_inversa} para Cap Rate)...\n"
|
| 216 |
try:
|
| 217 |
page = context.new_page(); page.goto(url_inversa, wait_until="domcontentloaded", timeout=45000)
|
| 218 |
-
try: page.wait_for_load_state("networkidle", timeout=5000)
|
| 219 |
-
except: pass
|
| 220 |
-
|
| 221 |
-
# Hacemos scroll para asegurar que carguen los elementos
|
| 222 |
for _ in range(3): page.mouse.wheel(0, 1000); page.wait_for_timeout(1500)
|
| 223 |
-
|
| 224 |
elementos = page.query_selector_all("a")
|
| 225 |
for el in elementos:
|
| 226 |
-
if len(precios_inversos) >= 8: break
|
| 227 |
try:
|
| 228 |
href = el.get_attribute("href")
|
| 229 |
if not es_inmueble_valido(href, "FR"): continue
|
| 230 |
-
|
| 231 |
-
# Usamos el mismo radar preciso de la búsqueda principal
|
| 232 |
card = el.evaluate_handle("el => el.closest('article') || el.closest('[class*=\"card\"]') || el.parentElement.parentElement")
|
| 233 |
if not card: continue
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
precio = extraer_precio(txt, op_inversa)
|
| 237 |
-
|
| 238 |
-
if precio > 0:
|
| 239 |
-
precios_inversos.append(precio)
|
| 240 |
except: continue
|
| 241 |
page.close()
|
| 242 |
-
log_visible += f"✅
|
| 243 |
-
except Exception as ex:
|
| 244 |
-
log_visible += f"⚠️ Módulo Financiero falló: {ex}\n"
|
| 245 |
|
| 246 |
browser.close()
|
| 247 |
|
| 248 |
-
if not resultados: return f"{log_visible}\n❌ NO HAY DATOS.", pd.DataFrame(), None, "---", "Sin Mapa"
|
| 249 |
|
| 250 |
# --- LIMPIEZA DE DATOS ---
|
| 251 |
df_final_completo = pd.DataFrame(resultados)
|
|
@@ -253,9 +268,8 @@ def motor_tramitia_visual(operacion, barrio, ciudad, area, m2_min, m2_max, tipo,
|
|
| 253 |
df_mc = df_final_completo[df_final_completo['Portal'] == 'Metrocuadrado'].head(6)
|
| 254 |
df_final = pd.concat([df_fr, df_mc]).reset_index(drop=True)
|
| 255 |
|
| 256 |
-
# ---
|
| 257 |
df_final['V_Homogeneizado'] = df_final['Precio'] * 0.92
|
| 258 |
-
|
| 259 |
mediana_m2 = df_final['Precio_M2'].median()
|
| 260 |
minimo_zona = df_final['Precio'].min()
|
| 261 |
maximo_zona = df_final['Precio'].max()
|
|
@@ -266,34 +280,25 @@ def motor_tramitia_visual(operacion, barrio, ciudad, area, m2_min, m2_max, tipo,
|
|
| 266 |
valor_maximo_neg = (mediana_m2 * area) * 0.95
|
| 267 |
valor_minimo_neg = (mediana_m2 * area) * 0.90
|
| 268 |
|
| 269 |
-
# ---
|
| 270 |
cap_rate_txt = "Datos insuficientes en la zona para cruzar la rentabilidad."
|
| 271 |
if len(precios_inversos) > 0:
|
| 272 |
mediana_inversa = pd.Series(precios_inversos).median()
|
| 273 |
-
|
| 274 |
-
# Para evitar rentabilidades rotas si el portal cruza mal los datos (ej: Venta de lote en vez de apto)
|
| 275 |
try:
|
| 276 |
if operacion == "Arriendo":
|
| 277 |
-
# Tasa Cap = (Arriendo Anual Sugerido / Valor de Venta Promedio de la zona)
|
| 278 |
rentabilidad = ((valor_total_sugerido * 12) / mediana_inversa) * 100
|
| 279 |
cap_rate_txt = f"Valor Comercial de Venta Promedio en la zona: ${mediana_inversa:,.0f} COP\nRentabilidad Bruta Anual Estimada (Cap Rate): {rentabilidad:.2f}%"
|
| 280 |
-
else:
|
| 281 |
-
# Tasa Cap = (Arriendo Anual Promedio de la zona / Valor de Venta Sugerido)
|
| 282 |
rentabilidad = ((mediana_inversa * 12) / valor_total_sugerido) * 100
|
| 283 |
cap_rate_txt = f"Canon de Arriendo Mensual Promedio en la zona: ${mediana_inversa:,.0f} COP\nRentabilidad Bruta Anual Estimada (Cap Rate): {rentabilidad:.2f}%"
|
| 284 |
except: pass
|
| 285 |
|
| 286 |
-
# ---
|
| 287 |
mapa_html, lat_mapa, lon_mapa = generar_mapa(barrio, ciudad)
|
| 288 |
-
|
| 289 |
-
# --- GENERACIÓN DEL PDF ---
|
| 290 |
preparar_entorno_pdf()
|
| 291 |
pdf_path = f"Dictamen_Pericial_SAE_{int(time.time())}.pdf"
|
| 292 |
pdf = PDF_SAE()
|
| 293 |
-
|
| 294 |
-
COLOR_ROSADO = (254, 25, 120)
|
| 295 |
-
COLOR_GRIS = (137, 137, 137)
|
| 296 |
-
COLOR_NEGRO = (0, 0, 0)
|
| 297 |
|
| 298 |
# PÁGINA 1
|
| 299 |
pdf.add_page()
|
|
@@ -301,8 +306,7 @@ def motor_tramitia_visual(operacion, barrio, ciudad, area, m2_min, m2_max, tipo,
|
|
| 301 |
pdf.cell(0, 15, sanear_texto("DICTAMEN COMERCIAL Y FINANCIERO"), ln=True, align='C')
|
| 302 |
pdf.set_font("Arial", 'B', 14); pdf.set_text_color(*COLOR_GRIS)
|
| 303 |
pdf.cell(0, 8, sanear_texto(f"METODOLOGIA COMPARATIVA DE MERCADO"), ln=True, align='C')
|
| 304 |
-
pdf.line(20, 65, 190, 65)
|
| 305 |
-
pdf.ln(15)
|
| 306 |
|
| 307 |
pdf.set_font("Arial", 'B', 12); pdf.set_text_color(*COLOR_ROSADO)
|
| 308 |
pdf.cell(0, 10, sanear_texto("1. DATOS GEOESPACIALES Y DEL ACTIVO"), ln=True)
|
|
@@ -320,7 +324,7 @@ def motor_tramitia_visual(operacion, barrio, ciudad, area, m2_min, m2_max, tipo,
|
|
| 320 |
pdf.multi_cell(0, 6, sanear_texto(f"Basado en el cruce de datos de la zona, el activo presenta el siguiente comportamiento:\n{cap_rate_txt}"))
|
| 321 |
pdf.ln(5)
|
| 322 |
|
| 323 |
-
# PÁGINA 2
|
| 324 |
pdf.add_page()
|
| 325 |
pdf.set_font("Arial", 'B', 12); pdf.set_fill_color(*COLOR_ROSADO); pdf.set_text_color(255, 255, 255)
|
| 326 |
pdf.cell(0, 10, sanear_texto(" 3. MATRIZ DE HOMOGENEIZACION Y RANGOS"), ln=True, fill=True)
|
|
@@ -334,7 +338,6 @@ def motor_tramitia_visual(operacion, barrio, ciudad, area, m2_min, m2_max, tipo,
|
|
| 334 |
pdf.cell(50, 8, sanear_texto("-8.00%"), border=1, ln=True, align='C')
|
| 335 |
pdf.ln(10)
|
| 336 |
|
| 337 |
-
# BANDAS
|
| 338 |
pdf.set_font("Arial", 'B', 14); pdf.set_text_color(*COLOR_ROSADO)
|
| 339 |
pdf.cell(0, 10, sanear_texto("RANGOS DE NEGOCIACION AUTORIZADOS (CIERRE):"), ln=True)
|
| 340 |
pdf.set_font("Arial", 'B', 12); pdf.set_text_color(*COLOR_GRIS)
|
|
@@ -344,7 +347,7 @@ def motor_tramitia_visual(operacion, barrio, ciudad, area, m2_min, m2_max, tipo,
|
|
| 344 |
pdf.set_font("Arial", 'B', 12); pdf.set_text_color(200, 0, 0)
|
| 345 |
pdf.cell(0, 8, sanear_texto(f"PISO MINIMO ACEPTABLE (-10%): ${valor_minimo_neg:,.0f} COP"), ln=True)
|
| 346 |
|
| 347 |
-
# PÁGINA 3+
|
| 348 |
pdf.add_page()
|
| 349 |
pdf.set_font("Arial", 'B', 12); pdf.set_fill_color(*COLOR_GRIS); pdf.set_text_color(255, 255, 255)
|
| 350 |
pdf.cell(0, 10, sanear_texto(" 4. ANEXO TECNICO: TESTIGOS COMPARABLES"), ln=True, fill=True)
|
|
@@ -356,10 +359,8 @@ def motor_tramitia_visual(operacion, barrio, ciudad, area, m2_min, m2_max, tipo,
|
|
| 356 |
if img_path and os.path.exists(img_path):
|
| 357 |
try: pdf.image(img_path, x=10, y=y_start, w=45, h=30); text_x = 60
|
| 358 |
except: pass
|
| 359 |
-
|
| 360 |
pdf.set_xy(text_x, y_start); pdf.set_font("Arial", 'B', 11)
|
| 361 |
pdf.cell(0, 6, f"Oferta: ${r['Precio']:,.0f} | Homogeneizado: ${r['V_Homogeneizado']:,.0f}", ln=True)
|
| 362 |
-
|
| 363 |
pdf.set_x(text_x); pdf.set_font("Arial", 'B', 8); pdf.set_text_color(*COLOR_ROSADO)
|
| 364 |
pdf.cell(0, 4, f"Ubicacion: {sanear_texto(r['Ubicacion'])}", ln=True)
|
| 365 |
pdf.set_x(text_x); pdf.set_font("Arial", '', 8); pdf.set_text_color(*COLOR_NEGRO)
|
|
@@ -374,7 +375,6 @@ def motor_tramitia_visual(operacion, barrio, ciudad, area, m2_min, m2_max, tipo,
|
|
| 374 |
|
| 375 |
pdf.output(pdf_path)
|
| 376 |
|
| 377 |
-
# --- CÁLCULOS INTERFAZ ---
|
| 378 |
resumen = (
|
| 379 |
f"🏢 **DICTAMEN PERICIAL Y FINANCIERO**\n"
|
| 380 |
f"📈 **Techo Máximo:** ${valor_maximo_neg:,.0f}\n"
|
|
@@ -391,9 +391,17 @@ def motor_tramitia_visual(operacion, barrio, ciudad, area, m2_min, m2_max, tipo,
|
|
| 391 |
return f"{log_visible}\n✅ Dictamen Maestro Generado.", df_mostrar, pdf_path, resumen, mapa_html
|
| 392 |
|
| 393 |
except Exception as error_fatal:
|
| 394 |
-
traza = traceback.format_exc()
|
| 395 |
return f"❌ ERROR GRAVE:\n{str(error_fatal)}", pd.DataFrame(), None, "⚠️ Falló la ejecución", "<p>Error en mapa</p>"
|
| 396 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
# --- INTERFAZ GRÁFICA ---
|
| 398 |
with gr.Blocks() as demo:
|
| 399 |
gr.Markdown("## 🏢 TramitIA Pro: Dictamen Pericial Máster (IGAC/SAE)")
|
|
@@ -403,25 +411,35 @@ with gr.Blocks() as demo:
|
|
| 403 |
op = gr.Radio(["Arriendo", "Venta"], label="Tipo de Operación", value="Arriendo")
|
| 404 |
c = gr.Textbox(label="Ciudad", value="Barranquilla")
|
| 405 |
b = gr.Textbox(label="Barrio (Ej: La Concepcion)", value="La Concepcion")
|
|
|
|
| 406 |
with gr.Row():
|
| 407 |
-
t = gr.Dropdown(["Apartamento", "Casa", "Bodega", "
|
| 408 |
a = gr.Number(label="Área M2 a tasar", value=70)
|
|
|
|
| 409 |
with gr.Row():
|
| 410 |
m2_min = gr.Number(label="M2 Mínimo", value=10)
|
| 411 |
m2_max = gr.Number(label="M2 Máximo", value=200)
|
|
|
|
|
|
|
| 412 |
with gr.Row():
|
| 413 |
ascensor = gr.Checkbox(label="Con Ascensor")
|
| 414 |
piscina = gr.Checkbox(label="Con Piscina")
|
| 415 |
with gr.Row():
|
| 416 |
-
h = gr.Number(label="Hab", value=3)
|
|
|
|
|
|
|
|
|
|
| 417 |
e = gr.Dropdown(["Menos de 1 año", "1 a 8 años", "9 a 15 años", "16 a 30 años", "Más de 30 años"], label="Antigüedad", value="1 a 8 años")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
btn = gr.Button("GENERAR DICTAMEN INTEGRAL", variant="primary")
|
| 419 |
|
| 420 |
with gr.Column(scale=2):
|
| 421 |
res_fin = gr.Markdown("### 💰 Resultado y Rentabilidad (Cap Rate)...")
|
| 422 |
with gr.Tabs():
|
| 423 |
-
with gr.TabItem("Mapa Geoespacial"):
|
| 424 |
-
mapa_ui = gr.HTML("<p style='text-align:center; color:gray;'>El mapa aparecerá aquí...</p>")
|
| 425 |
with gr.TabItem("Descargar Dictamen (PDF)"): out_pdf = gr.File()
|
| 426 |
with gr.TabItem("Tabla de Homogeneización"): out_df = gr.Dataframe()
|
| 427 |
with gr.TabItem("Log de Sistema"): msg = gr.Textbox(lines=10)
|
|
|
|
| 8 |
import gradio as gr
|
| 9 |
from playwright.sync_api import sync_playwright
|
| 10 |
import time
|
|
|
|
| 11 |
import requests
|
| 12 |
from PIL import Image
|
| 13 |
import io
|
|
|
|
| 60 |
except: return None
|
| 61 |
return None
|
| 62 |
|
| 63 |
+
# --- CONSTRUCTOR DE URLS INTELIGENTE (ACTUALIZADO) ---
|
| 64 |
def construir_urls_final(operacion, barrio, ciudad, tipo, hab, ban, park, antiguedad, m2_min, m2_max, ascensor, piscina):
|
| 65 |
mapa_ant = {"Menos de 1 año": "de-0-a-1-anos", "1 a 8 años": "de-1-a-8-anos", "9 a 15 años": "de-9-a-15-anos", "16 a 30 años": "de-16-a-30-anos", "Más de 30 años": "mas-de-30-anos"}
|
| 66 |
slug_ant = mapa_ant.get(antiguedad, "de-1-a-8-anos")
|
| 67 |
slug_park = f"{int(park)}-parqueadero" if int(park) == 1 else f"{int(park)}-parqueaderos"
|
| 68 |
+
|
| 69 |
b_slug = barrio.lower().strip().replace(" ", "-")
|
| 70 |
c_slug = ciudad.lower().strip().replace(" ", "-")
|
| 71 |
op_slug = operacion.lower().strip()
|
| 72 |
tipo_slug = tipo.lower().strip()
|
|
|
|
| 73 |
|
| 74 |
+
residenciales = ["apartamento", "casa", "apartaestudio"]
|
| 75 |
+
|
| 76 |
+
# 1. Ajuste de Estructura de Filtros según Inmueble
|
| 77 |
+
if tipo_slug in residenciales:
|
| 78 |
+
tipo_fr = "casas-y-apartamentos-y-apartaestudios"
|
| 79 |
+
filtros_fr = f"/{int(hab)}-o-mas-habitaciones/{int(ban)}-o-mas-banos/{slug_park}/{slug_ant}"
|
| 80 |
+
filtros_mc = f"/{int(ban)}-banos-{int(hab)}-habitaciones"
|
| 81 |
+
else:
|
| 82 |
+
# Es Comercial o Terreno (Bodega, Local, Oficina, Lote, Finca)
|
| 83 |
+
if tipo_slug == "local": tipo_fr = "locales"
|
| 84 |
+
elif tipo_slug == "edificio": tipo_fr = "edificios"
|
| 85 |
+
else: tipo_fr = tipo_slug + "s"
|
| 86 |
+
|
| 87 |
+
# Lotes y Fincas no llevan ni baños, ni cuartos, ni antigüedad ni parqueaderos en la ruta
|
| 88 |
+
if tipo_slug in ["lote", "finca"]:
|
| 89 |
+
filtros_fr = ""
|
| 90 |
+
filtros_mc = ""
|
| 91 |
+
else:
|
| 92 |
+
# Oficinas, Bodegas, Locales sí llevan parqueaderos y antigüedad, pero NO cuartos ni baños
|
| 93 |
+
filtros_fr = f"/{slug_park}/{slug_ant}"
|
| 94 |
+
filtros_mc = ""
|
| 95 |
+
|
| 96 |
+
# 2. Generación Final de Rutas
|
| 97 |
+
url_fr_base = f"https://www.fincaraiz.com.co/{op_slug}/{tipo_fr}/{b_slug}/{c_slug}{filtros_fr}/m2-desde-{int(m2_min)}/m2-hasta-{int(m2_max)}"
|
| 98 |
+
|
| 99 |
+
# Excepciones lógicas de amenidades
|
| 100 |
+
if ascensor and tipo_slug not in ["lote", "finca"]: url_fr_base += "/con-ascensor"
|
| 101 |
+
if piscina and tipo_slug in residenciales + ["finca"]: url_fr_base += "/con-piscina"
|
| 102 |
+
|
| 103 |
+
url_mc = f"https://www.metrocuadrado.com/{tipo_slug}/{op_slug}/{c_slug}/{b_slug}{filtros_mc}/?search=form"
|
| 104 |
+
|
| 105 |
return url_fr_base, url_mc
|
| 106 |
|
| 107 |
def extraer_precio(texto, operacion):
|
|
|
|
| 118 |
for linea in lineas:
|
| 119 |
if "$" in linea or re.search(r'(?i)(hab|baño|m2|m²)', linea): continue
|
| 120 |
if "," in linea or " en " in linea.lower():
|
| 121 |
+
limpio = re.sub(r'(?i)(apartamento|casa|bodega|lote|oficina|local|consultorio|finca|edificio)\s+en\s+(arriendo|venta)\s+(en\s+)?', '', linea)
|
| 122 |
return limpio[:60].strip()
|
| 123 |
for linea in lineas[1:4]:
|
| 124 |
if "$" not in linea and not re.search(r'\d', linea): return linea[:60]
|
|
|
|
| 186 |
browser = p.chromium.launch(headless=True, args=['--disable-blink-features=AutomationControlled', '--no-sandbox'])
|
| 187 |
context = browser.new_context(viewport={'width': 1366, 'height': 768}, user_agent=ua.random)
|
| 188 |
|
| 189 |
+
# 1. EXTRACCIÓN (FR)
|
| 190 |
try:
|
| 191 |
page = context.new_page(); page.goto(url_fr, wait_until="domcontentloaded", timeout=60000)
|
| 192 |
for _ in range(3): page.mouse.wheel(0, 1000); page.wait_for_timeout(2000)
|
|
|
|
| 212 |
page.close(); log_visible += f"✅ FR: {cont_fr} inmuebles.\n"
|
| 213 |
except Exception as e: log_visible += f"⚠️ Error FR.\n"
|
| 214 |
|
| 215 |
+
# 2. EXTRACCIÓN (MC)
|
| 216 |
try:
|
| 217 |
page = context.new_page(); page.goto(url_mc, wait_until="domcontentloaded", timeout=60000)
|
| 218 |
for _ in range(3): page.mouse.wheel(0, 1000); page.wait_for_timeout(2000)
|
| 219 |
elementos = page.query_selector_all("a"); cont_mc = 0
|
| 220 |
+
for el in elements:
|
| 221 |
if cont_mc >= 12: break
|
| 222 |
try:
|
| 223 |
href = el.get_attribute("href")
|
|
|
|
| 238 |
page.close(); log_visible += f"✅ MC: {cont_mc} inmuebles.\n"
|
| 239 |
except Exception as e: log_visible += f"⚠️ Error MC.\n"
|
| 240 |
|
| 241 |
+
# 3. MÓDULO FINANCIERO
|
| 242 |
log_visible += f"\n🔄 Ejecutando Módulo Financiero (Buscando {op_inversa} para Cap Rate)...\n"
|
| 243 |
try:
|
| 244 |
page = context.new_page(); page.goto(url_inversa, wait_until="domcontentloaded", timeout=45000)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
for _ in range(3): page.mouse.wheel(0, 1000); page.wait_for_timeout(1500)
|
|
|
|
| 246 |
elementos = page.query_selector_all("a")
|
| 247 |
for el in elementos:
|
| 248 |
+
if len(precios_inversos) >= 8: break
|
| 249 |
try:
|
| 250 |
href = el.get_attribute("href")
|
| 251 |
if not es_inmueble_valido(href, "FR"): continue
|
|
|
|
|
|
|
| 252 |
card = el.evaluate_handle("el => el.closest('article') || el.closest('[class*=\"card\"]') || el.parentElement.parentElement")
|
| 253 |
if not card: continue
|
| 254 |
+
txt = card.inner_text(); precio = extraer_precio(txt, op_inversa)
|
| 255 |
+
if precio > 0: precios_inversos.append(precio)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
except: continue
|
| 257 |
page.close()
|
| 258 |
+
log_visible += f"✅ Finanzas: Capturados {len(precios_inversos)} valores de {op_inversa} en la zona.\n"
|
| 259 |
+
except Exception as ex: pass
|
|
|
|
| 260 |
|
| 261 |
browser.close()
|
| 262 |
|
| 263 |
+
if not resultados: return f"{log_visible}\n❌ NO HAY DATOS. Intenta ampliar los M2 o quitar filtros.", pd.DataFrame(), None, "---", "Sin Mapa"
|
| 264 |
|
| 265 |
# --- LIMPIEZA DE DATOS ---
|
| 266 |
df_final_completo = pd.DataFrame(resultados)
|
|
|
|
| 268 |
df_mc = df_final_completo[df_final_completo['Portal'] == 'Metrocuadrado'].head(6)
|
| 269 |
df_final = pd.concat([df_fr, df_mc]).reset_index(drop=True)
|
| 270 |
|
| 271 |
+
# --- CÁLCULOS TÉCNICOS ---
|
| 272 |
df_final['V_Homogeneizado'] = df_final['Precio'] * 0.92
|
|
|
|
| 273 |
mediana_m2 = df_final['Precio_M2'].median()
|
| 274 |
minimo_zona = df_final['Precio'].min()
|
| 275 |
maximo_zona = df_final['Precio'].max()
|
|
|
|
| 280 |
valor_maximo_neg = (mediana_m2 * area) * 0.95
|
| 281 |
valor_minimo_neg = (mediana_m2 * area) * 0.90
|
| 282 |
|
| 283 |
+
# --- CAP RATE ---
|
| 284 |
cap_rate_txt = "Datos insuficientes en la zona para cruzar la rentabilidad."
|
| 285 |
if len(precios_inversos) > 0:
|
| 286 |
mediana_inversa = pd.Series(precios_inversos).median()
|
|
|
|
|
|
|
| 287 |
try:
|
| 288 |
if operacion == "Arriendo":
|
|
|
|
| 289 |
rentabilidad = ((valor_total_sugerido * 12) / mediana_inversa) * 100
|
| 290 |
cap_rate_txt = f"Valor Comercial de Venta Promedio en la zona: ${mediana_inversa:,.0f} COP\nRentabilidad Bruta Anual Estimada (Cap Rate): {rentabilidad:.2f}%"
|
| 291 |
+
else:
|
|
|
|
| 292 |
rentabilidad = ((mediana_inversa * 12) / valor_total_sugerido) * 100
|
| 293 |
cap_rate_txt = f"Canon de Arriendo Mensual Promedio en la zona: ${mediana_inversa:,.0f} COP\nRentabilidad Bruta Anual Estimada (Cap Rate): {rentabilidad:.2f}%"
|
| 294 |
except: pass
|
| 295 |
|
| 296 |
+
# --- MAPA Y PDF ---
|
| 297 |
mapa_html, lat_mapa, lon_mapa = generar_mapa(barrio, ciudad)
|
|
|
|
|
|
|
| 298 |
preparar_entorno_pdf()
|
| 299 |
pdf_path = f"Dictamen_Pericial_SAE_{int(time.time())}.pdf"
|
| 300 |
pdf = PDF_SAE()
|
| 301 |
+
COLOR_ROSADO = (254, 25, 120); COLOR_GRIS = (137, 137, 137); COLOR_NEGRO = (0, 0, 0)
|
|
|
|
|
|
|
|
|
|
| 302 |
|
| 303 |
# PÁGINA 1
|
| 304 |
pdf.add_page()
|
|
|
|
| 306 |
pdf.cell(0, 15, sanear_texto("DICTAMEN COMERCIAL Y FINANCIERO"), ln=True, align='C')
|
| 307 |
pdf.set_font("Arial", 'B', 14); pdf.set_text_color(*COLOR_GRIS)
|
| 308 |
pdf.cell(0, 8, sanear_texto(f"METODOLOGIA COMPARATIVA DE MERCADO"), ln=True, align='C')
|
| 309 |
+
pdf.line(20, 65, 190, 65); pdf.ln(15)
|
|
|
|
| 310 |
|
| 311 |
pdf.set_font("Arial", 'B', 12); pdf.set_text_color(*COLOR_ROSADO)
|
| 312 |
pdf.cell(0, 10, sanear_texto("1. DATOS GEOESPACIALES Y DEL ACTIVO"), ln=True)
|
|
|
|
| 324 |
pdf.multi_cell(0, 6, sanear_texto(f"Basado en el cruce de datos de la zona, el activo presenta el siguiente comportamiento:\n{cap_rate_txt}"))
|
| 325 |
pdf.ln(5)
|
| 326 |
|
| 327 |
+
# PÁGINA 2
|
| 328 |
pdf.add_page()
|
| 329 |
pdf.set_font("Arial", 'B', 12); pdf.set_fill_color(*COLOR_ROSADO); pdf.set_text_color(255, 255, 255)
|
| 330 |
pdf.cell(0, 10, sanear_texto(" 3. MATRIZ DE HOMOGENEIZACION Y RANGOS"), ln=True, fill=True)
|
|
|
|
| 338 |
pdf.cell(50, 8, sanear_texto("-8.00%"), border=1, ln=True, align='C')
|
| 339 |
pdf.ln(10)
|
| 340 |
|
|
|
|
| 341 |
pdf.set_font("Arial", 'B', 14); pdf.set_text_color(*COLOR_ROSADO)
|
| 342 |
pdf.cell(0, 10, sanear_texto("RANGOS DE NEGOCIACION AUTORIZADOS (CIERRE):"), ln=True)
|
| 343 |
pdf.set_font("Arial", 'B', 12); pdf.set_text_color(*COLOR_GRIS)
|
|
|
|
| 347 |
pdf.set_font("Arial", 'B', 12); pdf.set_text_color(200, 0, 0)
|
| 348 |
pdf.cell(0, 8, sanear_texto(f"PISO MINIMO ACEPTABLE (-10%): ${valor_minimo_neg:,.0f} COP"), ln=True)
|
| 349 |
|
| 350 |
+
# PÁGINA 3+
|
| 351 |
pdf.add_page()
|
| 352 |
pdf.set_font("Arial", 'B', 12); pdf.set_fill_color(*COLOR_GRIS); pdf.set_text_color(255, 255, 255)
|
| 353 |
pdf.cell(0, 10, sanear_texto(" 4. ANEXO TECNICO: TESTIGOS COMPARABLES"), ln=True, fill=True)
|
|
|
|
| 359 |
if img_path and os.path.exists(img_path):
|
| 360 |
try: pdf.image(img_path, x=10, y=y_start, w=45, h=30); text_x = 60
|
| 361 |
except: pass
|
|
|
|
| 362 |
pdf.set_xy(text_x, y_start); pdf.set_font("Arial", 'B', 11)
|
| 363 |
pdf.cell(0, 6, f"Oferta: ${r['Precio']:,.0f} | Homogeneizado: ${r['V_Homogeneizado']:,.0f}", ln=True)
|
|
|
|
| 364 |
pdf.set_x(text_x); pdf.set_font("Arial", 'B', 8); pdf.set_text_color(*COLOR_ROSADO)
|
| 365 |
pdf.cell(0, 4, f"Ubicacion: {sanear_texto(r['Ubicacion'])}", ln=True)
|
| 366 |
pdf.set_x(text_x); pdf.set_font("Arial", '', 8); pdf.set_text_color(*COLOR_NEGRO)
|
|
|
|
| 375 |
|
| 376 |
pdf.output(pdf_path)
|
| 377 |
|
|
|
|
| 378 |
resumen = (
|
| 379 |
f"🏢 **DICTAMEN PERICIAL Y FINANCIERO**\n"
|
| 380 |
f"📈 **Techo Máximo:** ${valor_maximo_neg:,.0f}\n"
|
|
|
|
| 391 |
return f"{log_visible}\n✅ Dictamen Maestro Generado.", df_mostrar, pdf_path, resumen, mapa_html
|
| 392 |
|
| 393 |
except Exception as error_fatal:
|
|
|
|
| 394 |
return f"❌ ERROR GRAVE:\n{str(error_fatal)}", pd.DataFrame(), None, "⚠️ Falló la ejecución", "<p>Error en mapa</p>"
|
| 395 |
|
| 396 |
+
# --- FUNCIÓN DE OCULTAMIENTO INTELIGENTE UI ---
|
| 397 |
+
def adaptar_interfaz(tipo):
|
| 398 |
+
if tipo in ["Lote", "Finca"]:
|
| 399 |
+
return [gr.update(visible=False)] * 6
|
| 400 |
+
elif tipo in ["Bodega", "Local", "Oficina", "Consultorio", "Edificio"]:
|
| 401 |
+
return [gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(visible=True), gr.update(visible=True), gr.update(visible=False)]
|
| 402 |
+
else:
|
| 403 |
+
return [gr.update(visible=True)] * 6
|
| 404 |
+
|
| 405 |
# --- INTERFAZ GRÁFICA ---
|
| 406 |
with gr.Blocks() as demo:
|
| 407 |
gr.Markdown("## 🏢 TramitIA Pro: Dictamen Pericial Máster (IGAC/SAE)")
|
|
|
|
| 411 |
op = gr.Radio(["Arriendo", "Venta"], label="Tipo de Operación", value="Arriendo")
|
| 412 |
c = gr.Textbox(label="Ciudad", value="Barranquilla")
|
| 413 |
b = gr.Textbox(label="Barrio (Ej: La Concepcion)", value="La Concepcion")
|
| 414 |
+
|
| 415 |
with gr.Row():
|
| 416 |
+
t = gr.Dropdown(["Apartamento", "Casa", "Apartaestudio", "Bodega", "Local", "Oficina", "Consultorio", "Lote", "Finca", "Edificio"], label="Tipo de Inmueble", value="Apartamento")
|
| 417 |
a = gr.Number(label="Área M2 a tasar", value=70)
|
| 418 |
+
|
| 419 |
with gr.Row():
|
| 420 |
m2_min = gr.Number(label="M2 Mínimo", value=10)
|
| 421 |
m2_max = gr.Number(label="M2 Máximo", value=200)
|
| 422 |
+
|
| 423 |
+
# Se declaran las variables primero para poder esconderlas
|
| 424 |
with gr.Row():
|
| 425 |
ascensor = gr.Checkbox(label="Con Ascensor")
|
| 426 |
piscina = gr.Checkbox(label="Con Piscina")
|
| 427 |
with gr.Row():
|
| 428 |
+
h = gr.Number(label="Hab", value=3)
|
| 429 |
+
ban = gr.Number(label="Baños", value=2)
|
| 430 |
+
p = gr.Number(label="Park", value=1)
|
| 431 |
+
|
| 432 |
e = gr.Dropdown(["Menos de 1 año", "1 a 8 años", "9 a 15 años", "16 a 30 años", "Más de 30 años"], label="Antigüedad", value="1 a 8 años")
|
| 433 |
+
|
| 434 |
+
# Evento que oculta cosas al cambiar el tipo
|
| 435 |
+
t.change(adaptar_interfaz, inputs=t, outputs=[h, ban, p, e, ascensor, piscina])
|
| 436 |
+
|
| 437 |
btn = gr.Button("GENERAR DICTAMEN INTEGRAL", variant="primary")
|
| 438 |
|
| 439 |
with gr.Column(scale=2):
|
| 440 |
res_fin = gr.Markdown("### 💰 Resultado y Rentabilidad (Cap Rate)...")
|
| 441 |
with gr.Tabs():
|
| 442 |
+
with gr.TabItem("Mapa Geoespacial"): mapa_ui = gr.HTML("<p style='text-align:center; color:gray;'>El mapa aparecerá aquí...</p>")
|
|
|
|
| 443 |
with gr.TabItem("Descargar Dictamen (PDF)"): out_pdf = gr.File()
|
| 444 |
with gr.TabItem("Tabla de Homogeneización"): out_df = gr.Dataframe()
|
| 445 |
with gr.TabItem("Log de Sistema"): msg = gr.Textbox(lines=10)
|