Guilherme Silberfarb Costa commited on
Commit
b9bb1d5
·
1 Parent(s): e56a352

Improve elaboracao interactions and pesquisa map behavior

Browse files
backend/app/api/elaboracao.py CHANGED
@@ -129,7 +129,8 @@ class AvaliacaoPayload(SessionPayload):
129
 
130
 
131
  class AvaliacaoKnnDetalhesPayload(SessionPayload):
132
- valores_x: dict[str, Any]
 
133
 
134
 
135
  class AvaliacaoDeletePayload(SessionPayload):
@@ -401,7 +402,11 @@ def evaluation_calculate(payload: AvaliacaoPayload, request: Request) -> dict[st
401
  def evaluation_knn_details(payload: AvaliacaoKnnDetalhesPayload, request: Request) -> dict[str, Any]:
402
  session = session_store.get(payload.session_id)
403
  user = auth_service.require_user(request)
404
- resposta = elaboracao_service.detalhes_knn_avaliacao_elaboracao(session, payload.valores_x)
 
 
 
 
405
  log_event(
406
  "elaboracao",
407
  "avaliacao_knn_detalhes",
 
129
 
130
 
131
  class AvaliacaoKnnDetalhesPayload(SessionPayload):
132
+ valores_x: dict[str, Any] | None = None
133
+ indice_avaliacao: int | None = None
134
 
135
 
136
  class AvaliacaoDeletePayload(SessionPayload):
 
402
  def evaluation_knn_details(payload: AvaliacaoKnnDetalhesPayload, request: Request) -> dict[str, Any]:
403
  session = session_store.get(payload.session_id)
404
  user = auth_service.require_user(request)
405
+ resposta = elaboracao_service.detalhes_knn_avaliacao_elaboracao(
406
+ session,
407
+ payload.valores_x,
408
+ payload.indice_avaliacao,
409
+ )
410
  log_event(
411
  "elaboracao",
412
  "avaliacao_knn_detalhes",
backend/app/core/map_layers.py CHANGED
@@ -231,6 +231,7 @@ def build_trabalhos_tecnicos_marker_payloads(
231
  endereco = str(item.get("endereco") or "").strip()
232
  numero = str(item.get("numero") or "").strip()
233
  modelos = [str(valor).strip() for valor in (item.get("modelos_relacionados") or []) if str(valor).strip()]
 
234
 
235
  endereco_parts = []
236
  if endereco:
@@ -306,6 +307,7 @@ def build_trabalhos_tecnicos_marker_payloads(
306
  "lon": lon,
307
  "tooltip_html": detalhes_html,
308
  "popup_html": detalhes_html,
 
309
  "marker_html": marker_html,
310
  "marker_style": "estrela",
311
  "ignore_bounds": bool(ignore_bounds),
@@ -327,6 +329,7 @@ def build_trabalhos_tecnicos_marker_payloads(
327
  "lon": lon,
328
  "tooltip_html": detalhes_html,
329
  "popup_html": detalhes_html,
 
330
  "marker_html": marker_html,
331
  "marker_style": "ponto",
332
  "ignore_bounds": bool(ignore_bounds),
 
231
  endereco = str(item.get("endereco") or "").strip()
232
  numero = str(item.get("numero") or "").strip()
233
  modelos = [str(valor).strip() for valor in (item.get("modelos_relacionados") or []) if str(valor).strip()]
234
+ modelos_origem_ids = [str(valor).strip() for valor in (item.get("modelos_origem_ids") or []) if str(valor).strip()]
235
 
236
  endereco_parts = []
237
  if endereco:
 
307
  "lon": lon,
308
  "tooltip_html": detalhes_html,
309
  "popup_html": detalhes_html,
310
+ "source_overlay_ids": modelos_origem_ids,
311
  "marker_html": marker_html,
312
  "marker_style": "estrela",
313
  "ignore_bounds": bool(ignore_bounds),
 
329
  "lon": lon,
330
  "tooltip_html": detalhes_html,
331
  "popup_html": detalhes_html,
332
+ "source_overlay_ids": modelos_origem_ids,
333
  "marker_html": marker_html,
334
  "marker_style": "ponto",
335
  "ignore_bounds": bool(ignore_bounds),
backend/app/core/visualizacao/app.py CHANGED
@@ -929,7 +929,7 @@ def criar_mapa(
929
  camada_indices.add_to(mapa)
930
 
931
  if avaliandos_tecnicos:
932
- camada_trabalhos_tecnicos = folium.FeatureGroup(name="Avaliandos", show=True)
933
  add_trabalhos_tecnicos_markers(camada_trabalhos_tecnicos, avaliandos_tecnicos)
934
  camada_trabalhos_tecnicos.add_to(mapa)
935
 
 
929
  camada_indices.add_to(mapa)
930
 
931
  if avaliandos_tecnicos:
932
+ camada_trabalhos_tecnicos = folium.FeatureGroup(name="Trabalhos tecnicos", show=True)
933
  add_trabalhos_tecnicos_markers(camada_trabalhos_tecnicos, avaliandos_tecnicos)
934
  camada_trabalhos_tecnicos.add_to(mapa)
935
 
backend/app/services/elaboracao_service.py CHANGED
@@ -2248,7 +2248,11 @@ def calcular_avaliacao_elaboracao(
2248
  }
2249
 
2250
 
2251
- def detalhes_knn_avaliacao_elaboracao(session: SessionState, valores_x: dict[str, Any]) -> dict[str, Any]:
 
 
 
 
2252
  if session.resultado_modelo is None:
2253
  raise HTTPException(status_code=400, detail="Ajuste um modelo primeiro")
2254
  if session.tabela_estatisticas is None:
@@ -2258,12 +2262,31 @@ def detalhes_knn_avaliacao_elaboracao(session: SessionState, valores_x: dict[str
2258
  if not colunas_x:
2259
  raise HTTPException(status_code=400, detail="Modelo sem variaveis")
2260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2261
  entradas: dict[str, float] = {}
2262
  for col in colunas_x:
2263
- if col not in valores_x:
2264
  raise HTTPException(status_code=400, detail=f"Valor ausente para {col}")
2265
  try:
2266
- entradas[col] = float(valores_x[col])
2267
  except Exception as exc:
2268
  raise HTTPException(status_code=400, detail=f"Valor invalido para {col}") from exc
2269
 
@@ -2288,7 +2311,7 @@ def detalhes_knn_avaliacao_elaboracao(session: SessionState, valores_x: dict[str
2288
  if col in session.percentuais and (valor < -PERCENTUAL_RUIDO_TOL or valor > 1 + PERCENTUAL_RUIDO_TOL):
2289
  raise HTTPException(status_code=400, detail=f"{col} aceita valores entre 0 e 1")
2290
 
2291
- valor_area = _resolver_valor_area_avaliacao(valores_x, session.coluna_area)
2292
  if session.tipo_y and valor_area is None:
2293
  raise HTTPException(status_code=400, detail="Informe um valor de area maior que 1 para a coluna configurada.")
2294
 
@@ -2299,12 +2322,12 @@ def detalhes_knn_avaliacao_elaboracao(session: SessionState, valores_x: dict[str
2299
  coluna_y_knn = str(session.coluna_y or session.resultado_modelo.get("coluna_y") or "").strip()
2300
  resultado_knn = enriquecer_resultado_knn_com_area(
2301
  estimar_valor_knn_avaliacao(
2302
- df_base=df_knn,
2303
- coluna_y=coluna_y_knn,
2304
- colunas_x=colunas_x,
2305
- valores_x=entradas,
2306
- alpha_geo=0.35,
2307
- retornar_detalhes=True,
2308
  ),
2309
  tipo_y=session.tipo_y,
2310
  coluna_area=session.coluna_area,
 
2248
  }
2249
 
2250
 
2251
+ def detalhes_knn_avaliacao_elaboracao(
2252
+ session: SessionState,
2253
+ valores_x: dict[str, Any] | None = None,
2254
+ indice_avaliacao: int | None = None,
2255
+ ) -> dict[str, Any]:
2256
  if session.resultado_modelo is None:
2257
  raise HTTPException(status_code=400, detail="Ajuste um modelo primeiro")
2258
  if session.tabela_estatisticas is None:
 
2262
  if not colunas_x:
2263
  raise HTTPException(status_code=400, detail="Modelo sem variaveis")
2264
 
2265
+ valores_resolvidos: dict[str, Any] = {}
2266
+ idx_avaliacao = (int(indice_avaliacao) - 1) if indice_avaliacao is not None else None
2267
+ if idx_avaliacao is not None and 0 <= idx_avaliacao < len(session.avaliacoes_elaboracao):
2268
+ avaliacao_sessao = session.avaliacoes_elaboracao[idx_avaliacao]
2269
+ if isinstance(avaliacao_sessao, dict):
2270
+ valores_resolvidos.update(avaliacao_sessao.get("valores_x") or {})
2271
+ coluna_area_sessao = str(avaliacao_sessao.get("coluna_area") or "").strip()
2272
+ valor_area_sessao = avaliacao_sessao.get("valor_area")
2273
+ if (
2274
+ coluna_area_sessao
2275
+ and coluna_area_sessao not in valores_resolvidos
2276
+ and valor_area_sessao is not None
2277
+ ):
2278
+ valores_resolvidos[coluna_area_sessao] = valor_area_sessao
2279
+ if isinstance(valores_x, dict):
2280
+ valores_resolvidos.update(valores_x)
2281
+ if not valores_resolvidos:
2282
+ raise HTTPException(status_code=400, detail="Avaliacao selecionada indisponivel para detalhamento do KNN.")
2283
+
2284
  entradas: dict[str, float] = {}
2285
  for col in colunas_x:
2286
+ if col not in valores_resolvidos:
2287
  raise HTTPException(status_code=400, detail=f"Valor ausente para {col}")
2288
  try:
2289
+ entradas[col] = float(valores_resolvidos[col])
2290
  except Exception as exc:
2291
  raise HTTPException(status_code=400, detail=f"Valor invalido para {col}") from exc
2292
 
 
2311
  if col in session.percentuais and (valor < -PERCENTUAL_RUIDO_TOL or valor > 1 + PERCENTUAL_RUIDO_TOL):
2312
  raise HTTPException(status_code=400, detail=f"{col} aceita valores entre 0 e 1")
2313
 
2314
+ valor_area = _resolver_valor_area_avaliacao(valores_resolvidos, session.coluna_area)
2315
  if session.tipo_y and valor_area is None:
2316
  raise HTTPException(status_code=400, detail="Informe um valor de area maior que 1 para a coluna configurada.")
2317
 
 
2322
  coluna_y_knn = str(session.coluna_y or session.resultado_modelo.get("coluna_y") or "").strip()
2323
  resultado_knn = enriquecer_resultado_knn_com_area(
2324
  estimar_valor_knn_avaliacao(
2325
+ df_base=df_knn,
2326
+ coluna_y=coluna_y_knn,
2327
+ colunas_x=colunas_x,
2328
+ valores_x=entradas,
2329
+ alpha_geo=0.35,
2330
+ retornar_detalhes=True,
2331
  ),
2332
  tipo_y=session.tipo_y,
2333
  coluna_area=session.coluna_area,
backend/app/services/pesquisa_service.py CHANGED
@@ -876,6 +876,44 @@ def _listar_avaliandos_tecnicos_proximos_por_avaliandos(
876
  return sanitize_value(consolidado)
877
 
878
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
879
  def gerar_mapa_modelos(
880
  modelos_ids: list[str],
881
  limite_pontos_por_modelo: int = 0,
@@ -1193,12 +1231,16 @@ def _marker_payloads_avaliandos(avaliandos_geo: list[dict[str, Any]]) -> list[di
1193
  marker_html = (
1194
  "<div style='display:flex;align-items:center;justify-content:center;"
1195
  "width:30px;height:30px;filter:drop-shadow(0 2px 6px rgba(0,0,0,0.22));'>"
1196
- "<svg viewBox='0 0 24 24' width='28' height='28' aria-hidden='true'>"
 
 
 
1197
  "<path d='M12 3.2L3.9 10v10.1h5.4v-5.3h5.4v5.3h5.4V10L12 3.2Z' "
1198
- "fill='#c62828' stroke='rgba(255,255,255,0.96)' stroke-width='1.5' stroke-linejoin='round'/>"
1199
  "<rect x='10.1' y='15.2' width='3.8' height='4.9' rx='0.5' fill='rgba(255,255,255,0.96)'/>"
1200
  "</svg>"
1201
  "</div>"
 
1202
  )
1203
  payloads.append(
1204
  {
@@ -1299,13 +1341,13 @@ def _build_mapa_modelos_payload(
1299
  avaliando_unico = avaliandos_geo[0] if len(avaliandos_geo) == 1 else None
1300
  aval_lat = avaliando_unico.get("lat") if avaliando_unico is not None else None
1301
  aval_lon = avaliando_unico.get("lon") if avaliando_unico is not None else None
 
1302
 
1303
  for modelo in modelos_plotados:
1304
  layer: dict[str, Any] = {
1305
  "id": str(modelo.get("id") or ""),
1306
  "label": str(modelo.get("nome") or "Modelo"),
1307
  "show": True,
1308
- "markers": build_trabalhos_tecnicos_marker_payloads(modelo.get("avaliandos_tecnicos") or []),
1309
  }
1310
  if modo_exibicao == "pontos":
1311
  tooltip_html = _tooltip_mapa_modelo_html(modelo)
@@ -1326,12 +1368,23 @@ def _build_mapa_modelos_payload(
1326
  layer["shapes"] = _shapes_modelo_payload(modelo, aval_lat, aval_lon)
1327
  overlay_layers.append(layer)
1328
 
 
 
 
 
 
 
 
 
 
 
1329
  if avaliandos_tecnicos_proximos is not None:
1330
  label_raio = f" (até {int(trabalhos_tecnicos_raio_m or 0)} m)" if trabalhos_tecnicos_raio_m is not None else ""
1331
  proximos_layer: dict[str, Any] = {
1332
  "id": "trabalhos-tecnicos-proximos",
1333
  "label": f"Trabalhos técnicos próximos{label_raio}",
1334
  "show": True,
 
1335
  "markers": build_trabalhos_tecnicos_marker_payloads(
1336
  avaliandos_tecnicos_proximos or [],
1337
  origem="pesquisa_mapa",
@@ -1409,6 +1462,12 @@ def _renderizar_mapa_modelos(
1409
  add_bairros_layer(mapa, show=True)
1410
  nome_camada_avaliando = "Avaliando" if len(avaliandos_geo) == 1 else "Avaliandos"
1411
  camada_avaliando = folium.FeatureGroup(name=nome_camada_avaliando, show=True)
 
 
 
 
 
 
1412
  camada_trabalhos_proximos = None
1413
  if avaliandos_tecnicos_proximos is not None:
1414
  label_raio = f" (ate {int(trabalhos_tecnicos_raio_m or 0)} m)" if trabalhos_tecnicos_raio_m is not None else ""
@@ -1451,10 +1510,12 @@ def _renderizar_mapa_modelos(
1451
 
1452
  if renderizar_cobertura:
1453
  _adicionar_geometria_modelo_no_mapa(camada_modelo, camada_modelo, modelo, aval_lat, aval_lon)
1454
-
1455
- add_trabalhos_tecnicos_markers(camada_modelo, modelo.get("avaliandos_tecnicos") or [])
1456
  camada_modelo.add_to(mapa)
1457
 
 
 
 
 
1458
  if camada_trabalhos_proximos is not None:
1459
  if int(trabalhos_tecnicos_raio_m or 0) > 0:
1460
  for idx, avaliando in enumerate(avaliandos_geo):
@@ -1495,14 +1556,20 @@ def _renderizar_mapa_modelos(
1495
  marker_icon = folium.DivIcon(
1496
  html=(
1497
  "<div style='display:flex;align-items:center;justify-content:center;"
1498
- "width:28px;height:28px;border-radius:999px;background:#c62828;color:#fff;"
1499
- "font-weight:800;font-size:12px;border:2px solid rgba(255,255,255,0.95);"
1500
- "box-shadow:0 2px 6px rgba(0,0,0,0.18);'>"
1501
- f"{escape(label)}"
 
 
 
 
 
 
1502
  "</div>"
1503
  ),
1504
- icon_size=(28, 28),
1505
- icon_anchor=(14, 14),
1506
  class_name="mesa-avaliando-marker",
1507
  )
1508
  folium.Marker(
 
876
  return sanitize_value(consolidado)
877
 
878
 
879
+ def _consolidar_trabalhos_tecnicos_modelos(modelos_plotados: list[dict[str, Any]]) -> list[dict[str, Any]]:
880
+ agregados: dict[tuple[str, str, str, str, str, str], dict[str, Any]] = {}
881
+
882
+ for modelo in modelos_plotados or []:
883
+ modelo_id = str(modelo.get("id") or "").strip()
884
+ for item in (modelo.get("avaliandos_tecnicos") or []):
885
+ if not isinstance(item, dict):
886
+ continue
887
+ chave = _chave_unica_trabalho_tecnico(item)
888
+ atual = agregados.get(chave)
889
+ if atual is None:
890
+ novo_item = dict(item)
891
+ novo_item["modelos_relacionados"] = [str(valor).strip() for valor in (item.get("modelos_relacionados") or []) if str(valor).strip()]
892
+ novo_item["modelos_origem_ids"] = [modelo_id] if modelo_id else []
893
+ agregados[chave] = novo_item
894
+ continue
895
+
896
+ _append_aliases_unicos(
897
+ atual.setdefault("modelos_relacionados", []),
898
+ *[str(valor).strip() for valor in (item.get("modelos_relacionados") or []) if str(valor).strip()],
899
+ )
900
+ if modelo_id:
901
+ _append_aliases_unicos(atual.setdefault("modelos_origem_ids", []), modelo_id)
902
+ for campo in ("trabalho_nome", "tipo_label", "label", "endereco", "numero"):
903
+ if not str(atual.get(campo) or "").strip() and str(item.get(campo) or "").strip():
904
+ atual[campo] = item.get(campo)
905
+
906
+ consolidado = list(agregados.values())
907
+ consolidado.sort(
908
+ key=lambda item: (
909
+ str(item.get("trabalho_nome") or "").casefold(),
910
+ str(item.get("label") or item.get("endereco") or "").casefold(),
911
+ str(item.get("numero") or "").casefold(),
912
+ )
913
+ )
914
+ return sanitize_value(consolidado)
915
+
916
+
917
  def gerar_mapa_modelos(
918
  modelos_ids: list[str],
919
  limite_pontos_por_modelo: int = 0,
 
1231
  marker_html = (
1232
  "<div style='display:flex;align-items:center;justify-content:center;"
1233
  "width:30px;height:30px;filter:drop-shadow(0 2px 6px rgba(0,0,0,0.22));'>"
1234
+ "<div style='display:flex;align-items:center;justify-content:center;"
1235
+ "width:24px;height:24px;border-radius:999px;background:rgba(255,255,255,0.97);"
1236
+ "box-shadow:0 0 0 1.4px rgba(255,255,255,0.98),0 0 0 2.5px rgba(0,0,0,0.74);'>"
1237
+ "<svg viewBox='0 0 24 24' width='20' height='20' aria-hidden='true'>"
1238
  "<path d='M12 3.2L3.9 10v10.1h5.4v-5.3h5.4v5.3h5.4V10L12 3.2Z' "
1239
+ "fill='#c62828' stroke='rgba(255,255,255,0.96)' stroke-width='1.2' stroke-linejoin='round'/>"
1240
  "<rect x='10.1' y='15.2' width='3.8' height='4.9' rx='0.5' fill='rgba(255,255,255,0.96)'/>"
1241
  "</svg>"
1242
  "</div>"
1243
+ "</div>"
1244
  )
1245
  payloads.append(
1246
  {
 
1341
  avaliando_unico = avaliandos_geo[0] if len(avaliandos_geo) == 1 else None
1342
  aval_lat = avaliando_unico.get("lat") if avaliando_unico is not None else None
1343
  aval_lon = avaliando_unico.get("lon") if avaliando_unico is not None else None
1344
+ trabalhos_tecnicos_modelos = _consolidar_trabalhos_tecnicos_modelos(modelos_plotados)
1345
 
1346
  for modelo in modelos_plotados:
1347
  layer: dict[str, Any] = {
1348
  "id": str(modelo.get("id") or ""),
1349
  "label": str(modelo.get("nome") or "Modelo"),
1350
  "show": True,
 
1351
  }
1352
  if modo_exibicao == "pontos":
1353
  tooltip_html = _tooltip_mapa_modelo_html(modelo)
 
1368
  layer["shapes"] = _shapes_modelo_payload(modelo, aval_lat, aval_lon)
1369
  overlay_layers.append(layer)
1370
 
1371
+ if trabalhos_tecnicos_modelos:
1372
+ overlay_layers.append(
1373
+ {
1374
+ "id": "trabalhos-tecnicos-modelos",
1375
+ "label": "Trabalhos tecnicos dos modelos",
1376
+ "show": True,
1377
+ "markers": build_trabalhos_tecnicos_marker_payloads(trabalhos_tecnicos_modelos),
1378
+ }
1379
+ )
1380
+
1381
  if avaliandos_tecnicos_proximos is not None:
1382
  label_raio = f" (até {int(trabalhos_tecnicos_raio_m or 0)} m)" if trabalhos_tecnicos_raio_m is not None else ""
1383
  proximos_layer: dict[str, Any] = {
1384
  "id": "trabalhos-tecnicos-proximos",
1385
  "label": f"Trabalhos técnicos próximos{label_raio}",
1386
  "show": True,
1387
+ "required_overlay_ids": ["trabalhos-tecnicos-modelos"],
1388
  "markers": build_trabalhos_tecnicos_marker_payloads(
1389
  avaliandos_tecnicos_proximos or [],
1390
  origem="pesquisa_mapa",
 
1462
  add_bairros_layer(mapa, show=True)
1463
  nome_camada_avaliando = "Avaliando" if len(avaliandos_geo) == 1 else "Avaliandos"
1464
  camada_avaliando = folium.FeatureGroup(name=nome_camada_avaliando, show=True)
1465
+ trabalhos_tecnicos_modelos = _consolidar_trabalhos_tecnicos_modelos(modelos_plotados)
1466
+ camada_trabalhos_modelos = (
1467
+ folium.FeatureGroup(name="Trabalhos tecnicos dos modelos", show=True)
1468
+ if trabalhos_tecnicos_modelos
1469
+ else None
1470
+ )
1471
  camada_trabalhos_proximos = None
1472
  if avaliandos_tecnicos_proximos is not None:
1473
  label_raio = f" (ate {int(trabalhos_tecnicos_raio_m or 0)} m)" if trabalhos_tecnicos_raio_m is not None else ""
 
1510
 
1511
  if renderizar_cobertura:
1512
  _adicionar_geometria_modelo_no_mapa(camada_modelo, camada_modelo, modelo, aval_lat, aval_lon)
 
 
1513
  camada_modelo.add_to(mapa)
1514
 
1515
+ if camada_trabalhos_modelos is not None:
1516
+ add_trabalhos_tecnicos_markers(camada_trabalhos_modelos, trabalhos_tecnicos_modelos)
1517
+ camada_trabalhos_modelos.add_to(mapa)
1518
+
1519
  if camada_trabalhos_proximos is not None:
1520
  if int(trabalhos_tecnicos_raio_m or 0) > 0:
1521
  for idx, avaliando in enumerate(avaliandos_geo):
 
1556
  marker_icon = folium.DivIcon(
1557
  html=(
1558
  "<div style='display:flex;align-items:center;justify-content:center;"
1559
+ "width:30px;height:30px;filter:drop-shadow(0 2px 6px rgba(0,0,0,0.22));'>"
1560
+ "<div style='display:flex;align-items:center;justify-content:center;"
1561
+ "width:24px;height:24px;border-radius:999px;background:rgba(255,255,255,0.97);"
1562
+ "box-shadow:0 0 0 1.4px rgba(255,255,255,0.98),0 0 0 2.5px rgba(0,0,0,0.74);'>"
1563
+ "<svg viewBox='0 0 24 24' width='20' height='20' aria-hidden='true'>"
1564
+ "<path d='M12 3.2L3.9 10v10.1h5.4v-5.3h5.4v5.3h5.4V10L12 3.2Z' "
1565
+ "fill='#c62828' stroke='rgba(255,255,255,0.96)' stroke-width='1.2' stroke-linejoin='round'/>"
1566
+ "<rect x='10.1' y='15.2' width='3.8' height='4.9' rx='0.5' fill='rgba(255,255,255,0.96)'/>"
1567
+ "</svg>"
1568
+ "</div>"
1569
  "</div>"
1570
  ),
1571
+ icon_size=(30, 30),
1572
+ icon_anchor=(15, 15),
1573
  class_name="mesa-avaliando-marker",
1574
  )
1575
  folium.Marker(
backend/app/services/trabalhos_tecnicos_importer.py CHANGED
@@ -13,7 +13,11 @@ from typing import Any
13
 
14
  DEFAULT_SOURCE_XLSX_FILE = "dados_geocodificados_limpos_v2.xlsx"
15
  DEFAULT_LOCAL_DB_FILE = "trabalhos_tecnicos.sqlite3"
16
- COLUNAS_ESPERADAS = ["ANO", "AVALIANDO", "MODELO", "ENDERECO", "NUM", "x", "y"]
 
 
 
 
17
  TIPO_LABELS = {
18
  "LA": "Laudo de Avaliacao",
19
  "PT": "Parecer Tecnico",
@@ -48,9 +52,14 @@ XLSX_NS = {
48
  class TrabalhoRawGroup:
49
  raw_name: str
50
  ano: int | None = None
 
 
51
  endereco_base: str = ""
52
  numero_base: str = ""
53
  first_row_number: int = 0
 
 
 
54
  modelos: dict[str, int] = field(default_factory=dict)
55
  imoveis: dict[tuple[str, str, str, str], dict[str, Any]] = field(default_factory=dict)
56
  registros: list[dict[str, Any]] = field(default_factory=list)
@@ -69,6 +78,14 @@ def _clean_text(value: Any) -> str:
69
  return text
70
 
71
 
 
 
 
 
 
 
 
 
72
  def _to_int(value: Any) -> int | None:
73
  text = _clean_text(value)
74
  if not text:
@@ -215,24 +232,41 @@ def read_xlsx_rows(path: str | Path) -> tuple[str, list[dict[str, Any]]]:
215
 
216
 
217
  def _build_groups(records: list[dict[str, Any]]) -> tuple[list[TrabalhoRawGroup], int]:
218
- missing_columns = [column for column in COLUNAS_ESPERADAS if not records or column not in records[0]]
 
 
 
 
219
  if missing_columns:
220
  raise ValueError(f"Planilha sem colunas obrigatorias: {', '.join(missing_columns)}")
 
 
 
 
 
221
 
222
  groups: dict[str, TrabalhoRawGroup] = {}
223
  invalid_rows = 0
224
 
225
  for offset, record in enumerate(records, start=2):
226
- raw_name = _clean_text(record.get("AVALIANDO"))
 
 
227
  if not raw_name:
228
  invalid_rows += 1
229
  continue
230
 
 
 
 
 
231
  group = groups.get(raw_name)
232
  if group is None:
233
  group = TrabalhoRawGroup(
234
  raw_name=raw_name,
235
  ano=_to_int(record.get("ANO")),
 
 
236
  endereco_base=_clean_text(record.get("ENDERECO")),
237
  numero_base=_clean_text(record.get("NUM")),
238
  first_row_number=offset,
@@ -241,6 +275,10 @@ def _build_groups(records: list[dict[str, Any]]) -> tuple[list[TrabalhoRawGroup]
241
  else:
242
  if group.ano is None:
243
  group.ano = _to_int(record.get("ANO"))
 
 
 
 
244
  if not group.endereco_base:
245
  group.endereco_base = _clean_text(record.get("ENDERECO"))
246
  if not group.numero_base:
@@ -252,6 +290,12 @@ def _build_groups(records: list[dict[str, Any]]) -> tuple[list[TrabalhoRawGroup]
252
  coord_x = _to_float(record.get("x"))
253
  coord_y = _to_float(record.get("y"))
254
 
 
 
 
 
 
 
255
  if modelo_nome:
256
  group.modelos.setdefault(modelo_nome, len(group.modelos) + 1)
257
 
@@ -280,6 +324,11 @@ def _build_groups(records: list[dict[str, Any]]) -> tuple[list[TrabalhoRawGroup]
280
  "source_row": offset,
281
  "ano": _to_int(record.get("ANO")),
282
  "nome_original": raw_name,
 
 
 
 
 
283
  "modelo_nome": modelo_nome,
284
  "endereco": endereco,
285
  "numero": numero or "S/N",
@@ -292,9 +341,9 @@ def _build_groups(records: list[dict[str, Any]]) -> tuple[list[TrabalhoRawGroup]
292
 
293
  used_names: dict[str, str] = {}
294
  for group in ordered_groups:
295
- base_name = build_clean_trabalho_name(group.raw_name, group.endereco_base, group.numero_base)
296
  if not base_name:
297
- base_name = _slugify(group.raw_name) or f"TRABALHO_{group.first_row_number}"
298
  candidate = base_name
299
  suffix = 2
300
  while candidate in used_names and used_names[candidate] != group.raw_name:
@@ -306,6 +355,15 @@ def _build_groups(records: list[dict[str, Any]]) -> tuple[list[TrabalhoRawGroup]
306
  return ordered_groups, invalid_rows
307
 
308
 
 
 
 
 
 
 
 
 
 
309
  def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict[str, Any]:
310
  source_path = Path(xlsx_path).expanduser().resolve()
311
  target_path = Path(db_path).expanduser().resolve()
@@ -333,6 +391,8 @@ def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict
333
  trabalho_id TEXT PRIMARY KEY,
334
  nome TEXT NOT NULL,
335
  nome_original TEXT NOT NULL,
 
 
336
  tipo_codigo TEXT NOT NULL,
337
  tipo_label TEXT NOT NULL,
338
  ano INTEGER,
@@ -340,6 +400,9 @@ def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict
340
  numero_principal TEXT,
341
  endereco_resumo TEXT,
342
  modelo_resumo TEXT,
 
 
 
343
  total_registros INTEGER NOT NULL,
344
  total_imoveis INTEGER NOT NULL,
345
  total_modelos INTEGER NOT NULL,
@@ -375,6 +438,11 @@ def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict
375
  source_row INTEGER NOT NULL,
376
  ano INTEGER,
377
  nome_original TEXT NOT NULL,
 
 
 
 
 
378
  modelo_nome TEXT,
379
  endereco TEXT,
380
  numero TEXT,
@@ -398,7 +466,7 @@ def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict
398
  "invalid_row_count": str(invalid_rows),
399
  "total_trabalhos": str(len(groups)),
400
  "generated_at_utc": imported_at,
401
- "generator_version": "sqlite-v1",
402
  }
403
  conn.executemany(
404
  "INSERT INTO meta (key, value) VALUES (?, ?)",
@@ -417,6 +485,9 @@ def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict
417
  modelo_resumo = modelos_ordenados[0][0]
418
  elif len(modelos_ordenados) > 1:
419
  modelo_resumo = f"{len(modelos_ordenados)} modelos vinculados"
 
 
 
420
 
421
  conn.execute(
422
  """
@@ -424,6 +495,8 @@ def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict
424
  trabalho_id,
425
  nome,
426
  nome_original,
 
 
427
  tipo_codigo,
428
  tipo_label,
429
  ano,
@@ -431,16 +504,21 @@ def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict
431
  numero_principal,
432
  endereco_resumo,
433
  modelo_resumo,
 
 
 
434
  total_registros,
435
  total_imoveis,
436
  total_modelos,
437
  tem_coordenadas
438
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
439
  """,
440
  (
441
  group.clean_name,
442
  group.clean_name,
443
  group.raw_name,
 
 
444
  tipo_codigo,
445
  _tipo_label(tipo_codigo),
446
  group.ano,
@@ -448,6 +526,9 @@ def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict
448
  group.numero_base or "S/N",
449
  endereco_resumo,
450
  modelo_resumo,
 
 
 
451
  len(group.registros),
452
  len(imoveis_ordenados),
453
  len(modelos_ordenados),
@@ -491,18 +572,28 @@ def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict
491
  source_row,
492
  ano,
493
  nome_original,
 
 
 
 
 
494
  modelo_nome,
495
  endereco,
496
  numero,
497
  coord_x,
498
  coord_y
499
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
500
  """,
501
  (
502
  group.clean_name,
503
  registro["source_row"],
504
  registro["ano"],
505
  registro["nome_original"],
 
 
 
 
 
506
  registro["modelo_nome"],
507
  registro["endereco"],
508
  registro["numero"],
 
13
 
14
  DEFAULT_SOURCE_XLSX_FILE = "dados_geocodificados_limpos_v2.xlsx"
15
  DEFAULT_LOCAL_DB_FILE = "trabalhos_tecnicos.sqlite3"
16
+ COLUNAS_BASE_OBRIGATORIAS = ["ANO", "MODELO", "ENDERECO", "NUM", "x", "y"]
17
+ COLUNAS_IDENTIFICADORAS = ["NOME_PASTA", "AVALIANDO", "TRABALHO"]
18
+ COLUNAS_TECNICO = ["TÉCNICO", "TECNICO"]
19
+ COLUNA_PROCESSO = "PROCESSO"
20
+ COLUNA_FINALIDADE_PROCESSO = "FINALIDADE PROCESSO"
21
  TIPO_LABELS = {
22
  "LA": "Laudo de Avaliacao",
23
  "PT": "Parecer Tecnico",
 
52
  class TrabalhoRawGroup:
53
  raw_name: str
54
  ano: int | None = None
55
+ codigo_trabalho: str = ""
56
+ nome_pasta: str = ""
57
  endereco_base: str = ""
58
  numero_base: str = ""
59
  first_row_number: int = 0
60
+ tecnicos: dict[str, int] = field(default_factory=dict)
61
+ processos: dict[str, int] = field(default_factory=dict)
62
+ finalidades_processo: dict[str, int] = field(default_factory=dict)
63
  modelos: dict[str, int] = field(default_factory=dict)
64
  imoveis: dict[tuple[str, str, str, str], dict[str, Any]] = field(default_factory=dict)
65
  registros: list[dict[str, Any]] = field(default_factory=list)
 
78
  return text
79
 
80
 
81
+ def _first_non_empty(record: dict[str, Any], *keys: str) -> str:
82
+ for key in keys:
83
+ text = _clean_text(record.get(key))
84
+ if text:
85
+ return text
86
+ return ""
87
+
88
+
89
  def _to_int(value: Any) -> int | None:
90
  text = _clean_text(value)
91
  if not text:
 
232
 
233
 
234
  def _build_groups(records: list[dict[str, Any]]) -> tuple[list[TrabalhoRawGroup], int]:
235
+ if not records:
236
+ raise ValueError("Planilha sem linhas de dados")
237
+
238
+ available_columns = set(records[0].keys())
239
+ missing_columns = [column for column in COLUNAS_BASE_OBRIGATORIAS if column not in available_columns]
240
  if missing_columns:
241
  raise ValueError(f"Planilha sem colunas obrigatorias: {', '.join(missing_columns)}")
242
+ if not any(column in available_columns for column in COLUNAS_IDENTIFICADORAS):
243
+ raise ValueError(
244
+ "Planilha sem coluna identificadora obrigatoria: informe ao menos uma entre "
245
+ + ", ".join(COLUNAS_IDENTIFICADORAS)
246
+ )
247
 
248
  groups: dict[str, TrabalhoRawGroup] = {}
249
  invalid_rows = 0
250
 
251
  for offset, record in enumerate(records, start=2):
252
+ nome_pasta = _clean_text(record.get("NOME_PASTA"))
253
+ codigo_trabalho = _clean_text(record.get("TRABALHO"))
254
+ raw_name = _first_non_empty(record, "NOME_PASTA", "AVALIANDO", "TRABALHO")
255
  if not raw_name:
256
  invalid_rows += 1
257
  continue
258
 
259
+ tecnico = _first_non_empty(record, *COLUNAS_TECNICO)
260
+ processo = _clean_text(record.get(COLUNA_PROCESSO))
261
+ finalidade_processo = _clean_text(record.get(COLUNA_FINALIDADE_PROCESSO))
262
+
263
  group = groups.get(raw_name)
264
  if group is None:
265
  group = TrabalhoRawGroup(
266
  raw_name=raw_name,
267
  ano=_to_int(record.get("ANO")),
268
+ codigo_trabalho=codigo_trabalho,
269
+ nome_pasta=nome_pasta,
270
  endereco_base=_clean_text(record.get("ENDERECO")),
271
  numero_base=_clean_text(record.get("NUM")),
272
  first_row_number=offset,
 
275
  else:
276
  if group.ano is None:
277
  group.ano = _to_int(record.get("ANO"))
278
+ if not group.codigo_trabalho:
279
+ group.codigo_trabalho = codigo_trabalho
280
+ if not group.nome_pasta:
281
+ group.nome_pasta = nome_pasta
282
  if not group.endereco_base:
283
  group.endereco_base = _clean_text(record.get("ENDERECO"))
284
  if not group.numero_base:
 
290
  coord_x = _to_float(record.get("x"))
291
  coord_y = _to_float(record.get("y"))
292
 
293
+ if tecnico:
294
+ group.tecnicos.setdefault(tecnico, len(group.tecnicos) + 1)
295
+ if processo:
296
+ group.processos.setdefault(processo, len(group.processos) + 1)
297
+ if finalidade_processo:
298
+ group.finalidades_processo.setdefault(finalidade_processo, len(group.finalidades_processo) + 1)
299
  if modelo_nome:
300
  group.modelos.setdefault(modelo_nome, len(group.modelos) + 1)
301
 
 
324
  "source_row": offset,
325
  "ano": _to_int(record.get("ANO")),
326
  "nome_original": raw_name,
327
+ "codigo_trabalho": codigo_trabalho,
328
+ "nome_pasta": nome_pasta,
329
+ "tecnico": tecnico,
330
+ "processo": processo,
331
+ "finalidade_processo": finalidade_processo,
332
  "modelo_nome": modelo_nome,
333
  "endereco": endereco,
334
  "numero": numero or "S/N",
 
341
 
342
  used_names: dict[str, str] = {}
343
  for group in ordered_groups:
344
+ base_name = build_clean_trabalho_name(group.nome_pasta or group.raw_name, group.endereco_base, group.numero_base)
345
  if not base_name:
346
+ base_name = _slugify(group.nome_pasta or group.raw_name) or f"TRABALHO_{group.first_row_number}"
347
  candidate = base_name
348
  suffix = 2
349
  while candidate in used_names and used_names[candidate] != group.raw_name:
 
355
  return ordered_groups, invalid_rows
356
 
357
 
358
+ def _summarize_ordered_values(values: dict[str, int]) -> str:
359
+ ordered = [value for value, _ in sorted(values.items(), key=lambda item: (item[1], item[0].lower()))]
360
+ if not ordered:
361
+ return ""
362
+ if len(ordered) == 1:
363
+ return ordered[0]
364
+ return f"{ordered[0]} (+{len(ordered) - 1})"
365
+
366
+
367
  def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict[str, Any]:
368
  source_path = Path(xlsx_path).expanduser().resolve()
369
  target_path = Path(db_path).expanduser().resolve()
 
391
  trabalho_id TEXT PRIMARY KEY,
392
  nome TEXT NOT NULL,
393
  nome_original TEXT NOT NULL,
394
+ codigo_trabalho TEXT,
395
+ nome_pasta TEXT,
396
  tipo_codigo TEXT NOT NULL,
397
  tipo_label TEXT NOT NULL,
398
  ano INTEGER,
 
400
  numero_principal TEXT,
401
  endereco_resumo TEXT,
402
  modelo_resumo TEXT,
403
+ tecnico_resumo TEXT,
404
+ processo_resumo TEXT,
405
+ finalidade_processo_resumo TEXT,
406
  total_registros INTEGER NOT NULL,
407
  total_imoveis INTEGER NOT NULL,
408
  total_modelos INTEGER NOT NULL,
 
438
  source_row INTEGER NOT NULL,
439
  ano INTEGER,
440
  nome_original TEXT NOT NULL,
441
+ codigo_trabalho TEXT,
442
+ nome_pasta TEXT,
443
+ tecnico TEXT,
444
+ processo TEXT,
445
+ finalidade_processo TEXT,
446
  modelo_nome TEXT,
447
  endereco TEXT,
448
  numero TEXT,
 
466
  "invalid_row_count": str(invalid_rows),
467
  "total_trabalhos": str(len(groups)),
468
  "generated_at_utc": imported_at,
469
+ "generator_version": "sqlite-v2",
470
  }
471
  conn.executemany(
472
  "INSERT INTO meta (key, value) VALUES (?, ?)",
 
485
  modelo_resumo = modelos_ordenados[0][0]
486
  elif len(modelos_ordenados) > 1:
487
  modelo_resumo = f"{len(modelos_ordenados)} modelos vinculados"
488
+ tecnico_resumo = _summarize_ordered_values(group.tecnicos)
489
+ processo_resumo = _summarize_ordered_values(group.processos)
490
+ finalidade_processo_resumo = _summarize_ordered_values(group.finalidades_processo)
491
 
492
  conn.execute(
493
  """
 
495
  trabalho_id,
496
  nome,
497
  nome_original,
498
+ codigo_trabalho,
499
+ nome_pasta,
500
  tipo_codigo,
501
  tipo_label,
502
  ano,
 
504
  numero_principal,
505
  endereco_resumo,
506
  modelo_resumo,
507
+ tecnico_resumo,
508
+ processo_resumo,
509
+ finalidade_processo_resumo,
510
  total_registros,
511
  total_imoveis,
512
  total_modelos,
513
  tem_coordenadas
514
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
515
  """,
516
  (
517
  group.clean_name,
518
  group.clean_name,
519
  group.raw_name,
520
+ group.codigo_trabalho or None,
521
+ group.nome_pasta or None,
522
  tipo_codigo,
523
  _tipo_label(tipo_codigo),
524
  group.ano,
 
526
  group.numero_base or "S/N",
527
  endereco_resumo,
528
  modelo_resumo,
529
+ tecnico_resumo or None,
530
+ processo_resumo or None,
531
+ finalidade_processo_resumo or None,
532
  len(group.registros),
533
  len(imoveis_ordenados),
534
  len(modelos_ordenados),
 
572
  source_row,
573
  ano,
574
  nome_original,
575
+ codigo_trabalho,
576
+ nome_pasta,
577
+ tecnico,
578
+ processo,
579
+ finalidade_processo,
580
  modelo_nome,
581
  endereco,
582
  numero,
583
  coord_x,
584
  coord_y
585
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
586
  """,
587
  (
588
  group.clean_name,
589
  registro["source_row"],
590
  registro["ano"],
591
  registro["nome_original"],
592
+ registro["codigo_trabalho"] or None,
593
+ registro["nome_pasta"] or None,
594
+ registro["tecnico"] or None,
595
+ registro["processo"] or None,
596
+ registro["finalidade_processo"] or None,
597
  registro["modelo_nome"],
598
  registro["endereco"],
599
  registro["numero"],
backend/app/services/trabalhos_tecnicos_service.py CHANGED
@@ -349,6 +349,80 @@ def _dedupe_text_list(values: Any) -> list[str]:
349
  return resultado
350
 
351
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  def _tipo_label_from_codigo(codigo: Any, fallback: Any = None) -> str:
353
  codigo_texto = str(codigo or "").strip().upper()
354
  if codigo_texto in TIPO_LABELS:
@@ -432,6 +506,7 @@ def _parse_override_payload(payload_json: str | None) -> dict[str, Any]:
432
  "nome": str(raw.get("nome") or "").strip(),
433
  "tipo_codigo": str(raw.get("tipo_codigo") or "").strip().upper(),
434
  "ano": _to_int_or_none(raw.get("ano")),
 
435
  "processos_sei": _dedupe_text_list(raw.get("processos_sei")),
436
  "modelos": modelos,
437
  "imoveis": imoveis,
@@ -456,7 +531,9 @@ def _apply_override_trabalho(
456
  ) -> dict[str, Any]:
457
  if not override:
458
  trabalho = dict(base)
459
- trabalho.setdefault("processos_sei", [])
 
 
460
  return trabalho
461
 
462
  trabalho = dict(base)
@@ -479,7 +556,12 @@ def _apply_override_trabalho(
479
  trabalho["tipo_codigo"] = override.get("tipo_codigo") or trabalho.get("tipo_codigo") or ""
480
  trabalho["tipo_label"] = _tipo_label_from_codigo(trabalho.get("tipo_codigo"), trabalho.get("tipo_label"))
481
  trabalho["ano"] = override.get("ano")
482
- trabalho["processos_sei"] = _dedupe_text_list(override.get("processos_sei"))
 
 
 
 
 
483
  trabalho["imoveis"] = imoveis
484
  trabalho["modelos"] = _enriquecer_modelos(modelos_raw, catalogo_modelos)
485
  trabalho["total_imoveis"] = len(imoveis)
@@ -563,6 +645,7 @@ def _carregar_trabalho_base(
563
  """,
564
  (trabalho_id,),
565
  ).fetchall()
 
566
 
567
  modelos = _enriquecer_modelos([str(row["modelo_nome"]) for row in modelo_rows], catalogo_modelos)
568
  modelos_por_imovel: dict[int, list[str]] = {}
@@ -597,7 +680,8 @@ def _carregar_trabalho_base(
597
  "total_imoveis": int(trabalho_row["total_imoveis"] or 0),
598
  "total_modelos": int(trabalho_row["total_modelos"] or 0),
599
  "tem_coordenadas": bool(trabalho_row["tem_coordenadas"]),
600
- "processos_sei": [],
 
601
  "modelos": modelos,
602
  "imoveis": imoveis,
603
  }
@@ -626,7 +710,10 @@ def _normalizar_edicao_payload(
626
  nome = str(payload.get("nome") or base.get("nome") or "").strip()
627
  tipo_codigo = str(payload.get("tipo_codigo") or base.get("tipo_codigo") or "").strip().upper()
628
  ano = _to_int_or_none(payload.get("ano"))
629
- processos_sei = _dedupe_text_list(payload.get("processos_sei"))
 
 
 
630
 
631
  modelos_informados = _dedupe_text_list(payload.get("modelos"))
632
  if not modelos_informados:
@@ -675,6 +762,7 @@ def _listar_avaliandos_tecnicos(chaves_modelo: list[str] | None = None) -> list[
675
  conn = _connect_database(resolved.db_path)
676
  try:
677
  overrides = _fetch_overrides(conn)
 
678
  rows = conn.execute(
679
  """
680
  SELECT
@@ -737,6 +825,7 @@ def _listar_avaliandos_tecnicos(chaves_modelo: list[str] | None = None) -> list[
737
  "coord_lon": float(coord_x),
738
  "coord_lat": float(coord_y),
739
  "modelos_relacionados": set(),
 
740
  },
741
  )
742
  if modelo_nome:
@@ -1205,6 +1294,7 @@ def listar_trabalhos() -> dict[str, Any]:
1205
  try:
1206
  meta = _fetch_meta(conn)
1207
  overrides = _fetch_overrides(conn)
 
1208
  trabalhos_rows = conn.execute(
1209
  """
1210
  SELECT
@@ -1238,6 +1328,7 @@ def listar_trabalhos() -> dict[str, Any]:
1238
  total_com_modelo_mesa = 0
1239
  for row in trabalhos_rows:
1240
  trabalho_id = str(row["trabalho_id"])
 
1241
  base = {
1242
  "id": trabalho_id,
1243
  "nome": str(row["nome"]),
@@ -1251,7 +1342,8 @@ def listar_trabalhos() -> dict[str, Any]:
1251
  "total_imoveis": int(row["total_imoveis"] or 0),
1252
  "total_modelos": int(row["total_modelos"] or 0),
1253
  "tem_coordenadas": bool(row["tem_coordenadas"]),
1254
- "processos_sei": [],
 
1255
  "modelos": _enriquecer_modelos(modelos_por_trabalho.get(trabalho_id, []), catalogo_modelos),
1256
  "imoveis": [],
1257
  }
 
349
  return resultado
350
 
351
 
352
+ def _table_has_column(conn: sqlite3.Connection, table_name: str, column_name: str) -> bool:
353
+ try:
354
+ rows = conn.execute(f"PRAGMA table_info({table_name})").fetchall()
355
+ except Exception:
356
+ return False
357
+ column_name_norm = str(column_name or "").strip().casefold()
358
+ return any(str(row["name"] or "").strip().casefold() == column_name_norm for row in rows)
359
+
360
+
361
+ def _resumo_textos(valores: list[str], limite_inline: int = 2) -> str:
362
+ unicos = _dedupe_text_list(valores)
363
+ if not unicos:
364
+ return ""
365
+ if len(unicos) <= limite_inline:
366
+ return " | ".join(unicos)
367
+ return " | ".join(unicos[:limite_inline]) + f" | +{len(unicos) - limite_inline}"
368
+
369
+
370
+ def _carregar_processos_importados_por_trabalho(
371
+ conn: sqlite3.Connection,
372
+ trabalho_id: str | None = None,
373
+ ) -> dict[str, list[str]]:
374
+ processos_por_trabalho: dict[str, list[str]] = {}
375
+
376
+ if _table_has_column(conn, "trabalho_registros", "processo"):
377
+ params: list[Any] = []
378
+ where_clause = "WHERE TRIM(COALESCE(processo, '')) <> ''"
379
+ if trabalho_id:
380
+ where_clause += " AND trabalho_id = ?"
381
+ params.append(trabalho_id)
382
+ rows = conn.execute(
383
+ f"""
384
+ SELECT trabalho_id, processo
385
+ FROM trabalho_registros
386
+ {where_clause}
387
+ ORDER BY trabalho_id, source_row, LOWER(TRIM(processo))
388
+ """,
389
+ params,
390
+ ).fetchall()
391
+ for row in rows:
392
+ chave = str(row["trabalho_id"] or "").strip()
393
+ processo = str(row["processo"] or "").strip()
394
+ if not chave or not processo:
395
+ continue
396
+ processos_por_trabalho.setdefault(chave, []).append(processo)
397
+
398
+ if processos_por_trabalho:
399
+ return {chave: _dedupe_text_list(valores) for chave, valores in processos_por_trabalho.items()}
400
+
401
+ if _table_has_column(conn, "trabalhos", "processo_resumo"):
402
+ params = []
403
+ where_clause = "WHERE TRIM(COALESCE(processo_resumo, '')) <> ''"
404
+ if trabalho_id:
405
+ where_clause += " AND trabalho_id = ?"
406
+ params.append(trabalho_id)
407
+ rows = conn.execute(
408
+ f"""
409
+ SELECT trabalho_id, processo_resumo
410
+ FROM trabalhos
411
+ {where_clause}
412
+ ORDER BY LOWER(trabalho_id)
413
+ """,
414
+ params,
415
+ ).fetchall()
416
+ for row in rows:
417
+ chave = str(row["trabalho_id"] or "").strip()
418
+ processo = str(row["processo_resumo"] or "").strip()
419
+ if not chave or not processo:
420
+ continue
421
+ processos_por_trabalho[chave] = [processo]
422
+
423
+ return {chave: _dedupe_text_list(valores) for chave, valores in processos_por_trabalho.items()}
424
+
425
+
426
  def _tipo_label_from_codigo(codigo: Any, fallback: Any = None) -> str:
427
  codigo_texto = str(codigo or "").strip().upper()
428
  if codigo_texto in TIPO_LABELS:
 
506
  "nome": str(raw.get("nome") or "").strip(),
507
  "tipo_codigo": str(raw.get("tipo_codigo") or "").strip().upper(),
508
  "ano": _to_int_or_none(raw.get("ano")),
509
+ "processos_sei_informados": "processos_sei" in raw,
510
  "processos_sei": _dedupe_text_list(raw.get("processos_sei")),
511
  "modelos": modelos,
512
  "imoveis": imoveis,
 
531
  ) -> dict[str, Any]:
532
  if not override:
533
  trabalho = dict(base)
534
+ processos_base = _dedupe_text_list(base.get("processos_sei"))
535
+ trabalho["processos_sei"] = processos_base
536
+ trabalho["processos_sei_resumo"] = _resumo_textos(processos_base, limite_inline=1)
537
  return trabalho
538
 
539
  trabalho = dict(base)
 
556
  trabalho["tipo_codigo"] = override.get("tipo_codigo") or trabalho.get("tipo_codigo") or ""
557
  trabalho["tipo_label"] = _tipo_label_from_codigo(trabalho.get("tipo_codigo"), trabalho.get("tipo_label"))
558
  trabalho["ano"] = override.get("ano")
559
+ processos_base = _dedupe_text_list(base.get("processos_sei"))
560
+ if override.get("processos_sei_informados"):
561
+ trabalho["processos_sei"] = _dedupe_text_list(override.get("processos_sei"))
562
+ else:
563
+ trabalho["processos_sei"] = processos_base
564
+ trabalho["processos_sei_resumo"] = _resumo_textos(_dedupe_text_list(trabalho.get("processos_sei")), limite_inline=1)
565
  trabalho["imoveis"] = imoveis
566
  trabalho["modelos"] = _enriquecer_modelos(modelos_raw, catalogo_modelos)
567
  trabalho["total_imoveis"] = len(imoveis)
 
645
  """,
646
  (trabalho_id,),
647
  ).fetchall()
648
+ processos_sei = _carregar_processos_importados_por_trabalho(conn, trabalho_id).get(trabalho_id, [])
649
 
650
  modelos = _enriquecer_modelos([str(row["modelo_nome"]) for row in modelo_rows], catalogo_modelos)
651
  modelos_por_imovel: dict[int, list[str]] = {}
 
680
  "total_imoveis": int(trabalho_row["total_imoveis"] or 0),
681
  "total_modelos": int(trabalho_row["total_modelos"] or 0),
682
  "tem_coordenadas": bool(trabalho_row["tem_coordenadas"]),
683
+ "processos_sei": processos_sei,
684
+ "processos_sei_resumo": _resumo_textos(processos_sei, limite_inline=1),
685
  "modelos": modelos,
686
  "imoveis": imoveis,
687
  }
 
710
  nome = str(payload.get("nome") or base.get("nome") or "").strip()
711
  tipo_codigo = str(payload.get("tipo_codigo") or base.get("tipo_codigo") or "").strip().upper()
712
  ano = _to_int_or_none(payload.get("ano"))
713
+ if "processos_sei" in payload:
714
+ processos_sei = _dedupe_text_list(payload.get("processos_sei"))
715
+ else:
716
+ processos_sei = _dedupe_text_list(base.get("processos_sei"))
717
 
718
  modelos_informados = _dedupe_text_list(payload.get("modelos"))
719
  if not modelos_informados:
 
762
  conn = _connect_database(resolved.db_path)
763
  try:
764
  overrides = _fetch_overrides(conn)
765
+ processos_por_trabalho = _carregar_processos_importados_por_trabalho(conn)
766
  rows = conn.execute(
767
  """
768
  SELECT
 
825
  "coord_lon": float(coord_x),
826
  "coord_lat": float(coord_y),
827
  "modelos_relacionados": set(),
828
+ "processos_sei": list(processos_por_trabalho.get(trabalho_id, [])),
829
  },
830
  )
831
  if modelo_nome:
 
1294
  try:
1295
  meta = _fetch_meta(conn)
1296
  overrides = _fetch_overrides(conn)
1297
+ processos_por_trabalho = _carregar_processos_importados_por_trabalho(conn)
1298
  trabalhos_rows = conn.execute(
1299
  """
1300
  SELECT
 
1328
  total_com_modelo_mesa = 0
1329
  for row in trabalhos_rows:
1330
  trabalho_id = str(row["trabalho_id"])
1331
+ processos_sei = list(processos_por_trabalho.get(trabalho_id, []))
1332
  base = {
1333
  "id": trabalho_id,
1334
  "nome": str(row["nome"]),
 
1342
  "total_imoveis": int(row["total_imoveis"] or 0),
1343
  "total_modelos": int(row["total_modelos"] or 0),
1344
  "tem_coordenadas": bool(row["tem_coordenadas"]),
1345
+ "processos_sei": processos_sei,
1346
+ "processos_sei_resumo": _resumo_textos(processos_sei, limite_inline=1),
1347
  "modelos": _enriquecer_modelos(modelos_por_trabalho.get(trabalho_id, []), catalogo_modelos),
1348
  "imoveis": [],
1349
  }
frontend/src/api.js CHANGED
@@ -301,9 +301,10 @@ export const api = {
301
  valores_x: valoresX,
302
  indice_base: indiceBase,
303
  }),
304
- evaluationKnnDetailsElab: (sessionId, valoresX) => postJson('/api/elaboracao/evaluation/knn-details', {
305
  session_id: sessionId,
306
  valores_x: valoresX,
 
307
  }),
308
  evaluationClearElab: (sessionId) => postJson('/api/elaboracao/evaluation/clear', { session_id: sessionId }),
309
  evaluationDeleteElab: (sessionId, indice, indiceBase) => postJson('/api/elaboracao/evaluation/delete', {
 
301
  valores_x: valoresX,
302
  indice_base: indiceBase,
303
  }),
304
+ evaluationKnnDetailsElab: (sessionId, valoresX = null, indiceAvaliacao = null) => postJson('/api/elaboracao/evaluation/knn-details', {
305
  session_id: sessionId,
306
  valores_x: valoresX,
307
+ indice_avaliacao: indiceAvaliacao,
308
  }),
309
  evaluationClearElab: (sessionId) => postJson('/api/elaboracao/evaluation/clear', { session_id: sessionId }),
310
  evaluationDeleteElab: (sessionId, indice, indiceBase) => postJson('/api/elaboracao/evaluation/delete', {
frontend/src/components/ElaboracaoTab.jsx CHANGED
@@ -3299,7 +3299,9 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3299
  const idx = Number(indiceRaw) - 1
3300
  if (!Number.isInteger(idx) || idx < 0) return
3301
  const avaliacao = Array.isArray(avaliacoesResultado) ? avaliacoesResultado[idx] : null
3302
- if (!avaliacao || typeof avaliacao !== 'object' || !avaliacao.valores_x) return
 
 
3303
 
3304
  setKnnDetalheAberto(true)
3305
  setKnnDetalheLoading(true)
@@ -3312,7 +3314,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3312
  setKnnDetalheInfo(null)
3313
 
3314
  try {
3315
- const resp = await api.evaluationKnnDetailsElab(sessionId, construirPayloadKnnAvaliacao(avaliacao))
3316
  setKnnDetalheMapaHtml(String(resp?.mapa_html || ''))
3317
  setKnnDetalheMapaPayload(resp?.mapa_payload || null)
3318
  setKnnDetalheAvaliando(Array.isArray(resp?.avaliando) ? resp.avaliando : [])
 
3299
  const idx = Number(indiceRaw) - 1
3300
  if (!Number.isInteger(idx) || idx < 0) return
3301
  const avaliacao = Array.isArray(avaliacoesResultado) ? avaliacoesResultado[idx] : null
3302
+ const valoresKnn = avaliacao && typeof avaliacao === 'object'
3303
+ ? construirPayloadKnnAvaliacao(avaliacao)
3304
+ : null
3305
 
3306
  setKnnDetalheAberto(true)
3307
  setKnnDetalheLoading(true)
 
3314
  setKnnDetalheInfo(null)
3315
 
3316
  try {
3317
+ const resp = await api.evaluationKnnDetailsElab(sessionId, valoresKnn, idx + 1)
3318
  setKnnDetalheMapaHtml(String(resp?.mapa_html || ''))
3319
  setKnnDetalheMapaPayload(resp?.mapa_payload || null)
3320
  setKnnDetalheAvaliando(Array.isArray(resp?.avaliando) ? resp.avaliando : [])
frontend/src/components/LeafletMapFrame.jsx CHANGED
@@ -343,8 +343,8 @@ function buildLegacyOverlayLayers(payload) {
343
 
344
  if (Array.isArray(payload?.trabalhos_tecnicos_points) && payload.trabalhos_tecnicos_points.length) {
345
  overlays.push({
346
- id: 'avaliandos',
347
- label: 'Avaliandos',
348
  show: true,
349
  markers: payload.trabalhos_tecnicos_points,
350
  })
@@ -391,6 +391,8 @@ export default function LeafletMapFrame({ payload, sessionId }) {
391
  const canvasRenderer = L.canvas({ padding: 0.5, pane: 'mesa-market-pane' })
392
  const baseLayers = {}
393
  const overlayLayers = {}
 
 
394
 
395
  ;(payload.tile_layers || []).forEach((layerDef, index) => {
396
  const tileLayer = L.tileLayer(String(layerDef?.url || ''), {
@@ -576,6 +578,46 @@ export default function LeafletMapFrame({ payload, sessionId }) {
576
  })
577
  }
578
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
579
  function addShapeOverlays(layerGroup, shapes) {
580
  if (!Array.isArray(shapes) || !shapes.length) return
581
  shapes.forEach((shape) => {
@@ -680,9 +722,13 @@ export default function LeafletMapFrame({ payload, sessionId }) {
680
  }
681
 
682
  overlaySpecs.forEach((spec, index) => {
 
683
  const label = String(spec?.label || spec?.id || `Camada ${index + 1}`)
684
  const layerGroup = L.layerGroup()
685
  overlayLayers[label] = layerGroup
 
 
 
686
  if (spec?.show !== false) {
687
  layerGroup.addTo(map)
688
  }
@@ -690,11 +736,24 @@ export default function LeafletMapFrame({ payload, sessionId }) {
690
  addGeoJsonOverlay(layerGroup, spec)
691
  }
692
  addHeatmapOverlay(layerGroup, spec?.heatmap)
693
- addShapeOverlays(layerGroup, spec?.shapes)
694
  addPointMarkers(layerGroup, spec?.points)
695
- addMarkerOverlays(layerGroup, spec?.markers)
 
 
 
 
 
 
 
 
 
696
  })
697
 
 
 
 
 
 
698
  if (payload.controls?.layer_control) {
699
  L.control.layers(baseLayers, overlayLayers, { collapsed: true }).addTo(map)
700
  }
@@ -989,6 +1048,10 @@ export default function LeafletMapFrame({ payload, sessionId }) {
989
  }
990
 
991
  return () => {
 
 
 
 
992
  disposed = true
993
  restoreMapInteractions?.()
994
  map.remove()
 
343
 
344
  if (Array.isArray(payload?.trabalhos_tecnicos_points) && payload.trabalhos_tecnicos_points.length) {
345
  overlays.push({
346
+ id: 'trabalhos-tecnicos',
347
+ label: 'Trabalhos técnicos',
348
  show: true,
349
  markers: payload.trabalhos_tecnicos_points,
350
  })
 
391
  const canvasRenderer = L.canvas({ padding: 0.5, pane: 'mesa-market-pane' })
392
  const baseLayers = {}
393
  const overlayLayers = {}
394
+ const overlayGroupsById = new Map()
395
+ const dependencyAwareOverlays = []
396
 
397
  ;(payload.tile_layers || []).forEach((layerDef, index) => {
398
  const tileLayer = L.tileLayer(String(layerDef?.url || ''), {
 
578
  })
579
  }
580
 
581
+ function filterDependencyAwareMarkers(items) {
582
+ if (!Array.isArray(items) || !items.length) return []
583
+ return items.filter((item) => {
584
+ const sourceIds = Array.isArray(item?.source_overlay_ids)
585
+ ? item.source_overlay_ids.map((entry) => String(entry || '').trim()).filter(Boolean)
586
+ : []
587
+ if (!sourceIds.length) return true
588
+ return sourceIds.some((sourceId) => {
589
+ const sourceLayer = overlayGroupsById.get(sourceId)
590
+ return Boolean(sourceLayer && map.hasLayer(sourceLayer))
591
+ })
592
+ })
593
+ }
594
+
595
+ function areRequiredOverlaysVisible(spec) {
596
+ const requiredIds = Array.isArray(spec?.required_overlay_ids)
597
+ ? spec.required_overlay_ids.map((entry) => String(entry || '').trim()).filter(Boolean)
598
+ : []
599
+ if (!requiredIds.length) return true
600
+ return requiredIds.every((requiredId) => {
601
+ const requiredLayer = overlayGroupsById.get(requiredId)
602
+ return Boolean(requiredLayer && map.hasLayer(requiredLayer))
603
+ })
604
+ }
605
+
606
+ function renderDependencyAwareOverlay(spec, layerGroup) {
607
+ if (!layerGroup) return
608
+ layerGroup.clearLayers()
609
+ if (!map.hasLayer(layerGroup)) return
610
+ if (!areRequiredOverlaysVisible(spec)) return
611
+ addShapeOverlays(layerGroup, spec?.shapes)
612
+ addMarkerOverlays(layerGroup, filterDependencyAwareMarkers(spec?.markers))
613
+ }
614
+
615
+ function refreshDependencyAwareOverlays() {
616
+ dependencyAwareOverlays.forEach(({ spec, layerGroup }) => {
617
+ renderDependencyAwareOverlay(spec, layerGroup)
618
+ })
619
+ }
620
+
621
  function addShapeOverlays(layerGroup, shapes) {
622
  if (!Array.isArray(shapes) || !shapes.length) return
623
  shapes.forEach((shape) => {
 
722
  }
723
 
724
  overlaySpecs.forEach((spec, index) => {
725
+ const specId = String(spec?.id || '').trim()
726
  const label = String(spec?.label || spec?.id || `Camada ${index + 1}`)
727
  const layerGroup = L.layerGroup()
728
  overlayLayers[label] = layerGroup
729
+ if (specId) {
730
+ overlayGroupsById.set(specId, layerGroup)
731
+ }
732
  if (spec?.show !== false) {
733
  layerGroup.addTo(map)
734
  }
 
736
  addGeoJsonOverlay(layerGroup, spec)
737
  }
738
  addHeatmapOverlay(layerGroup, spec?.heatmap)
 
739
  addPointMarkers(layerGroup, spec?.points)
740
+ const hasMarkerDependencies = Array.isArray(spec?.markers)
741
+ && spec.markers.some((item) => Array.isArray(item?.source_overlay_ids) && item.source_overlay_ids.length)
742
+ const hasOverlayDependencies = Array.isArray(spec?.required_overlay_ids) && spec.required_overlay_ids.length > 0
743
+ if (hasMarkerDependencies || hasOverlayDependencies) {
744
+ dependencyAwareOverlays.push({ spec, layerGroup })
745
+ renderDependencyAwareOverlay(spec, layerGroup)
746
+ } else {
747
+ addShapeOverlays(layerGroup, spec?.shapes)
748
+ addMarkerOverlays(layerGroup, spec?.markers)
749
+ }
750
  })
751
 
752
+ if (dependencyAwareOverlays.length) {
753
+ map.on('overlayadd', refreshDependencyAwareOverlays)
754
+ map.on('overlayremove', refreshDependencyAwareOverlays)
755
+ }
756
+
757
  if (payload.controls?.layer_control) {
758
  L.control.layers(baseLayers, overlayLayers, { collapsed: true }).addTo(map)
759
  }
 
1048
  }
1049
 
1050
  return () => {
1051
+ if (dependencyAwareOverlays.length) {
1052
+ map.off('overlayadd', refreshDependencyAwareOverlays)
1053
+ map.off('overlayremove', refreshDependencyAwareOverlays)
1054
+ }
1055
  disposed = true
1056
  restoreMapInteractions?.()
1057
  map.remove()
frontend/src/components/PesquisaTab.jsx CHANGED
@@ -82,6 +82,15 @@ const TIPO_SIGLAS = {
82
  DEPOS: 'Deposito',
83
  CCOM: 'Casa comercial',
84
  }
 
 
 
 
 
 
 
 
 
85
 
86
  function formatRange(range) {
87
  if (!range) return '-'
@@ -1024,9 +1033,12 @@ export default function PesquisaTab({
1024
 
1025
  const sugestoes = result.sugestoes || {}
1026
  const opcoesTipoModelo = useMemo(
1027
- () => [...new Set((sugestoes.tipos_modelo || []).map((item) => String(item || '').trim()).filter(Boolean))]
1028
- .sort((a, b) => a.localeCompare(b, 'pt-BR', { sensitivity: 'base' })),
1029
- [sugestoes.tipos_modelo],
 
 
 
1030
  )
1031
  const modoModeloAberto = Boolean(modeloAbertoMeta)
1032
  const avaliandosGeoPayload = useMemo(
 
82
  DEPOS: 'Deposito',
83
  CCOM: 'Casa comercial',
84
  }
85
+ const TIPOS_MODELO_GENERICOS = [
86
+ 'Apartamentos',
87
+ 'Casa comercial',
88
+ 'Deposito',
89
+ 'Edificio',
90
+ 'Loja',
91
+ 'Salas comerciais',
92
+ 'Terrenos',
93
+ ]
94
 
95
  function formatRange(range) {
96
  if (!range) return '-'
 
1033
 
1034
  const sugestoes = result.sugestoes || {}
1035
  const opcoesTipoModelo = useMemo(
1036
+ () => {
1037
+ const atual = String(filters.tipoModelo || '').trim()
1038
+ if (!atual || TIPOS_MODELO_GENERICOS.includes(atual)) return TIPOS_MODELO_GENERICOS
1039
+ return [...TIPOS_MODELO_GENERICOS, atual]
1040
+ },
1041
+ [filters.tipoModelo],
1042
  )
1043
  const modoModeloAberto = Boolean(modeloAbertoMeta)
1044
  const avaliandosGeoPayload = useMemo(