Guilherme Silberfarb Costa commited on
Commit
fef776b
·
1 Parent(s): 440f380

alteracoes nos mapas e no carregamento de planilha

Browse files
.gitignore CHANGED
@@ -24,6 +24,7 @@ logs/**/*.jsonl
24
  backend/local_data/*.sqlite3
25
  backend/local_data/*.sqlite3-shm
26
  backend/local_data/*.sqlite3-wal
 
27
 
28
  # Local .dai model repository
29
  backend/app/core/pesquisa/modelos_dai/*.dai
 
24
  backend/local_data/*.sqlite3
25
  backend/local_data/*.sqlite3-shm
26
  backend/local_data/*.sqlite3-wal
27
+ backend/local_data/anexos_gerados/
28
 
29
  # Local .dai model repository
30
  backend/app/core/pesquisa/modelos_dai/*.dai
backend/app/api/anexos.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import APIRouter, Request
6
+ from fastapi.responses import FileResponse
7
+ from pydantic import BaseModel, Field
8
+
9
+ from app.services import anexos_service, auth_service
10
+ from app.services.audit_log_service import log_event
11
+
12
+
13
+ router = APIRouter(prefix="/api/anexos", tags=["anexos"])
14
+
15
+
16
+ class GerarAnexosPayload(BaseModel):
17
+ modelo_id: str = Field(..., min_length=1)
18
+ banco_dados_colunas: list[str] | None = None
19
+ avaliacao: dict[str, Any] | None = None
20
+
21
+
22
+ @router.get("/modelos")
23
+ def modelos(request: Request) -> dict[str, Any]:
24
+ auth_service.require_user(request)
25
+ return anexos_service.listar_modelos()
26
+
27
+
28
+ @router.get("/modelos/{modelo_id}/columns")
29
+ def modelo_colunas(modelo_id: str, request: Request) -> dict[str, Any]:
30
+ auth_service.require_user(request)
31
+ return anexos_service.obter_colunas(modelo_id)
32
+
33
+
34
+ @router.post("/generate")
35
+ def gerar(payload: GerarAnexosPayload, request: Request) -> dict[str, Any]:
36
+ user = auth_service.require_user(request)
37
+ caminho, avisos = anexos_service.gerar_documento(
38
+ payload.modelo_id,
39
+ banco_dados_colunas=payload.banco_dados_colunas,
40
+ avaliacao=payload.avaliacao,
41
+ )
42
+ log_event(
43
+ "visualizacao",
44
+ "gerar_anexos_modelo",
45
+ user=user,
46
+ request=request,
47
+ details={
48
+ "modelo_id": payload.modelo_id,
49
+ "com_avaliacao": bool(payload.avaliacao),
50
+ "total_colunas": len(payload.banco_dados_colunas or []),
51
+ },
52
+ )
53
+ return {
54
+ "message": f"Anexos gerados com sucesso: {caminho.name}",
55
+ "filename": caminho.name,
56
+ "statistical_warnings": avisos,
57
+ }
58
+
59
+
60
+ @router.get("/download/{filename}")
61
+ def download(filename: str, request: Request) -> FileResponse:
62
+ auth_service.require_user(request)
63
+ caminho = anexos_service.resolver_download(filename)
64
+ return FileResponse(
65
+ path=caminho,
66
+ filename=caminho.name,
67
+ media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
68
+ headers={"Cache-Control": "no-store"},
69
+ )
backend/app/api/pesquisa.py CHANGED
@@ -1,13 +1,16 @@
1
  from __future__ import annotations
2
 
3
  import json
 
4
  from typing import Any
5
 
6
  from fastapi import APIRouter, HTTPException, Query
 
7
  from pydantic import BaseModel
8
 
9
  from app.services.pesquisa_service import (
10
  PesquisaFiltros,
 
11
  gerar_mapa_modelos,
12
  listar_logradouros_eixos,
13
  listar_modelos,
@@ -75,6 +78,15 @@ class PesquisaAdminConfigPayload(BaseModel):
75
  campos: dict[str, list[str]] = {}
76
 
77
 
 
 
 
 
 
 
 
 
 
78
  @router.get("/admin-config")
79
  def pesquisar_admin_config() -> dict:
80
  return obter_admin_config_pesquisa()
@@ -224,3 +236,15 @@ def pesquisar_mapa_modelos(payload: MapaModelosPayload) -> dict:
224
  trabalhos_tecnicos_proximidade_modo=proximidade_modo,
225
  trabalhos_tecnicos_raio_m=payload.trabalhos_tecnicos_raio_m,
226
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import json
4
+ import os
5
  from typing import Any
6
 
7
  from fastapi import APIRouter, HTTPException, Query
8
+ from fastapi.responses import FileResponse
9
  from pydantic import BaseModel
10
 
11
  from app.services.pesquisa_service import (
12
  PesquisaFiltros,
13
+ exportar_indices_mapa_pesquisa_xlsx,
14
  gerar_mapa_modelos,
15
  listar_logradouros_eixos,
16
  listar_modelos,
 
78
  campos: dict[str, list[str]] = {}
79
 
80
 
81
+ class MapaIndicesModeloPayload(BaseModel):
82
+ nome: Any = None
83
+ indices: list[Any] = []
84
+
85
+
86
+ class MapaIndicesExportPayload(BaseModel):
87
+ modelos: list[MapaIndicesModeloPayload] = []
88
+
89
+
90
  @router.get("/admin-config")
91
  def pesquisar_admin_config() -> dict:
92
  return obter_admin_config_pesquisa()
 
236
  trabalhos_tecnicos_proximidade_modo=proximidade_modo,
237
  trabalhos_tecnicos_raio_m=payload.trabalhos_tecnicos_raio_m,
238
  )
239
+
240
+
241
+ @router.post("/mapa-indices-selecionados/export")
242
+ def exportar_mapa_indices_selecionados(payload: MapaIndicesExportPayload) -> FileResponse:
243
+ caminho, nome = exportar_indices_mapa_pesquisa_xlsx(
244
+ [item.model_dump() for item in payload.modelos]
245
+ )
246
+ return FileResponse(
247
+ path=caminho,
248
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
249
+ filename=os.path.basename(nome),
250
+ )
backend/app/core/anexos/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Geração dos anexos técnicos de modelos de avaliação."""
2
+
3
+ from .generator import AnexosGenerator
4
+ from .model_loader import load_model, model_data_from_package
5
+
6
+ __all__ = ["AnexosGenerator", "load_model", "model_data_from_package"]
backend/app/core/anexos/banco_dados.py ADDED
@@ -0,0 +1,454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gerador do Anexo de Banco de Dados.
3
+ Gera a tabela com os dados das amostras (Xy_preview).
4
+ """
5
+ from datetime import date, datetime
6
+ from functools import lru_cache
7
+ from numbers import Number
8
+ from pathlib import Path
9
+ import re
10
+ from typing import Optional, Sequence
11
+
12
+ import pandas as pd
13
+ from docx import Document
14
+
15
+ from .model_data import ModelData
16
+ from .formatters import (
17
+ add_placeholder_text,
18
+ add_simple_table,
19
+ )
20
+
21
+ # Hífen que não permite quebra de linha (non-breaking hyphen)
22
+ HIFEN_NAO_QUEBRAVEL = '\u2011'
23
+ COLUNAS_COORDENADAS = {"lat", "lon"}
24
+ PESO_COLUNA_DICOTOMICA = 0.45
25
+ PESO_COLUNA_RH = 0.5
26
+ PESO_COLUNA_DATA = 1.45
27
+ PESO_COLUNA_TEXTO = 2.0
28
+ CASAS_DECIMAIS_COORDENADAS = 5
29
+ FONTE_TABELA_PT = 8
30
+ EMU_POR_POLEGADA = 914400
31
+ DPI_MEDICAO = 96
32
+ FOLGA_CELULA_POLEGADAS = 0.18
33
+ FOLGA_CABECALHO_POLEGADAS = 0.16
34
+ FATOR_SEGURANCA_CABECALHO = 1.1
35
+ LARGURA_MINIMA_COLUNA_POLEGADAS = 0.28
36
+ LARGURA_MINIMA_DATA_POLEGADAS = 0.74
37
+
38
+
39
+ def _formatar_numero(
40
+ val: float,
41
+ casas_decimais: int = 2,
42
+ separador_milhar: bool = True,
43
+ ) -> str:
44
+ """
45
+ Formata número em padrão brasileiro, substituindo hífen por hífen não-quebrável.
46
+ Evita que o Word quebre a linha entre o sinal negativo e o número.
47
+ """
48
+ if separador_milhar:
49
+ texto = f"{val:,.{casas_decimais}f}".replace(',', 'X').replace('.', ',').replace('X', '.')
50
+ else:
51
+ texto = f"{val:.{casas_decimais}f}".replace('.', ',')
52
+ return texto.replace('-', HIFEN_NAO_QUEBRAVEL)
53
+
54
+
55
+ def _polegadas_para_emu(valor: float) -> int:
56
+ return int(valor * EMU_POR_POLEGADA)
57
+
58
+
59
+ def _valor_vazio(val) -> bool:
60
+ if val is None:
61
+ return True
62
+
63
+ vazio = pd.isna(val)
64
+ return bool(vazio) if isinstance(vazio, bool) else False
65
+
66
+
67
+ def _valores_nao_vazios(series: pd.Series) -> list:
68
+ return [val for val in series.tolist() if not _valor_vazio(val)]
69
+
70
+
71
+ def _nome_coluna_normalizado(col) -> str:
72
+ return str(col).strip().lower()
73
+
74
+
75
+ def _is_coluna_coordenada(col) -> bool:
76
+ return _nome_coluna_normalizado(col) in COLUNAS_COORDENADAS
77
+
78
+
79
+ def _is_coluna_rh(col) -> bool:
80
+ return _nome_coluna_normalizado(col) == "rh"
81
+
82
+
83
+ def _is_numero(val) -> bool:
84
+ return isinstance(val, Number) and not isinstance(val, bool)
85
+
86
+
87
+ def _is_coluna_data(col, series: pd.Series) -> bool:
88
+ if pd.api.types.is_datetime64_any_dtype(series):
89
+ return True
90
+
91
+ valores = _valores_nao_vazios(series)
92
+ if not valores:
93
+ return False
94
+
95
+ if all(isinstance(val, (pd.Timestamp, datetime, date)) for val in valores):
96
+ return True
97
+
98
+ if not all(isinstance(val, str) for val in valores):
99
+ return False
100
+
101
+ nome_indica_data = "data" in _nome_coluna_normalizado(col) or "date" in _nome_coluna_normalizado(col)
102
+ textos_tem_separador_data = any(re.search(r"\d[/-]\d", val) for val in valores)
103
+ if not nome_indica_data and not textos_tem_separador_data:
104
+ return False
105
+
106
+ datas = pd.to_datetime(pd.Series(valores), errors="coerce", dayfirst=True)
107
+ return bool(datas.notna().all())
108
+
109
+
110
+ def _is_coluna_texto_puro(col, series: pd.Series) -> bool:
111
+ valores = _valores_nao_vazios(series)
112
+ return bool(valores) and all(isinstance(val, str) for val in valores) and not _is_coluna_data(col, series)
113
+
114
+
115
+ def _formatar_data(val) -> str:
116
+ data = pd.to_datetime(val, errors="coerce", dayfirst=True)
117
+ if pd.isna(data):
118
+ return str(val)
119
+ return data.strftime("%d/%m/%Y")
120
+
121
+
122
+ def _formatar_valor_tabela(val, col, colunas_inteiras: set, colunas_data: set) -> str:
123
+ if _valor_vazio(val):
124
+ return ""
125
+
126
+ if col in colunas_data:
127
+ return _formatar_data(val)
128
+
129
+ if _is_coluna_coordenada(col) and _is_numero(val):
130
+ return _formatar_numero(
131
+ float(val),
132
+ casas_decimais=CASAS_DECIMAIS_COORDENADAS,
133
+ separador_milhar=False,
134
+ )
135
+
136
+ if col in colunas_inteiras and _is_numero(val):
137
+ texto = str(int(val))
138
+ return texto.replace('-', HIFEN_NAO_QUEBRAVEL)
139
+
140
+ if _is_numero(val):
141
+ return _formatar_numero(float(val), casas_decimais=2)
142
+
143
+ return str(val)
144
+
145
+
146
+ @lru_cache(maxsize=1)
147
+ def _carregar_fonte_medicao():
148
+ try:
149
+ from PIL import ImageFont
150
+ except ImportError:
151
+ return None
152
+
153
+ tamanho_px = round(FONTE_TABELA_PT * DPI_MEDICAO / 72)
154
+ caminhos = [
155
+ Path("/System/Library/Fonts/Supplemental/Arial.ttf"),
156
+ Path("/Library/Fonts/Arial.ttf"),
157
+ Path("/usr/share/fonts/truetype/msttcorefonts/Arial.ttf"),
158
+ Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"),
159
+ ]
160
+
161
+ for caminho in caminhos:
162
+ if caminho.exists():
163
+ try:
164
+ return ImageFont.truetype(str(caminho), tamanho_px)
165
+ except OSError:
166
+ continue
167
+
168
+ try:
169
+ return ImageFont.load_default()
170
+ except OSError:
171
+ return None
172
+
173
+
174
+ def _medir_texto_emu_com_folga(texto: str, folga_polegadas: float, minimo_polegadas: float) -> int:
175
+ texto = str(texto)
176
+ fonte = _carregar_fonte_medicao()
177
+
178
+ if fonte is not None:
179
+ bbox = fonte.getbbox(texto)
180
+ largura_px = bbox[2] - bbox[0]
181
+ else:
182
+ largura_px = len(texto) * (FONTE_TABELA_PT * 0.58)
183
+
184
+ largura_polegadas = largura_px / DPI_MEDICAO + folga_polegadas
185
+ return max(
186
+ _polegadas_para_emu(minimo_polegadas),
187
+ _polegadas_para_emu(largura_polegadas),
188
+ )
189
+
190
+
191
+ def _medir_texto_emu(texto: str) -> int:
192
+ return _medir_texto_emu_com_folga(
193
+ texto,
194
+ FOLGA_CELULA_POLEGADAS,
195
+ LARGURA_MINIMA_COLUNA_POLEGADAS,
196
+ )
197
+
198
+
199
+ def _medir_cabecalho_emu(texto: str) -> int:
200
+ largura = _medir_texto_emu_com_folga(texto, FOLGA_CABECALHO_POLEGADAS, 0)
201
+ return int(largura * FATOR_SEGURANCA_CABECALHO)
202
+
203
+
204
+ def _largura_util_pagina_emu(doc: Document) -> int:
205
+ section = doc.sections[-1]
206
+ return int(section.page_width - section.left_margin - section.right_margin)
207
+
208
+
209
+ def _calcular_larguras_naturais(
210
+ dados: list[list[str]],
211
+ colunas_df: Sequence,
212
+ colunas_data: set,
213
+ ) -> list[int]:
214
+ larguras = []
215
+
216
+ for indice, header in enumerate(dados[0]):
217
+ valores = [row[indice] for row in dados[1:] if row[indice]]
218
+ if indice == 0:
219
+ valores = valores or ["1"]
220
+ else:
221
+ col = colunas_df[indice - 1]
222
+ if col in colunas_data:
223
+ valores.append("00/00/0000")
224
+
225
+ largura = max(_medir_texto_emu(valor) for valor in valores) if valores else _medir_texto_emu("")
226
+
227
+ if indice > 0 and colunas_df[indice - 1] in colunas_data:
228
+ largura = max(largura, _polegadas_para_emu(LARGURA_MINIMA_DATA_POLEGADAS))
229
+
230
+ larguras.append(largura)
231
+
232
+ return larguras
233
+
234
+
235
+ def _largura_rh_emu() -> int:
236
+ return _medir_texto_emu("999")
237
+
238
+
239
+ def _aplicar_larguras_especiais(
240
+ larguras: Sequence[int],
241
+ dados: list[list[str]],
242
+ colunas_df: Sequence,
243
+ ) -> list[int]:
244
+ larguras_ajustadas = [int(largura) for largura in larguras]
245
+
246
+ valores_id = [row[0] for row in dados[1:] if row[0]]
247
+ if valores_id:
248
+ larguras_ajustadas[0] = max(
249
+ larguras_ajustadas[0],
250
+ max(_medir_texto_emu(valor) for valor in valores_id),
251
+ )
252
+
253
+ for indice, col in enumerate(colunas_df, start=1):
254
+ if _is_coluna_rh(col):
255
+ larguras_ajustadas[indice] = _largura_rh_emu()
256
+
257
+ return larguras_ajustadas
258
+
259
+
260
+ def _calcular_larguras_fallback(
261
+ colunas_df: Sequence,
262
+ colunas_data: set,
263
+ colunas_texto_puro: set,
264
+ colunas_dicotomicas: set,
265
+ ) -> list[float]:
266
+ larguras = [0.5]
267
+ for col in colunas_df:
268
+ if _is_coluna_rh(col):
269
+ larguras.append(PESO_COLUNA_RH)
270
+ elif col in colunas_dicotomicas:
271
+ larguras.append(PESO_COLUNA_DICOTOMICA)
272
+ elif col in colunas_texto_puro:
273
+ larguras.append(PESO_COLUNA_TEXTO)
274
+ elif col in colunas_data:
275
+ larguras.append(PESO_COLUNA_DATA)
276
+ else:
277
+ larguras.append(1)
278
+ return larguras
279
+
280
+
281
+ def _converter_pesos_para_larguras_absolutas(
282
+ pesos: Sequence[float],
283
+ largura_total: int,
284
+ ) -> list[int]:
285
+ total = sum(pesos)
286
+ return [int(largura_total * peso / total) for peso in pesos]
287
+
288
+
289
+ def _calcular_rotacoes_cabecalho(
290
+ headers: Sequence[str],
291
+ larguras_finais: Sequence[int],
292
+ colunas_df: Sequence,
293
+ colunas_dicotomicas: set,
294
+ ) -> list[bool]:
295
+ rotacoes = []
296
+ for indice, (header, largura) in enumerate(zip(headers, larguras_finais)):
297
+ if indice > 0 and (
298
+ colunas_df[indice - 1] in colunas_dicotomicas
299
+ or _is_coluna_rh(colunas_df[indice - 1])
300
+ ):
301
+ rotacoes.append(True)
302
+ else:
303
+ rotacoes.append(len(str(header)) > 4 and _medir_cabecalho_emu(header) > largura)
304
+ return rotacoes
305
+
306
+
307
+ def _colunas_dicotomicas_na_tabela(modelo: ModelData, colunas_df: Sequence) -> set:
308
+ dicotomicas = {str(col) for col in modelo.variaveis_dicotomicas}
309
+ return {col for col in colunas_df if str(col) in dicotomicas}
310
+
311
+
312
+ def _formatar_cabecalho_coluna(col) -> str:
313
+ if _is_coluna_coordenada(col):
314
+ return str(col).upper()
315
+ return str(col)
316
+
317
+
318
+ def _filtrar_dataframe_banco_dados(
319
+ modelo: Optional[ModelData],
320
+ colunas: Optional[Sequence[str]] = None,
321
+ ) -> Optional[pd.DataFrame]:
322
+ if modelo is None or modelo.xy_preview.empty:
323
+ return None
324
+
325
+ df = modelo.xy_preview
326
+ colunas_por_nome = {str(col): col for col in df.columns.tolist()}
327
+ colunas_disponiveis = list(colunas_por_nome.keys())
328
+
329
+ if colunas is None:
330
+ return df
331
+
332
+ colunas_validas = [
333
+ colunas_por_nome[col]
334
+ for col in colunas
335
+ if col in colunas_disponiveis
336
+ ]
337
+ if not colunas_validas:
338
+ return df.iloc[:, 0:0]
339
+
340
+ return df.loc[:, colunas_validas]
341
+
342
+
343
+ def _montar_dados_tabela(df: pd.DataFrame) -> tuple[list[list[str]], set, set]:
344
+ colunas_data = {
345
+ col for col in df.columns if _is_coluna_data(col, df[col])
346
+ }
347
+ colunas_texto_puro = {
348
+ col for col in df.columns if _is_coluna_texto_puro(col, df[col])
349
+ }
350
+
351
+ # Detectar colunas que contêm apenas valores inteiros
352
+ colunas_inteiras = set()
353
+ for col in df.columns:
354
+ valores = _valores_nao_vazios(df[col])
355
+ if len(valores) > 0 and all(
356
+ _is_numero(v) and float(v) == int(v)
357
+ for v in valores
358
+ ):
359
+ colunas_inteiras.add(col)
360
+
361
+ headers = ["ID"] + [_formatar_cabecalho_coluna(col) for col in df.columns.tolist()]
362
+ dados = [headers]
363
+
364
+ for idx, (_, row) in enumerate(df.iterrows(), start=1):
365
+ linha = [str(idx)] # ID da amostra (1-based)
366
+ for col, val in zip(df.columns, row.values):
367
+ linha.append(_formatar_valor_tabela(val, col, colunas_inteiras, colunas_data))
368
+ dados.append(linha)
369
+
370
+ return dados, colunas_data, colunas_texto_puro
371
+
372
+
373
+ def anexo_banco_dados_cabe_na_largura(
374
+ modelo: Optional[ModelData],
375
+ colunas: Optional[Sequence[str]],
376
+ largura_util: int,
377
+ ) -> bool:
378
+ """Retorna True quando a tabela cabe usando larguras naturais por valor."""
379
+ df = _filtrar_dataframe_banco_dados(modelo, colunas)
380
+ if df is None or df.empty:
381
+ return True
382
+
383
+ dados, colunas_data, _ = _montar_dados_tabela(df)
384
+ larguras_naturais = _calcular_larguras_naturais(dados, df.columns.tolist(), colunas_data)
385
+ larguras_naturais = _aplicar_larguras_especiais(larguras_naturais, dados, df.columns.tolist())
386
+ return sum(larguras_naturais) <= largura_util
387
+
388
+
389
+ def gerar_anexo_banco_dados(
390
+ doc: Document,
391
+ modelo: Optional[ModelData],
392
+ colunas: Optional[Sequence[str]] = None,
393
+ ) -> None:
394
+ """
395
+ Gera o conteúdo do Anexo de Banco de Dados.
396
+
397
+ Args:
398
+ doc: Documento DOCX
399
+ modelo: Dados do modelo carregado do .dai
400
+ colunas: Colunas do banco de dados a exibir, na ordem desejada.
401
+ Quando omitido, todas as colunas são exibidas.
402
+ """
403
+ if modelo is None:
404
+ add_placeholder_text(doc, "[Modelo não selecionado - inserir banco de dados manualmente]")
405
+ return
406
+
407
+ if modelo.xy_preview.empty:
408
+ add_placeholder_text(doc, "[Banco de dados não disponível no modelo]")
409
+ return
410
+
411
+ df = _filtrar_dataframe_banco_dados(modelo, colunas)
412
+ if df is None or df.empty:
413
+ add_placeholder_text(doc, "[Nenhuma coluna válida selecionada para o banco de dados]")
414
+ return
415
+
416
+ dados, colunas_data, colunas_texto_puro = _montar_dados_tabela(df)
417
+ headers = dados[0]
418
+ colunas_df = df.columns.tolist()
419
+ colunas_dicotomicas = _colunas_dicotomicas_na_tabela(modelo, colunas_df)
420
+
421
+ largura_util = _largura_util_pagina_emu(doc)
422
+ larguras_naturais = _calcular_larguras_naturais(dados, colunas_df, colunas_data)
423
+ usar_largura_natural = sum(larguras_naturais) <= largura_util
424
+
425
+ if usar_largura_natural:
426
+ larguras_finais = larguras_naturais
427
+ else:
428
+ pesos_fallback = _calcular_larguras_fallback(
429
+ colunas_df,
430
+ colunas_data,
431
+ colunas_texto_puro,
432
+ colunas_dicotomicas,
433
+ )
434
+ larguras_finais = _converter_pesos_para_larguras_absolutas(pesos_fallback, largura_util)
435
+
436
+ larguras_finais = _aplicar_larguras_especiais(larguras_finais, dados, colunas_df)
437
+
438
+ rotacoes_cabecalho = _calcular_rotacoes_cabecalho(
439
+ headers,
440
+ larguras_finais,
441
+ colunas_df,
442
+ colunas_dicotomicas,
443
+ )
444
+
445
+ add_simple_table(
446
+ doc,
447
+ dados,
448
+ header_row=True,
449
+ largura_colunas=larguras_finais,
450
+ rotacao_cabecalho=rotacoes_cabecalho,
451
+ largura_total=largura_util,
452
+ largura_colunas_absolutas=True,
453
+ no_quebrar_linha=True,
454
+ )
backend/app/core/anexos/calculo_valor.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Geração do Anexo IV, com preenchimento opcional por uma avaliação da mesa."""
2
+ from __future__ import annotations
3
+
4
+ from numbers import Number
5
+ from typing import Any
6
+
7
+ from docx import Document
8
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
9
+
10
+ from .formatters import (
11
+ add_body_text,
12
+ add_placeholder_text,
13
+ add_simple_table,
14
+ add_subsection_title,
15
+ criar_paragrafo_formatado,
16
+ )
17
+ from .model_data import ModelData
18
+
19
+
20
+ def _numero(value: Any) -> float | None:
21
+ try:
22
+ numero = float(value)
23
+ except (TypeError, ValueError):
24
+ return None
25
+ return numero
26
+
27
+
28
+ def _formatar_numero(value: Any, casas: int = 2) -> str:
29
+ numero = _numero(value)
30
+ if numero is None:
31
+ return "-" if value in (None, "") else str(value)
32
+ return f"{numero:,.{casas}f}".replace(",", "X").replace(".", ",").replace("X", ".")
33
+
34
+
35
+ def _formatar_moeda(value: Any) -> str:
36
+ numero = _numero(value)
37
+ return "-" if numero is None else f"R$ {_formatar_numero(numero)}"
38
+
39
+
40
+ def _formatar_percentual(value: Any) -> str:
41
+ numero = _numero(value)
42
+ return "-" if numero is None else f"{_formatar_numero(numero, 1)}%"
43
+
44
+
45
+ def _rotulo_estimado(tipo_y: str) -> tuple[str, str]:
46
+ if tipo_y == "unitario":
47
+ return "Valor unitário estimado", "Valor total estimado"
48
+ if tipo_y == "total":
49
+ return "Valor total estimado", "Valor unitário estimado"
50
+ return "Valor estimado", ""
51
+
52
+
53
+ def gerar_anexo_calculo(
54
+ doc: Document,
55
+ modelo: ModelData | None,
56
+ avaliacao: dict[str, Any] | None = None,
57
+ ) -> None:
58
+ if modelo is None:
59
+ add_placeholder_text(doc, "[Modelo não selecionado - inserir cálculo manualmente]")
60
+ return
61
+
62
+ if modelo.equacao:
63
+ add_subsection_title(doc, "", "Equação do Modelo")
64
+ criar_paragrafo_formatado(
65
+ doc,
66
+ modelo.equacao,
67
+ tamanho=11,
68
+ alinhamento=WD_ALIGN_PARAGRAPH.CENTER,
69
+ espaco_depois=12,
70
+ )
71
+
72
+ if modelo.variaveis:
73
+ add_subsection_title(doc, "", "Variáveis do Modelo")
74
+ for variavel in modelo.variaveis:
75
+ add_body_text(doc, f"• {variavel}")
76
+
77
+ doc.add_paragraph()
78
+
79
+ if not modelo.tabelas_coef.empty:
80
+ add_subsection_title(doc, "", "Coeficientes")
81
+ df = modelo.tabelas_coef
82
+ coluna_coef = next(
83
+ (col for col in ("coef", "Coeficiente", "coeficiente") if col in df.columns),
84
+ None,
85
+ )
86
+ if coluna_coef:
87
+ for indice, row in df.iterrows():
88
+ coeficiente = row[coluna_coef]
89
+ texto = f"{float(coeficiente):.6f}" if isinstance(coeficiente, Number) else str(coeficiente)
90
+ add_body_text(doc, f"• {indice}: {texto}")
91
+
92
+ doc.add_paragraph()
93
+ add_subsection_title(doc, "", "Cálculo do Valor do Imóvel")
94
+
95
+ if not avaliacao:
96
+ add_placeholder_text(doc, "[Inserir valores das variáveis para o imóvel avaliado]")
97
+ add_placeholder_text(doc, "[Inserir cálculo e resultado final]")
98
+ return
99
+
100
+ _adicionar_avaliacao(doc, avaliacao)
101
+
102
+
103
+ def _adicionar_avaliacao(doc: Document, avaliacao: dict[str, Any]) -> None:
104
+ valores_x = avaliacao.get("valores_x")
105
+ valores_x = valores_x if isinstance(valores_x, dict) else {}
106
+ coluna_area = str(avaliacao.get("coluna_area") or "").strip()
107
+ valor_area = avaliacao.get("valor_area")
108
+
109
+ valores_tabela = [["Variável", "Valor informado"]]
110
+ for variavel, valor in valores_x.items():
111
+ valores_tabela.append([str(variavel), _formatar_numero(valor)])
112
+ if coluna_area and coluna_area not in valores_x and valor_area is not None:
113
+ valores_tabela.append([coluna_area, _formatar_numero(valor_area)])
114
+
115
+ add_body_text(doc, "Valores utilizados:", indent=False)
116
+ if len(valores_tabela) > 1:
117
+ add_simple_table(doc, valores_tabela, header_row=True)
118
+
119
+ tipo_y = str(avaliacao.get("tipo_y") or "").strip().lower()
120
+ rotulo_principal, rotulo_convertido = _rotulo_estimado(tipo_y)
121
+ resultado_tabela = [["Resultado", "Valor"]]
122
+ resultado_tabela.append([rotulo_principal, _formatar_moeda(avaliacao.get("estimado"))])
123
+
124
+ if rotulo_convertido:
125
+ chave_convertida = "estimado_total" if tipo_y == "unitario" else "estimado_unitario"
126
+ if avaliacao.get(chave_convertida) is not None:
127
+ resultado_tabela.append([rotulo_convertido, _formatar_moeda(avaliacao.get(chave_convertida))])
128
+
129
+ resultado_tabela.extend(
130
+ [
131
+ ["Campo de arbítrio -15%", _formatar_moeda(avaliacao.get("ca_inf"))],
132
+ ["Campo de arbítrio +15%", _formatar_moeda(avaliacao.get("ca_sup"))],
133
+ [
134
+ "Intervalo de confiança 80% - inferior",
135
+ f"{_formatar_moeda(avaliacao.get('ic_inf'))} ({_formatar_percentual(avaliacao.get('perc_inf'))})",
136
+ ],
137
+ [
138
+ "Intervalo de confiança 80% - superior",
139
+ f"{_formatar_moeda(avaliacao.get('ic_sup'))} ({_formatar_percentual(avaliacao.get('perc_sup'))})",
140
+ ],
141
+ ["Amplitude do intervalo", _formatar_percentual(avaliacao.get("amplitude"))],
142
+ ["Grau de precisão", str(avaliacao.get("precisao") or "-")],
143
+ ["Grau de fundamentação", str(avaliacao.get("fundamentacao") or "-")],
144
+ ]
145
+ )
146
+
147
+ if bool(avaliacao.get("houve_extrapolacao")):
148
+ resultado_tabela.append(
149
+ ["Quantidade de extrapolações", _formatar_numero(avaliacao.get("qtd_extrapolacoes"), 0)]
150
+ )
151
+ if avaliacao.get("fronteira") is not None:
152
+ resultado_tabela.append(["Valor estimado na fronteira", _formatar_moeda(avaliacao.get("fronteira"))])
153
+ if avaliacao.get("perc_ext") is not None:
154
+ resultado_tabela.append(["Impacto da extrapolação", _formatar_percentual(avaliacao.get("perc_ext"))])
155
+
156
+ add_subsection_title(doc, "", "Resultado da Avaliação")
157
+ add_simple_table(doc, resultado_tabela, header_row=True)
backend/app/core/anexos/estatisticas_utils.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Utilitários para processamento de estatísticas do modelo.
3
+ Funções compartilhadas entre interface_estatisticas e geração de documentos.
4
+ """
5
+ from typing import Dict, Any, Optional
6
+ import numpy as np
7
+
8
+
9
+ def reorganizar_modelos_resumos(resumo_original: Dict[str, Any]) -> Dict[str, Any]:
10
+ """
11
+ Reorganiza a estrutura do dicionário modelos_resumos para uma
12
+ estrutura mais organizada com estatísticas gerais e testes separados.
13
+
14
+ Parâmetros
15
+ ----------
16
+ resumo_original : dict
17
+ Dicionário original com as chaves do modelo.
18
+
19
+ Retorna
20
+ -------
21
+ dict
22
+ Dicionário reorganizado com a nova estrutura.
23
+ """
24
+ if not resumo_original:
25
+ return {}
26
+
27
+ return {
28
+ "estatisticas_gerais": {
29
+ "n": {
30
+ "nome": "Número de observações",
31
+ "valor": resumo_original.get("n")
32
+ },
33
+ "k": {
34
+ "nome": "Número de variáveis independentes",
35
+ "valor": resumo_original.get("k")
36
+ },
37
+ "desvio_padrao_residuos": {
38
+ "nome": "Desvio padrão dos resíduos",
39
+ "valor": resumo_original.get("desvio_padrao_residuos")
40
+ },
41
+ "mse": {
42
+ "nome": "MSE",
43
+ "valor": resumo_original.get("mse")
44
+ },
45
+ "r2": {
46
+ "nome": "R²",
47
+ "valor": resumo_original.get("r2")
48
+ },
49
+ "r2_ajustado": {
50
+ "nome": "R² ajustado",
51
+ "valor": resumo_original.get("r2_ajustado")
52
+ },
53
+ "r_pearson": {
54
+ "nome": "Correlação Pearson",
55
+ "valor": resumo_original.get("r_pearson")
56
+ }
57
+ },
58
+ "teste_f": {
59
+ "nome": "Teste F",
60
+ "estatistica": resumo_original.get("Fc"),
61
+ "pvalor": resumo_original.get("p_valor_F"),
62
+ "interpretacao": resumo_original.get("Interpretacao_F")
63
+ },
64
+ "teste_ks": {
65
+ "nome": "Teste de Normalidade (Kolmogorov-Smirnov)",
66
+ "estatistica": resumo_original.get("ks_stat"),
67
+ "pvalor": resumo_original.get("ks_p"),
68
+ "interpretacao": resumo_original.get("Interpretacao_KS")
69
+ },
70
+ "perc_resid": {
71
+ "nome": "Teste de Normalidade (Comparação com a Curva Normal)",
72
+ "valor": resumo_original.get("perc_resid"),
73
+ "interpretacao": [
74
+ "Ideal 68% → aceitável entre 64% e 75%",
75
+ "Ideal 90% → aceitável entre 88% e 95%",
76
+ "Ideal 95% → aceitável entre 95% e 100%"
77
+ ]
78
+ },
79
+ "teste_dw": {
80
+ "nome": "Teste de Autocorrelação (Durbin-Watson)",
81
+ "estatistica": resumo_original.get("dw"),
82
+ "interpretacao": resumo_original.get("Interpretacao_DW")
83
+ },
84
+ "teste_bp": {
85
+ "nome": "Teste de Homocedasticidade (Breusch-Pagan)",
86
+ "estatistica": resumo_original.get("bp_lm"),
87
+ "pvalor": resumo_original.get("bp_p"),
88
+ "interpretacao": resumo_original.get("Interpretacao_BP")
89
+ },
90
+ "equacao": resumo_original.get("equacao")
91
+ }
92
+
93
+
94
+ def formatar_numero(valor: Any, casas_decimais: int = 4) -> str:
95
+ """
96
+ Formata número com casas decimais específicas.
97
+
98
+ Parâmetros
99
+ ----------
100
+ valor : Any
101
+ Valor a ser formatado.
102
+ casas_decimais : int
103
+ Número de casas decimais (default: 4).
104
+
105
+ Retorna
106
+ -------
107
+ str
108
+ Valor formatado como string.
109
+ """
110
+ if valor is None:
111
+ return "N/A"
112
+ if isinstance(valor, (int, float, np.floating)):
113
+ return f"{valor:.{casas_decimais}f}"
114
+ return str(valor)
backend/app/core/anexos/formatters/__init__.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Formatadores de documentos DOCX.
3
+ """
4
+ from .paragraph import (
5
+ aplicar_cor_run,
6
+ criar_paragrafo_formatado,
7
+ add_body_text,
8
+ add_placeholder_text,
9
+ add_bullet_text,
10
+ )
11
+ from .heading import (
12
+ add_heading_custom,
13
+ add_section_title,
14
+ add_subsection_title,
15
+ add_subsubsection_title,
16
+ add_subsubsubsection_title,
17
+ )
18
+ from .table import (
19
+ set_cell_shading,
20
+ formatar_celula_tabela,
21
+ add_simple_table,
22
+ configurar_linha_tabela_altura,
23
+ configurar_linha_cabecalho_repetir,
24
+ criar_celula_cabecalho_tabela,
25
+ criar_celula_dados_tabela,
26
+ )
27
+ from .image import (
28
+ inserir_imagem_de_documento,
29
+ add_image_placeholder,
30
+ )
31
+ from .section import (
32
+ iniciar_secao_paisagem,
33
+ iniciar_secao_retrato,
34
+ )
35
+
36
+ __all__ = [
37
+ # Parágrafos
38
+ 'aplicar_cor_run',
39
+ 'criar_paragrafo_formatado',
40
+ 'add_body_text',
41
+ 'add_placeholder_text',
42
+ 'add_bullet_text',
43
+ # Títulos
44
+ 'add_heading_custom',
45
+ 'add_section_title',
46
+ 'add_subsection_title',
47
+ 'add_subsubsection_title',
48
+ 'add_subsubsubsection_title',
49
+ # Tabelas
50
+ 'set_cell_shading',
51
+ 'formatar_celula_tabela',
52
+ 'add_simple_table',
53
+ 'configurar_linha_tabela_altura',
54
+ 'configurar_linha_cabecalho_repetir',
55
+ 'criar_celula_cabecalho_tabela',
56
+ 'criar_celula_dados_tabela',
57
+ # Imagens
58
+ 'inserir_imagem_de_documento',
59
+ 'add_image_placeholder',
60
+ # Seções
61
+ 'iniciar_secao_paisagem',
62
+ 'iniciar_secao_retrato',
63
+ ]
backend/app/core/anexos/formatters/heading.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Formatadores de títulos e seções.
3
+ """
4
+ from docx import Document
5
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
6
+
7
+ from .paragraph import criar_paragrafo_formatado
8
+
9
+
10
+ def add_heading_custom(doc: Document, text: str, level: int = 1):
11
+ """Adiciona título customizado."""
12
+ configs = {
13
+ 1: {'tamanho': 14, 'antes': 18, 'depois': 12},
14
+ 2: {'tamanho': 12, 'antes': 12, 'depois': 6},
15
+ }
16
+ cfg = configs.get(level, {'tamanho': 11, 'antes': 6, 'depois': 6})
17
+
18
+ return criar_paragrafo_formatado(
19
+ doc, text, negrito=True, sublinhado=True, tamanho=cfg['tamanho'],
20
+ espaco_antes=cfg['antes'], espaco_depois=cfg['depois'],
21
+ alinhamento=WD_ALIGN_PARAGRAPH.LEFT
22
+ )
23
+
24
+
25
+ def add_section_title(doc: Document, number: str, text: str):
26
+ """Adiciona título de seção principal (ex: 1. SOLICITAÇÃO)."""
27
+ return criar_paragrafo_formatado(
28
+ doc, f"{number}. {text}", negrito=True, tamanho=12,
29
+ espaco_antes=18, espaco_depois=12, alinhamento=WD_ALIGN_PARAGRAPH.LEFT
30
+ )
31
+
32
+
33
+ def add_subsection_title(doc: Document, number: str, text: str):
34
+ """Adiciona título de subseção (ex: 1.1 Considerações)."""
35
+ return criar_paragrafo_formatado(
36
+ doc, f"{number} {text}", negrito=True, tamanho=11,
37
+ espaco_antes=12, espaco_depois=6, recuo_esq=0.3,
38
+ alinhamento=WD_ALIGN_PARAGRAPH.LEFT
39
+ )
40
+
41
+
42
+ def add_subsubsection_title(doc: Document, number: str, text: str):
43
+ """Adiciona título de sub-subseção (ex: 3.2.1)."""
44
+ return criar_paragrafo_formatado(
45
+ doc, f"{number} {text}", negrito=True, tamanho=11,
46
+ espaco_antes=10, espaco_depois=4, recuo_esq=0.5,
47
+ alinhamento=WD_ALIGN_PARAGRAPH.LEFT
48
+ )
49
+
50
+
51
+ def add_subsubsubsection_title(doc: Document, number: str, text: str):
52
+ """Adiciona título de sub-sub-subseção (ex: 3.2.1.1)."""
53
+ return criar_paragrafo_formatado(
54
+ doc, f"{number} {text}", negrito=True, tamanho=10,
55
+ espaco_antes=8, espaco_depois=4, recuo_esq=0.7,
56
+ alinhamento=WD_ALIGN_PARAGRAPH.LEFT
57
+ )
backend/app/core/anexos/formatters/image.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Formatadores de imagens.
3
+ """
4
+ from io import BytesIO
5
+ from docx import Document
6
+ from docx.shared import Inches
7
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
8
+ from typing import Dict, Any, Optional
9
+
10
+
11
+ def inserir_imagem_de_documento(
12
+ doc: Document,
13
+ imagem_data: Optional[Dict[str, Any]],
14
+ largura_inches: float = 5.5
15
+ ) -> bool:
16
+ """
17
+ Insere uma imagem extraída de outro documento.
18
+
19
+ Args:
20
+ doc: Documento destino
21
+ imagem_data: Dict com 'paragraph_element' e 'doc' (documento origem)
22
+ largura_inches: Largura da imagem
23
+
24
+ Returns:
25
+ True se inseriu com sucesso, False caso contrário
26
+ """
27
+ if not imagem_data:
28
+ return False
29
+
30
+ try:
31
+ para_elem = imagem_data['paragraph_element']
32
+ doc_origem = imagem_data['doc']
33
+
34
+ ns_a = '{http://schemas.openxmlformats.org/drawingml/2006/main}'
35
+ ns_r = '{http://schemas.openxmlformats.org/officeDocument/2006/relationships}'
36
+
37
+ for blip in para_elem.iter(f'{ns_a}blip'):
38
+ embed_id = blip.get(f'{ns_r}embed')
39
+ if embed_id:
40
+ try:
41
+ image_part = doc_origem.part.related_parts[embed_id]
42
+ image_stream = BytesIO(image_part.blob)
43
+
44
+ p = doc.add_paragraph()
45
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
46
+ run = p.add_run()
47
+ run.add_picture(image_stream, width=Inches(largura_inches))
48
+ return True
49
+ except Exception as e:
50
+ print(f"Erro ao copiar imagem: {e}")
51
+ return False
52
+ except Exception as e:
53
+ print(f"Erro ao processar imagem: {e}")
54
+ return False
55
+
56
+ return False
57
+
58
+
59
+ def add_image_placeholder(doc: Document) -> None:
60
+ """Adiciona placeholder de imagem."""
61
+ p = doc.add_paragraph()
62
+ p.add_run("[IMAGEM]").italic = True
63
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
backend/app/core/anexos/formatters/paragraph.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Formatadores de parágrafos.
3
+ """
4
+ from docx import Document
5
+ from docx.shared import Pt, Inches, Cm, RGBColor
6
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
7
+ from typing import Optional, Tuple, Union
8
+
9
+
10
+ def aplicar_cor_run(run, cor: Optional[Union[Tuple[int, int, int], RGBColor]]) -> None:
11
+ """
12
+ Aplica cor RGB a um run.
13
+
14
+ Args:
15
+ run: Run do python-docx
16
+ cor: tuple (r,g,b) ou RGBColor
17
+ """
18
+ if cor:
19
+ if isinstance(cor, tuple):
20
+ run.font.color.rgb = RGBColor(cor[0], cor[1], cor[2])
21
+ else:
22
+ run.font.color.rgb = cor
23
+
24
+
25
+ def criar_paragrafo_formatado(
26
+ doc: Document,
27
+ texto: str,
28
+ negrito: bool = False,
29
+ sublinhado: bool = False,
30
+ italico: bool = False,
31
+ tamanho: int = 11,
32
+ cor: Optional[Union[Tuple[int, int, int], RGBColor]] = None,
33
+ alinhamento=WD_ALIGN_PARAGRAPH.JUSTIFY,
34
+ espaco_antes: int = 0,
35
+ espaco_depois: int = 6,
36
+ recuo_esq: float = 0,
37
+ recuo_primeira_linha: float = 0,
38
+ fonte: str = 'Arial'
39
+ ):
40
+ """
41
+ Cria um parágrafo com formatação completa.
42
+
43
+ Args:
44
+ doc: Documento python-docx
45
+ texto: Texto do parágrafo
46
+ negrito, sublinhado, italico: Formatação do texto
47
+ tamanho: Tamanho da fonte em pontos
48
+ cor: RGBColor ou tuple (r,g,b)
49
+ alinhamento: WD_ALIGN_PARAGRAPH constant
50
+ espaco_antes, espaco_depois: Espaçamento em pontos
51
+ recuo_esq: Recuo esquerdo em inches
52
+ recuo_primeira_linha: Recuo da primeira linha em cm
53
+ fonte: Nome da fonte
54
+
55
+ Returns:
56
+ Parágrafo criado
57
+ """
58
+ p = doc.add_paragraph()
59
+ run = p.add_run(texto)
60
+
61
+ run.bold = negrito
62
+ run.underline = sublinhado
63
+ run.italic = italico
64
+ run.font.size = Pt(tamanho)
65
+ run.font.name = fonte
66
+ aplicar_cor_run(run, cor)
67
+
68
+ p.alignment = alinhamento
69
+ p.paragraph_format.space_before = Pt(espaco_antes)
70
+ p.paragraph_format.space_after = Pt(espaco_depois)
71
+ if recuo_esq:
72
+ p.paragraph_format.left_indent = Inches(recuo_esq)
73
+ if recuo_primeira_linha:
74
+ p.paragraph_format.first_line_indent = Cm(recuo_primeira_linha)
75
+
76
+ return p
77
+
78
+
79
+ def add_body_text(doc: Document, text: str, cor=None, indent: bool = True):
80
+ """Adiciona texto do corpo com formatação padrão."""
81
+ return criar_paragrafo_formatado(
82
+ doc, text, tamanho=11, cor=cor,
83
+ espaco_depois=6, recuo_esq=0.3 if indent else 0,
84
+ recuo_primeira_linha=1.25 if indent else 0
85
+ )
86
+
87
+
88
+ def add_placeholder_text(doc: Document, text: str = "[Preencher informações]", indent: bool = True):
89
+ """Adiciona texto placeholder em vermelho."""
90
+ return criar_paragrafo_formatado(
91
+ doc, text, tamanho=11, cor=RGBColor(255, 0, 0),
92
+ espaco_depois=6, recuo_esq=0.3 if indent else 0,
93
+ recuo_primeira_linha=1.25 if indent else 0
94
+ )
95
+
96
+
97
+ def add_bullet_text(doc: Document, text: str, indent_level: int = 1):
98
+ """Adiciona texto com bullet/recuo."""
99
+ return criar_paragrafo_formatado(
100
+ doc, f"• {text}", tamanho=11,
101
+ espaco_depois=3, recuo_esq=0.3 + (indent_level * 0.2)
102
+ )
backend/app/core/anexos/formatters/section.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Formatadores de seções e orientação de página.
3
+ """
4
+ from docx import Document
5
+ from docx.shared import Cm
6
+ from docx.enum.section import WD_ORIENT
7
+
8
+
9
+ def iniciar_secao_paisagem(doc: Document) -> None:
10
+ """
11
+ Adiciona uma nova seção com orientação paisagem.
12
+
13
+ Args:
14
+ doc: Documento DOCX
15
+ """
16
+ # Adicionar quebra de seção
17
+ new_section = doc.add_section()
18
+
19
+ # Configurar orientação paisagem
20
+ new_section.orientation = WD_ORIENT.LANDSCAPE
21
+
22
+ # Trocar largura e altura para paisagem
23
+ new_width = new_section.page_height
24
+ new_height = new_section.page_width
25
+ new_section.page_width = new_width
26
+ new_section.page_height = new_height
27
+
28
+ # Ajustar margens para paisagem
29
+ new_section.top_margin = Cm(2)
30
+ new_section.bottom_margin = Cm(2)
31
+ new_section.left_margin = Cm(2)
32
+ new_section.right_margin = Cm(2)
33
+
34
+
35
+ def iniciar_secao_retrato(doc: Document) -> None:
36
+ """
37
+ Adiciona uma nova seção com orientação retrato.
38
+
39
+ Args:
40
+ doc: Documento DOCX
41
+ """
42
+ # Adicionar quebra de seção
43
+ new_section = doc.add_section()
44
+
45
+ # Configurar orientação retrato
46
+ new_section.orientation = WD_ORIENT.PORTRAIT
47
+
48
+ # Trocar largura e altura para retrato (se estava em paisagem)
49
+ if new_section.page_width > new_section.page_height:
50
+ new_width = new_section.page_height
51
+ new_height = new_section.page_width
52
+ new_section.page_width = new_width
53
+ new_section.page_height = new_height
54
+
55
+ # Margens padrão para retrato
56
+ new_section.top_margin = Cm(2.5)
57
+ new_section.bottom_margin = Cm(2.5)
58
+ new_section.left_margin = Cm(3)
59
+ new_section.right_margin = Cm(2)
backend/app/core/anexos/formatters/table.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Formatadores de tabelas.
3
+ """
4
+ from collections.abc import Sequence as SequenceABC
5
+ from docx import Document
6
+ from docx.shared import Pt, Inches, Cm
7
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
8
+ from docx.enum.table import WD_TABLE_ALIGNMENT, WD_CELL_VERTICAL_ALIGNMENT
9
+ from docx.oxml.ns import qn
10
+ from docx.oxml import OxmlElement
11
+ from typing import List, Optional, Sequence, Union, Dict, Any
12
+
13
+ from .paragraph import aplicar_cor_run
14
+
15
+ WD_ALIGN_VERTICAL = WD_CELL_VERTICAL_ALIGNMENT
16
+
17
+
18
+ def set_cell_shading(cell, color: str) -> None:
19
+ """
20
+ Define a cor de fundo de uma célula.
21
+
22
+ Args:
23
+ cell: Célula da tabela
24
+ color: Cor em hex (ex: "E6E6E6")
25
+ """
26
+ shading = OxmlElement('w:shd')
27
+ shading.set(qn('w:fill'), color)
28
+ cell._tc.get_or_add_tcPr().append(shading)
29
+
30
+
31
+ def set_cell_text_rotation(cell, direcao: str = 'btLr') -> None:
32
+ """
33
+ Define a direção/rotação do texto em uma célula.
34
+
35
+ Args:
36
+ cell: Célula da tabela
37
+ direcao: Direção do texto:
38
+ - 'btLr': bottom-to-top, left-to-right (90° - vertical)
39
+ - 'tbRl': top-to-bottom, right-to-left (270°)
40
+ """
41
+ tcPr = cell._tc.get_or_add_tcPr()
42
+ textDirection = OxmlElement('w:textDirection')
43
+ textDirection.set(qn('w:val'), direcao)
44
+ tcPr.append(textDirection)
45
+
46
+
47
+ def set_cell_no_wrap(cell) -> None:
48
+ """Evita quebra automática de linha em uma célula."""
49
+ tcPr = cell._tc.get_or_add_tcPr()
50
+ no_wrap = OxmlElement('w:noWrap')
51
+ no_wrap.set(qn('w:val'), '1')
52
+ tcPr.append(no_wrap)
53
+
54
+
55
+ def formatar_celula_tabela(cell, texto: str, negrito: bool = False, cor=None,
56
+ shading_color: Optional[str] = None, rotacao: bool = False,
57
+ no_wrap: bool = False):
58
+ """
59
+ Formata uma célula de tabela com configurações padrão.
60
+
61
+ Args:
62
+ cell: Célula da tabela
63
+ texto: Texto a inserir
64
+ negrito: Se o texto deve ser negrito
65
+ cor: Cor do texto (tuple ou RGBColor)
66
+ shading_color: Cor de fundo da célula (hex string)
67
+ rotacao: Se True, rotaciona o texto 90° (vertical)
68
+ no_wrap: Se True, evita quebra automática de linha
69
+
70
+ Returns:
71
+ Run criado
72
+ """
73
+ p = cell.paragraphs[0]
74
+
75
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
76
+ p.paragraph_format.left_indent = Cm(0)
77
+ p.paragraph_format.right_indent = Cm(0)
78
+ p.paragraph_format.first_line_indent = Cm(0)
79
+ p.paragraph_format.space_before = Pt(0)
80
+ p.paragraph_format.space_after = Pt(0)
81
+
82
+ cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
83
+
84
+ run = p.add_run(texto)
85
+ run.font.size = Pt(8)
86
+ run.font.name = 'Arial'
87
+ run.bold = negrito
88
+
89
+ if cor:
90
+ aplicar_cor_run(run, cor)
91
+
92
+ if shading_color:
93
+ set_cell_shading(cell, shading_color)
94
+
95
+ if rotacao:
96
+ set_cell_text_rotation(cell)
97
+
98
+ if no_wrap:
99
+ set_cell_no_wrap(cell)
100
+
101
+ return run
102
+
103
+
104
+ def add_simple_table(
105
+ doc: Document,
106
+ dados_tabela: List[List[Union[str, Dict[str, Any]]]],
107
+ header_row: bool = True,
108
+ largura_colunas: Optional[List[float]] = None,
109
+ rotacao_cabecalho: Union[bool, Sequence[bool]] = False,
110
+ largura_total: Optional[int] = None,
111
+ largura_colunas_absolutas: bool = False,
112
+ no_quebrar_linha: bool = False,
113
+ ):
114
+ """
115
+ Adiciona uma tabela simples ao documento.
116
+
117
+ Args:
118
+ doc: Documento
119
+ dados_tabela: Lista de listas. Cada célula pode ser string ou dict {'texto', 'cor'}
120
+ header_row: Se True, primeira linha é cabeçalho (negrito com fundo cinza)
121
+ largura_colunas: Lista de inteiros representando proporção da largura de cada coluna.
122
+ rotacao_cabecalho: Se True, rotaciona todos os cabeçalhos 90°.
123
+ Também aceita lista de booleanos para controlar coluna a coluna.
124
+ largura_total: Largura total disponível para a tabela. Quando omitida, usa 6.5".
125
+ largura_colunas_absolutas: Se True, largura_colunas já contém larguras em EMUs.
126
+ no_quebrar_linha: Se True, evita quebra automática de linha nas células.
127
+
128
+ Returns:
129
+ Tabela criada ou None se dados vazios
130
+ """
131
+ if not dados_tabela or not dados_tabela[0]:
132
+ return None
133
+
134
+ num_colunas = len(dados_tabela[0])
135
+
136
+ if largura_colunas is None:
137
+ largura_colunas = [1] * num_colunas
138
+ elif len(largura_colunas) != num_colunas:
139
+ raise ValueError("largura_colunas deve ter o mesmo número de colunas de dados_tabela")
140
+
141
+ if isinstance(rotacao_cabecalho, SequenceABC) and not isinstance(rotacao_cabecalho, (str, bytes)):
142
+ if len(rotacao_cabecalho) != num_colunas:
143
+ raise ValueError("rotacao_cabecalho deve ter o mesmo número de colunas de dados_tabela")
144
+ rotacoes_cabecalho = [bool(valor) for valor in rotacao_cabecalho]
145
+ else:
146
+ rotacoes_cabecalho = [bool(rotacao_cabecalho)] * num_colunas
147
+
148
+ if largura_total is None:
149
+ largura_total = Inches(6.5)
150
+
151
+ table = doc.add_table(rows=len(dados_tabela), cols=num_colunas)
152
+ table.style = 'Table Grid'
153
+ table.alignment = WD_TABLE_ALIGNMENT.CENTER
154
+ table.autofit = False
155
+
156
+ if largura_colunas_absolutas:
157
+ larguras_em_colunas = [int(largura) for largura in largura_colunas]
158
+ else:
159
+ total = sum(largura_colunas)
160
+ proporcoes = [x / total for x in largura_colunas]
161
+ larguras_em_colunas = [int(largura_total * p) for p in proporcoes]
162
+
163
+ for j, largura in enumerate(larguras_em_colunas):
164
+ table.columns[j].width = largura
165
+
166
+ for i, row_data in enumerate(dados_tabela):
167
+ row = table.rows[i]
168
+ for j, cell_data in enumerate(row_data):
169
+ if j >= len(row.cells):
170
+ continue
171
+
172
+ cell = row.cells[j]
173
+
174
+ if isinstance(cell_data, dict):
175
+ texto = cell_data.get('texto', '')
176
+ cor = cell_data.get('cor')
177
+ else:
178
+ texto = str(cell_data) if cell_data else ""
179
+ cor = None
180
+
181
+ is_header = header_row and i == 0
182
+
183
+ formatar_celula_tabela(
184
+ cell, texto,
185
+ negrito=is_header,
186
+ cor=cor,
187
+ shading_color="E6E6E6" if is_header else None,
188
+ rotacao=is_header and rotacoes_cabecalho[j],
189
+ no_wrap=no_quebrar_linha or is_header,
190
+ )
191
+
192
+ cell.width = larguras_em_colunas[j]
193
+
194
+ if header_row and len(dados_tabela) > 1:
195
+ configurar_linha_cabecalho_repetir(table.rows[0])
196
+
197
+ # Ajustar altura do cabeçalho para texto rotacionado
198
+ if header_row and any(rotacoes_cabecalho) and dados_tabela:
199
+ # Calcular o maior texto do cabeçalho
200
+ header_texts = []
201
+ for j, cell_data in enumerate(dados_tabela[0]):
202
+ if not rotacoes_cabecalho[j]:
203
+ continue
204
+ if isinstance(cell_data, dict):
205
+ header_texts.append(cell_data.get('texto', ''))
206
+ else:
207
+ header_texts.append(str(cell_data) if cell_data else "")
208
+
209
+ if header_texts:
210
+ max_len = max(len(t) for t in header_texts)
211
+ # Cabeçalhos rotacionados precisam de folga extra no Word; uma
212
+ # linha justa corta ou força quebra visual do texto renderizado.
213
+ altura_cm = max(2.8, max_len * 0.25 + 0.9)
214
+ configurar_linha_tabela_altura(table.rows[0], altura_cm)
215
+
216
+ doc.add_paragraph()
217
+ return table
218
+
219
+
220
+ def configurar_linha_tabela_altura(row, altura_cm: float = 0.6) -> None:
221
+ """
222
+ Configura altura mínima de uma linha de tabela.
223
+
224
+ Args:
225
+ row: Linha da tabela
226
+ altura_cm: Altura em centímetros
227
+ """
228
+ tr = row._tr
229
+ trPr = tr.get_or_add_trPr()
230
+ trHeight = OxmlElement('w:trHeight')
231
+ trHeight.set(qn('w:val'), str(int(Cm(altura_cm).twips)))
232
+ trHeight.set(qn('w:hRule'), 'atLeast')
233
+ trPr.append(trHeight)
234
+
235
+
236
+ def configurar_linha_cabecalho_repetir(row) -> None:
237
+ """
238
+ Marca a linha como cabeçalho repetível em quebras de página.
239
+
240
+ Args:
241
+ row: Linha de cabeçalho da tabela
242
+ """
243
+ tr = row._tr
244
+ trPr = tr.get_or_add_trPr()
245
+ tbl_header = OxmlElement('w:tblHeader')
246
+ tbl_header.set(qn('w:val'), '1')
247
+ trPr.append(tbl_header)
248
+
249
+
250
+ def criar_celula_cabecalho_tabela(cell, texto: str, recuo_primeira_linha_cm: float = 1.0) -> None:
251
+ """
252
+ Formata uma célula de cabeçalho de seção na tabela principal.
253
+
254
+ Args:
255
+ cell: Célula da tabela
256
+ texto: Texto do cabeçalho
257
+ recuo_primeira_linha_cm: Recuo da primeira linha
258
+ """
259
+ cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
260
+
261
+ p = cell.paragraphs[0]
262
+ p.paragraph_format.left_indent = Cm(0)
263
+ p.paragraph_format.first_line_indent = Cm(recuo_primeira_linha_cm)
264
+ p.alignment = WD_ALIGN_PARAGRAPH.LEFT
265
+
266
+ run = p.add_run(texto)
267
+ run.bold = True
268
+ run.underline = True
269
+ run.font.size = Pt(8)
270
+ run.font.name = 'Arial'
271
+
272
+ set_cell_shading(cell, "E6E6E6")
273
+
274
+
275
+ def criar_celula_dados_tabela(cell, texto: str, is_label: bool = False) -> None:
276
+ """
277
+ Formata uma célula de dados na tabela principal.
278
+
279
+ Args:
280
+ cell: Célula da tabela
281
+ texto: Texto da célula
282
+ is_label: Se True, formata como rótulo (negrito)
283
+ """
284
+ cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
285
+
286
+ p = cell.paragraphs[0]
287
+ p.paragraph_format.left_indent = Cm(0)
288
+ p.paragraph_format.first_line_indent = Cm(0)
289
+ p.paragraph_format.space_before = Pt(0)
290
+ p.paragraph_format.space_after = Pt(0)
291
+
292
+ run = p.add_run(texto)
293
+ run.bold = is_label
294
+ run.font.size = Pt(8)
295
+ run.font.name = 'Arial'
backend/app/core/anexos/generator.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gerador de documento contendo apenas anexos do modelo selecionado."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from datetime import datetime
6
+ from io import BytesIO
7
+ from pathlib import Path
8
+
9
+ from docx import Document
10
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
11
+ from docx.shared import Cm
12
+
13
+ from .banco_dados import anexo_banco_dados_cabe_na_largura, gerar_anexo_banco_dados
14
+ from .calculo_valor import gerar_anexo_calculo
15
+ from .graficos import gerar_anexo_graficos
16
+ from .planilha_calculo import gerar_anexo_planilha
17
+ from .formatters import (
18
+ criar_paragrafo_formatado,
19
+ iniciar_secao_paisagem,
20
+ iniciar_secao_retrato,
21
+ )
22
+ from .model_data import ModelData
23
+
24
+
25
+ class AnexosGenerator:
26
+ """Gera um DOCX com os anexos técnicos de um modelo .dai."""
27
+
28
+ def __init__(self, output_dir: Path) -> None:
29
+ self.output_dir = output_dir
30
+
31
+ def gerar(
32
+ self,
33
+ modelo: ModelData,
34
+ banco_dados_colunas: list[str] | None = None,
35
+ avaliacao: dict | None = None,
36
+ ) -> Path:
37
+ doc = Document()
38
+ self._configurar_margens(doc)
39
+ self._gerar_anexos(
40
+ doc,
41
+ modelo,
42
+ banco_dados_colunas=banco_dados_colunas,
43
+ avaliacao=avaliacao,
44
+ )
45
+
46
+ self.output_dir.mkdir(parents=True, exist_ok=True)
47
+ output_path = self._gerar_nome_arquivo(modelo.nome)
48
+ doc.save(str(output_path))
49
+ return output_path
50
+
51
+ def gerar_em_memoria(
52
+ self,
53
+ modelo: ModelData,
54
+ banco_dados_colunas: list[str] | None = None,
55
+ avaliacao: dict | None = None,
56
+ ) -> tuple[bytes, str]:
57
+ """Gera o DOCX sem persistir o anexo no filesystem."""
58
+ doc = Document()
59
+ self._configurar_margens(doc)
60
+ self._gerar_anexos(
61
+ doc,
62
+ modelo,
63
+ banco_dados_colunas=banco_dados_colunas,
64
+ avaliacao=avaliacao,
65
+ )
66
+
67
+ buffer = BytesIO()
68
+ doc.save(buffer)
69
+ return buffer.getvalue(), self._gerar_nome_arquivo(modelo.nome).name
70
+
71
+ def _configurar_margens(self, doc: Document) -> None:
72
+ for section in doc.sections:
73
+ section.top_margin = Cm(2.5)
74
+ section.bottom_margin = Cm(2.5)
75
+ section.left_margin = Cm(3)
76
+ section.right_margin = Cm(2)
77
+
78
+ def _criar_titulo_anexo(self, doc: Document, titulo: str) -> None:
79
+ criar_paragrafo_formatado(
80
+ doc,
81
+ titulo,
82
+ negrito=True,
83
+ sublinhado=True,
84
+ tamanho=10,
85
+ espaco_antes=18,
86
+ espaco_depois=12,
87
+ alinhamento=WD_ALIGN_PARAGRAPH.CENTER,
88
+ )
89
+
90
+ def _largura_util_secao_atual(self, doc: Document) -> int:
91
+ section = doc.sections[-1]
92
+ return int(section.page_width - section.left_margin - section.right_margin)
93
+
94
+ def _gerar_anexos(
95
+ self,
96
+ doc: Document,
97
+ modelo: ModelData,
98
+ banco_dados_colunas: list[str] | None = None,
99
+ avaliacao: dict | None = None,
100
+ ) -> None:
101
+ banco_cabe_em_retrato = anexo_banco_dados_cabe_na_largura(
102
+ modelo,
103
+ banco_dados_colunas,
104
+ self._largura_util_secao_atual(doc),
105
+ )
106
+ if not banco_cabe_em_retrato:
107
+ iniciar_secao_paisagem(doc)
108
+
109
+ self._criar_titulo_anexo(doc, "ANEXO I - BANCO DE DADOS")
110
+ gerar_anexo_banco_dados(doc, modelo, colunas=banco_dados_colunas)
111
+
112
+ iniciar_secao_retrato(doc)
113
+ self._criar_titulo_anexo(
114
+ doc,
115
+ "ANEXO II - PLANILHA DE CÁLCULO E RESULTADOS ESTATÍSTICOS",
116
+ )
117
+ gerar_anexo_planilha(doc, modelo)
118
+
119
+ doc.add_page_break()
120
+ self._criar_titulo_anexo(doc, "ANEXO III - GRÁFICOS")
121
+ gerar_anexo_graficos(doc, modelo)
122
+
123
+ doc.add_page_break()
124
+ self._criar_titulo_anexo(doc, "ANEXO IV - CÁLCULO DO VALOR")
125
+ gerar_anexo_calculo(doc, modelo, avaliacao=avaliacao)
126
+
127
+ def _gerar_nome_arquivo(self, modelo_nome: str) -> Path:
128
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
129
+ modelo_safe = re.sub(r"[^\w\-]", "_", modelo_nome)
130
+ return self.output_dir / f"Anexos_{modelo_safe}_{timestamp}.docx"
backend/app/core/anexos/graficos.py ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gerador do Anexo de Gráficos.
3
+ Insere gráficos de diagnóstico do modelo no documento.
4
+ Usa Matplotlib para geração de imagens sem dependências externas.
5
+ """
6
+ import io
7
+ from typing import Optional, List, Tuple
8
+ from docx import Document
9
+ from docx.shared import Inches
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+ from scipy import stats
14
+ import matplotlib
15
+ matplotlib.use('Agg') # Backend não-interativo
16
+ import matplotlib.pyplot as plt
17
+
18
+ from .model_data import ModelData
19
+ from .formatters import (
20
+ add_placeholder_text,
21
+ )
22
+
23
+
24
+ # Cores consistentes com a interface
25
+ COR_PRINCIPAL = '#FF8C00' # Laranja
26
+ COR_LINHA = '#dc3545' # Vermelho para linhas de referência
27
+ COR_GRID = '#d3d3d3' # Cinza claro para grid
28
+
29
+
30
+ def _configurar_estilo_grafico(ax, titulo: str, xlabel: str, ylabel: str) -> None:
31
+ """Aplica estilo consistente aos gráficos."""
32
+ ax.set_title(titulo, fontsize=14, fontweight='bold', color='black', pad=15)
33
+ ax.set_xlabel(xlabel, fontsize=11)
34
+ ax.set_ylabel(ylabel, fontsize=11)
35
+ ax.set_facecolor('white')
36
+ ax.grid(True, linestyle='-', linewidth=0.5, color=COR_GRID, alpha=0.7)
37
+ ax.spines['top'].set_visible(True)
38
+ ax.spines['right'].set_visible(True)
39
+ ax.spines['bottom'].set_color('black')
40
+ ax.spines['left'].set_color('black')
41
+ ax.spines['top'].set_color('black')
42
+ ax.spines['right'].set_color('black')
43
+
44
+
45
+ def _salvar_figura(fig) -> io.BytesIO:
46
+ """Salva figura matplotlib em buffer de bytes."""
47
+ buf = io.BytesIO()
48
+ fig.savefig(buf, format='png', dpi=150, bbox_inches='tight',
49
+ facecolor='white', edgecolor='none')
50
+ buf.seek(0)
51
+ plt.close(fig)
52
+ return buf
53
+
54
+
55
+ def gerar_anexo_graficos(doc: Document, modelo: Optional[ModelData]) -> None:
56
+ """
57
+ Gera o conteúdo do Anexo de Gráficos.
58
+
59
+ Args:
60
+ doc: Documento DOCX
61
+ modelo: Dados do modelo carregado do .dai
62
+ """
63
+ if modelo is None:
64
+ add_placeholder_text(doc, "[Modelo não selecionado - inserir gráficos manualmente]")
65
+ return
66
+
67
+ graficos_inseridos = 0
68
+
69
+ # Tentar gerar gráficos a partir dos dados do modelo
70
+ if modelo.modelos_sm is not None or not modelo.tabelas_obs_calc.empty:
71
+ graficos = _gerar_graficos_diagnostico(modelo)
72
+
73
+ for titulo, img_buffer in graficos:
74
+ doc.add_picture(img_buffer, width=Inches(5.5))
75
+ graficos_inseridos += 1
76
+
77
+ if graficos_inseridos == 0:
78
+ add_placeholder_text(doc, "[Gráficos não disponíveis no modelo]")
79
+
80
+
81
+ def _gerar_graficos_diagnostico(modelo: ModelData) -> List[Tuple[str, io.BytesIO]]:
82
+ """
83
+ Gera gráficos de diagnóstico do modelo de regressão usando Matplotlib.
84
+
85
+ Args:
86
+ modelo: Dados do modelo
87
+
88
+ Returns:
89
+ Lista de tuplas (título, buffer de imagem)
90
+ """
91
+ graficos = []
92
+
93
+ # Obter dados para os gráficos
94
+ obs_calc = modelo.tabelas_obs_calc
95
+ modelos_sm = modelo.modelos_sm
96
+
97
+ # Identificar colunas de valores observados e calculados
98
+ y_obs = None
99
+ y_calc = None
100
+ residuos = None
101
+
102
+ if not obs_calc.empty:
103
+ # Tentar identificar colunas
104
+ cols_lower = {c.lower(): c for c in obs_calc.columns}
105
+
106
+ for nome_obs in ['observado', 'obs', 'y_obs', 'y', 'valor_observado']:
107
+ if nome_obs in cols_lower:
108
+ y_obs = obs_calc[cols_lower[nome_obs]].values
109
+ break
110
+
111
+ for nome_calc in ['calculado', 'calc', 'y_calc', 'y_hat', 'previsto', 'predicted', 'valor_calculado']:
112
+ if nome_calc in cols_lower:
113
+ y_calc = obs_calc[cols_lower[nome_calc]].values
114
+ break
115
+
116
+ for nome_res in ['residuo', 'residuos', 'resid', 'residual']:
117
+ if nome_res in cols_lower:
118
+ residuos = obs_calc[cols_lower[nome_res]].values
119
+ break
120
+
121
+ # Se temos statsmodels result, extrair dados dele
122
+ if modelos_sm is not None:
123
+ try:
124
+ if y_obs is None and hasattr(modelos_sm, 'model') and hasattr(modelos_sm.model, 'endog'):
125
+ y_obs = modelos_sm.model.endog
126
+ if y_calc is None and hasattr(modelos_sm, 'fittedvalues'):
127
+ y_calc = modelos_sm.fittedvalues
128
+ if residuos is None and hasattr(modelos_sm, 'resid'):
129
+ residuos = modelos_sm.resid
130
+ except Exception:
131
+ pass
132
+
133
+ # Calcular resíduos se temos obs e calc
134
+ if residuos is None and y_obs is not None and y_calc is not None:
135
+ residuos = np.array(y_obs) - np.array(y_calc)
136
+
137
+ # Converter para arrays numpy se necessário
138
+ if y_obs is not None:
139
+ y_obs = np.array(y_obs)
140
+ if y_calc is not None:
141
+ y_calc = np.array(y_calc)
142
+ if residuos is not None:
143
+ residuos = np.array(residuos)
144
+
145
+ # Gráfico 1: Valores Observados vs Calculados
146
+ if y_obs is not None and y_calc is not None:
147
+ graf = _criar_grafico_obs_calc(y_obs, y_calc)
148
+ if graf:
149
+ graficos.append(("Valores Observados vs Calculados", graf))
150
+
151
+ # Gráfico 2: Resíduos vs Valores Ajustados
152
+ if residuos is not None and y_calc is not None:
153
+ graf = _criar_grafico_residuos(y_calc, residuos)
154
+ if graf:
155
+ graficos.append(("Resíduos vs Valores Ajustados", graf))
156
+
157
+ # Gráfico 3: Histograma dos Resíduos
158
+ if residuos is not None:
159
+ graf = _criar_histograma_residuos(residuos)
160
+ if graf:
161
+ graficos.append(("Distribuição dos Resíduos", graf))
162
+
163
+ # Gráfico 4: Distância de Cook
164
+ if modelos_sm is not None:
165
+ graf = _criar_grafico_cook(modelos_sm)
166
+ if graf:
167
+ graficos.append(("Distância de Cook", graf))
168
+
169
+ # Gráfico 5: Matriz de Correlação
170
+ if modelos_sm is not None:
171
+ graf = _criar_grafico_correlacao(modelos_sm)
172
+ if graf:
173
+ graficos.append(("Matriz de Correlação", graf))
174
+
175
+ return graficos
176
+
177
+
178
+ def _criar_grafico_obs_calc(y_obs: np.ndarray, y_calc: np.ndarray) -> Optional[io.BytesIO]:
179
+ """Cria gráfico de valores observados vs calculados."""
180
+ try:
181
+ fig, ax = plt.subplots(figsize=(8, 5))
182
+
183
+ # Scatter plot
184
+ ax.scatter(y_calc, y_obs, c=COR_PRINCIPAL, s=60, edgecolors='black',
185
+ linewidths=0.5, alpha=0.8, label='Dados', zorder=3)
186
+
187
+ # Linha de identidade (45 graus)
188
+ min_val = min(min(y_obs), min(y_calc))
189
+ max_val = max(max(y_obs), max(y_calc))
190
+ margin = (max_val - min_val) * 0.05
191
+
192
+ ax.plot([min_val - margin, max_val + margin],
193
+ [min_val - margin, max_val + margin],
194
+ color=COR_LINHA, linestyle='--', linewidth=2,
195
+ label='Linha de identidade', zorder=2)
196
+
197
+ ax.set_xlim(min_val - margin, max_val + margin)
198
+ ax.set_ylim(min_val - margin, max_val + margin)
199
+
200
+ _configurar_estilo_grafico(ax, 'Valores Observados vs Calculados',
201
+ 'Valores Calculados', 'Valores Observados')
202
+ ax.legend(loc='upper left', framealpha=0.9)
203
+
204
+ return _salvar_figura(fig)
205
+ except Exception as e:
206
+ print(f"Erro ao criar gráfico obs vs calc: {e}")
207
+ return None
208
+
209
+
210
+ def _criar_grafico_residuos(y_calc: np.ndarray, residuos: np.ndarray) -> Optional[io.BytesIO]:
211
+ """Cria gráfico de resíduos vs valores ajustados."""
212
+ try:
213
+ fig, ax = plt.subplots(figsize=(8, 5))
214
+
215
+ # Scatter plot
216
+ ax.scatter(y_calc, residuos, c=COR_PRINCIPAL, s=60, edgecolors='black',
217
+ linewidths=0.5, alpha=0.8, zorder=3)
218
+
219
+ # Linha horizontal em y=0
220
+ ax.axhline(y=0, color=COR_LINHA, linestyle='--', linewidth=2, zorder=2)
221
+
222
+ _configurar_estilo_grafico(ax, 'Resíduos vs Valores Ajustados',
223
+ 'Valores Ajustados', 'Resíduos')
224
+
225
+ return _salvar_figura(fig)
226
+ except Exception as e:
227
+ print(f"Erro ao criar gráfico de resíduos: {e}")
228
+ return None
229
+
230
+
231
+ def _criar_histograma_residuos(residuos: np.ndarray) -> Optional[io.BytesIO]:
232
+ """Cria histograma dos resíduos com curva normal."""
233
+ try:
234
+ fig, ax = plt.subplots(figsize=(8, 5))
235
+
236
+ # Histograma
237
+ n, bins, patches = ax.hist(residuos, bins='auto', density=True,
238
+ color=COR_PRINCIPAL, edgecolor='black',
239
+ linewidth=0.5, alpha=0.7, label='Resíduos',
240
+ zorder=3)
241
+
242
+ # Curva normal ajustada
243
+ mu, sigma = np.mean(residuos), np.std(residuos)
244
+ x_norm = np.linspace(min(residuos) - sigma, max(residuos) + sigma, 100)
245
+ y_norm = stats.norm.pdf(x_norm, mu, sigma)
246
+
247
+ ax.plot(x_norm, y_norm, color=COR_LINHA, linewidth=3,
248
+ label='Curva Normal', zorder=4)
249
+
250
+ _configurar_estilo_grafico(ax, 'Distribuição dos Resíduos',
251
+ 'Resíduos', 'Densidade')
252
+ ax.legend(loc='upper right', framealpha=0.9)
253
+
254
+ return _salvar_figura(fig)
255
+ except Exception as e:
256
+ print(f"Erro ao criar histograma: {e}")
257
+ return None
258
+
259
+
260
+ def _criar_grafico_cook(modelos_sm) -> Optional[io.BytesIO]:
261
+ """Cria gráfico de Distância de Cook."""
262
+ try:
263
+ from statsmodels.stats.outliers_influence import OLSInfluence
264
+
265
+ influence = OLSInfluence(modelos_sm)
266
+ cooks_d = influence.cooks_distance[0]
267
+
268
+ n = len(cooks_d)
269
+ indices = np.arange(1, n + 1)
270
+
271
+ # Limite de referência: 4/n
272
+ limite = 4 / n
273
+
274
+ fig, ax = plt.subplots(figsize=(8, 5))
275
+
276
+ # Cores baseadas no limite
277
+ cores = np.array([COR_LINHA if v > limite else COR_PRINCIPAL for v in cooks_d])
278
+
279
+ # Desenhar linhas verticais (stems) individualmente
280
+ for i, (idx, valor, cor) in enumerate(zip(indices, cooks_d, cores)):
281
+ ax.vlines(x=idx, ymin=0, ymax=valor, colors=cor, linewidth=1.5, zorder=2)
282
+
283
+ # Adicionar marcadores no topo
284
+ ax.scatter(indices, cooks_d, c=cores, s=50, edgecolors='black',
285
+ linewidths=0.5, zorder=3)
286
+
287
+ # Linha de referência horizontal (4/n)
288
+ ax.axhline(y=limite, color='gray', linestyle='--', linewidth=1.5,
289
+ label=f'4/n = {limite:.4f}', zorder=4)
290
+
291
+ ax.set_ylim(bottom=0)
292
+ ax.set_xlim(0, n + 1)
293
+
294
+ _configurar_estilo_grafico(ax, 'Distância de Cook',
295
+ 'Observação', 'Distância de Cook')
296
+ ax.legend(loc='upper right', framealpha=0.9)
297
+
298
+ return _salvar_figura(fig)
299
+ except Exception as e:
300
+ print(f"Erro ao criar gráfico de Cook: {e}")
301
+ return None
302
+
303
+
304
+ def _criar_grafico_correlacao(modelos_sm) -> Optional[io.BytesIO]:
305
+ """
306
+ Gera um heatmap de correlação entre a variável dependente e as variáveis explicativas.
307
+ """
308
+ try:
309
+ if not hasattr(modelos_sm, 'model'):
310
+ return None
311
+
312
+ model = modelos_sm.model
313
+
314
+ if not hasattr(model, 'exog') or not hasattr(model, 'endog'):
315
+ return None
316
+
317
+ X = model.exog
318
+ X_names = model.exog_names
319
+ y = model.endog
320
+ y_name = getattr(model, 'endog_names', 'Var. Dep.')
321
+
322
+ df_X = pd.DataFrame(X, columns=X_names)
323
+
324
+ # Remover constantes explícitas
325
+ df_X = df_X.drop(
326
+ columns=[c for c in df_X.columns if c.lower() in ('const', 'intercept')],
327
+ errors='ignore'
328
+ )
329
+
330
+ df_y = pd.DataFrame({y_name: y})
331
+
332
+ df = pd.concat([df_y, df_X], axis=1)
333
+
334
+ # Remover colunas realmente constantes
335
+ variancias = df.var(ddof=0)
336
+ df = df.loc[:, variancias.fillna(0) > 0]
337
+
338
+ if df.shape[1] < 2:
339
+ return None
340
+
341
+ corr = df.corr()
342
+
343
+ if corr.isnull().values.all():
344
+ return None
345
+
346
+ # Calcular tamanho da figura baseado no número de variáveis
347
+ n_vars = len(corr.columns)
348
+ fig_size = max(8, n_vars * 0.8)
349
+
350
+ fig, ax = plt.subplots(figsize=(fig_size, fig_size))
351
+
352
+ # Heatmap
353
+ im = ax.imshow(corr.values, cmap='RdBu_r', vmin=-1, vmax=1, aspect='auto')
354
+
355
+ # Adicionar colorbar
356
+ cbar = fig.colorbar(im, ax=ax, shrink=0.8)
357
+ cbar.set_label('Correlação', fontsize=11)
358
+
359
+ # Configurar ticks
360
+ ax.set_xticks(np.arange(len(corr.columns)))
361
+ ax.set_yticks(np.arange(len(corr.columns)))
362
+ ax.set_xticklabels(corr.columns, rotation=45, ha='right', fontsize=9)
363
+ ax.set_yticklabels(corr.columns, fontsize=9)
364
+
365
+ # Adicionar valores nas células
366
+ for i in range(len(corr.columns)):
367
+ for j in range(len(corr.columns)):
368
+ valor = corr.values[i, j]
369
+ # Cor do texto baseada no valor
370
+ cor_texto = 'white' if abs(valor) > 0.5 else 'black'
371
+ ax.text(j, i, f'{valor:.2f}', ha='center', va='center',
372
+ color=cor_texto, fontsize=9)
373
+
374
+ ax.set_title('Matriz de Correlação\n(Variável Dependente e Regressoras)',
375
+ fontsize=14, fontweight='bold', pad=15)
376
+
377
+ plt.tight_layout()
378
+
379
+ return _salvar_figura(fig)
380
+ except Exception as e:
381
+ print(f"Erro ao criar gráfico de correlação: {e}")
382
+ return None
backend/app/core/anexos/model_data.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Dataclass tipada para os dados do modelo de avaliação.
3
+ Os dados são carregados de arquivos .dai (joblib).
4
+ """
5
+ from dataclasses import dataclass, field
6
+ from typing import Dict, List, Any, Optional
7
+ from pathlib import Path
8
+
9
+ import pandas as pd
10
+
11
+
12
+ @dataclass
13
+ class ModelData:
14
+ """Representa os dados estatísticos de um modelo de avaliação."""
15
+
16
+ nome: str
17
+ path: Path
18
+
19
+ # Dados do banco de dados
20
+ xy_preview: pd.DataFrame = field(default_factory=pd.DataFrame)
21
+ top_x_esc: pd.DataFrame = field(default_factory=pd.DataFrame)
22
+ top_y_esc: pd.Series = field(default_factory=pd.Series)
23
+
24
+ # Estatísticas descritivas
25
+ estatisticas: pd.DataFrame = field(default_factory=pd.DataFrame)
26
+
27
+ # Modelo de regressão
28
+ tabelas_coef: pd.DataFrame = field(default_factory=pd.DataFrame)
29
+ tabelas_obs_calc: pd.DataFrame = field(default_factory=pd.DataFrame)
30
+ modelos_resumos: Dict[str, Any] = field(default_factory=dict)
31
+ modelos_sm: Any = None # RegressionResultsWrapper
32
+
33
+ # Transformações e gráficos
34
+ formatted_top_transformation_info: List[Any] = field(default_factory=list)
35
+ variaveis_dicotomicas: List[str] = field(default_factory=list)
36
+ graf_model: str = ""
37
+
38
+ @property
39
+ def r2(self) -> Optional[float]:
40
+ """Retorna o coeficiente de determinação R²."""
41
+ return self.modelos_resumos.get('r2')
42
+
43
+ @property
44
+ def r2_ajustado(self) -> Optional[float]:
45
+ """Retorna o R² ajustado."""
46
+ return self.modelos_resumos.get('r2_ajustado')
47
+
48
+ @property
49
+ def equacao(self) -> Optional[str]:
50
+ """Retorna a equação do modelo."""
51
+ return self.modelos_resumos.get('equacao')
52
+
53
+ @property
54
+ def variaveis(self) -> List[str]:
55
+ """Retorna lista de variáveis do modelo."""
56
+ if self.tabelas_coef.empty:
57
+ return []
58
+ return [v for v in self.tabelas_coef.index.tolist() if v != 'const']
59
+
60
+ @property
61
+ def n_amostras(self) -> int:
62
+ """Retorna número de amostras no banco de dados."""
63
+ return self.modelos_resumos.get('n', 0)
64
+
65
+ @property
66
+ def n_variaveis(self) -> int:
67
+ """Retorna número de variáveis do modelo."""
68
+ return self.modelos_resumos.get('k', 0)
backend/app/core/anexos/model_loader.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Carregador de modelos de avaliação.
3
+ Carrega arquivos .dai via joblib.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ import joblib
11
+ import pandas as pd
12
+
13
+ from .model_data import ModelData
14
+
15
+
16
+ def _get_first(container: dict[str, Any], *keys: str, default: Any = None) -> Any:
17
+ """Retorna o primeiro valor não-nulo encontrado nas chaves informadas."""
18
+ for key in keys:
19
+ if key in container and container[key] is not None:
20
+ return container[key]
21
+ return default
22
+
23
+
24
+ def _as_dict(value: Any) -> dict[str, Any]:
25
+ """Garante retorno como dicionário."""
26
+ return value if isinstance(value, dict) else {}
27
+
28
+
29
+ def _as_dataframe(value: Any) -> pd.DataFrame:
30
+ """Converte entrada em DataFrame quando possível."""
31
+ if isinstance(value, pd.DataFrame):
32
+ return value
33
+ if isinstance(value, pd.Series):
34
+ return value.to_frame()
35
+ return pd.DataFrame()
36
+
37
+
38
+ def _as_series(value: Any) -> pd.Series:
39
+ """Converte entrada em Series quando possível."""
40
+ if isinstance(value, pd.Series):
41
+ return value
42
+ if isinstance(value, pd.DataFrame) and value.shape[1] == 1:
43
+ return value.iloc[:, 0]
44
+ return pd.Series(dtype=float)
45
+
46
+
47
+ def _as_string_list(value: Any) -> list[str]:
48
+ """Converte listas/tuplas/índices/séries em lista de strings."""
49
+ if value is None:
50
+ return []
51
+ if isinstance(value, pd.Series):
52
+ value = value.tolist()
53
+ elif isinstance(value, pd.Index):
54
+ value = value.tolist()
55
+ elif not isinstance(value, (list, tuple, set)):
56
+ return []
57
+ return [str(item) for item in value]
58
+
59
+
60
+ def _extract_diagnostic_summary(diag: dict[str, Any]) -> dict[str, Any]:
61
+ """
62
+ Normaliza estatísticas do bloco de diagnósticos para o formato interno.
63
+
64
+ O formato dos .dai pode variar entre versões; esta função tolera aliases
65
+ comuns sem quebrar o carregamento do modelo.
66
+ """
67
+ gerais = _as_dict(_get_first(diag, "gerais", "estatisticas_gerais", default={}))
68
+ teste_f = _as_dict(_get_first(diag, "teste_f", "f_test", default={}))
69
+ teste_ks = _as_dict(_get_first(diag, "teste_ks", "ks_test", default={}))
70
+ teste_normalidade = _as_dict(
71
+ _get_first(diag, "teste_normalidade", "normalidade", default={})
72
+ )
73
+ teste_dw = _as_dict(_get_first(diag, "teste_dw", "durbin_watson", default={}))
74
+ teste_bp = _as_dict(_get_first(diag, "teste_bp", "breusch_pagan", default={}))
75
+
76
+ def stat_from(block: dict[str, Any]) -> Any:
77
+ return _get_first(block, "estatistica", "stat", "value")
78
+
79
+ def pvalue_from(block: dict[str, Any]) -> Any:
80
+ return _get_first(block, "p_valor", "pvalue", "p_val", "p")
81
+
82
+ def interp_from(block: dict[str, Any]) -> Any:
83
+ return _get_first(block, "interpretacao", "interpretation", "texto")
84
+
85
+ return {
86
+ **gerais,
87
+ "Fc": stat_from(teste_f),
88
+ "p_valor_F": pvalue_from(teste_f),
89
+ "Interpretacao_F": interp_from(teste_f),
90
+ "ks_stat": stat_from(teste_ks),
91
+ "ks_p": pvalue_from(teste_ks),
92
+ "Interpretacao_KS": interp_from(teste_ks),
93
+ "perc_resid": _get_first(
94
+ teste_normalidade, "percentuais", "percentis", "percentages"
95
+ ),
96
+ "dw": stat_from(teste_dw),
97
+ "Interpretacao_DW": interp_from(teste_dw),
98
+ "bp_lm": stat_from(teste_bp),
99
+ "bp_p": pvalue_from(teste_bp),
100
+ "Interpretacao_BP": interp_from(teste_bp),
101
+ "equacao": _get_first(diag, "equacao", "equation", "formula"),
102
+ }
103
+
104
+
105
+ def _build_xy_preview(
106
+ dados: dict[str, Any], transformacoes: dict[str, Any]
107
+ ) -> pd.DataFrame:
108
+ """
109
+ Monta DataFrame de preview para o Anexo I.
110
+
111
+ Em versões novas, `dados['df']` pode conter o dataset completo (centenas de
112
+ colunas), o que degrada a geração/abertura no Word. Quando disponível, usa
113
+ `transformacoes['X'] + y` como preview estável da modelagem.
114
+ """
115
+ df_preview = _as_dataframe(
116
+ _get_first(
117
+ dados,
118
+ "xy_preview",
119
+ "df_preview",
120
+ "df_modelagem",
121
+ "preview",
122
+ "df",
123
+ default=pd.DataFrame(),
124
+ )
125
+ )
126
+
127
+ x_df = _as_dataframe(_get_first(transformacoes, "X", "x", default=pd.DataFrame()))
128
+ y_series = _as_series(_get_first(transformacoes, "y", "Y", default=pd.Series(dtype=float)))
129
+
130
+ if not x_df.empty:
131
+ modelagem_df = x_df.copy()
132
+
133
+ if not y_series.empty:
134
+ y_name = str(y_series.name) if y_series.name else "y"
135
+ y_alinhado = y_series.reset_index(drop=True)
136
+ modelagem_df = modelagem_df.reset_index(drop=True)
137
+ tamanho = min(len(modelagem_df), len(y_alinhado))
138
+ if tamanho > 0:
139
+ modelagem_df = modelagem_df.iloc[:tamanho].copy()
140
+ modelagem_df[y_name] = y_alinhado.iloc[:tamanho].values
141
+
142
+ # Se o preview original veio "explodido" (df completo), prioriza modelagem.
143
+ if df_preview.empty or df_preview.shape[1] > max(40, modelagem_df.shape[1] + 10):
144
+ return modelagem_df
145
+
146
+ return df_preview
147
+
148
+
149
+ def model_data_from_package(
150
+ data: Any,
151
+ dai_path: Path,
152
+ nome: str | None = None,
153
+ ) -> Optional[ModelData]:
154
+ """Converte um pacote .dai já carregado para os dados usados nos anexos."""
155
+ try:
156
+ data_dict = _as_dict(data)
157
+ dados = _as_dict(_get_first(data_dict, "dados", "data", default={}))
158
+ transformacoes = _as_dict(
159
+ _get_first(data_dict, "transformacoes", "transformations", default={})
160
+ )
161
+ modelo_dict = _as_dict(_get_first(data_dict, "modelo", "model", default={}))
162
+ diag = _as_dict(_get_first(modelo_dict, "diagnosticos", "diagnostics", default={}))
163
+
164
+ modelos_resumos = _extract_diagnostic_summary(diag)
165
+ xy_preview = _build_xy_preview(dados, transformacoes)
166
+ top_x_esc = _as_dataframe(
167
+ _get_first(transformacoes, "X", "x", default=pd.DataFrame())
168
+ )
169
+ top_y_esc = _as_series(
170
+ _get_first(transformacoes, "y", "Y", default=pd.Series(dtype=float))
171
+ )
172
+ estatisticas = _as_dataframe(
173
+ _get_first(dados, "estatisticas", "statistics", default=pd.DataFrame())
174
+ )
175
+ tabelas_coef = _as_dataframe(
176
+ _get_first(
177
+ modelo_dict,
178
+ "coeficientes",
179
+ "coeficients",
180
+ "coefficients",
181
+ default=pd.DataFrame(),
182
+ )
183
+ )
184
+ for coluna_variavel in ("Variável", "Variavel", "variable"):
185
+ if coluna_variavel in tabelas_coef.columns:
186
+ tabelas_coef = tabelas_coef.set_index(coluna_variavel)
187
+ break
188
+
189
+ tabelas_obs_calc = _as_dataframe(
190
+ _get_first(
191
+ modelo_dict,
192
+ "obs_calc",
193
+ "observado_calculado",
194
+ "observed_vs_calculated",
195
+ default=pd.DataFrame(),
196
+ )
197
+ )
198
+ modelos_sm = _get_first(
199
+ modelo_dict, "sm", "statsmodels", "statsmodels_result", default=None
200
+ )
201
+
202
+ return ModelData(
203
+ nome=str(nome or dai_path.stem),
204
+ path=dai_path,
205
+ xy_preview=xy_preview,
206
+ top_x_esc=top_x_esc,
207
+ top_y_esc=top_y_esc,
208
+ estatisticas=estatisticas,
209
+ tabelas_coef=tabelas_coef,
210
+ tabelas_obs_calc=tabelas_obs_calc,
211
+ modelos_resumos=modelos_resumos,
212
+ modelos_sm=modelos_sm,
213
+ formatted_top_transformation_info=transformacoes.get("info", []),
214
+ variaveis_dicotomicas=_as_string_list(
215
+ _get_first(
216
+ transformacoes,
217
+ "dicotomicas",
218
+ "dicotômicas",
219
+ "dummies",
220
+ "dummy",
221
+ "binarias",
222
+ "binárias",
223
+ default=[],
224
+ )
225
+ ),
226
+ graf_model="",
227
+ )
228
+ except (KeyError, TypeError, ValueError):
229
+ return None
230
+
231
+
232
+ def load_model(dai_path: Path) -> Optional[ModelData]:
233
+ """
234
+ Carrega um modelo de avaliação a partir de um arquivo .dai.
235
+
236
+ Args:
237
+ dai_path: Caminho para o arquivo .dai
238
+
239
+ Returns:
240
+ ModelData com os dados carregados, ou None se falhar
241
+ """
242
+ if not dai_path.exists():
243
+ return None
244
+
245
+ try:
246
+ data = joblib.load(dai_path)
247
+ except Exception as e:
248
+ print(f"Erro ao carregar {dai_path}: {e}")
249
+ return None
250
+
251
+ return model_data_from_package(data, dai_path)
252
+
253
+
254
+ def load_model_by_name(name: str, models_dir: Path) -> Optional[ModelData]:
255
+ """
256
+ Carrega um modelo pelo nome.
257
+
258
+ Args:
259
+ name: Nome do modelo (nome do arquivo .dai sem extensão)
260
+ models_dir: Diretório dos modelos
261
+
262
+ Returns:
263
+ ModelData com os dados carregados, ou None se não encontrar
264
+ """
265
+ dai_path = models_dir / f"{name}.dai"
266
+ return load_model(dai_path)
backend/app/core/anexos/planilha_calculo.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gerador do Anexo de Planilha de Cálculo e Resultados Estatísticos.
3
+ Gera tabelas de coeficientes, valores observados vs calculados e resumo estatístico.
4
+ """
5
+ from typing import Optional, Dict, Any
6
+ from docx import Document
7
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
8
+
9
+ from .model_data import ModelData
10
+ from .formatters import (
11
+ add_body_text,
12
+ add_placeholder_text,
13
+ add_subsection_title,
14
+ add_simple_table,
15
+ criar_paragrafo_formatado,
16
+ )
17
+ from .estatisticas_utils import reorganizar_modelos_resumos, formatar_numero
18
+
19
+
20
+ def gerar_anexo_planilha(doc: Document, modelo: Optional[ModelData]) -> None:
21
+ """
22
+ Gera o conteúdo do Anexo de Planilha de Cálculo e Resultados Estatísticos.
23
+
24
+ Args:
25
+ doc: Documento DOCX
26
+ modelo: Dados do modelo carregado do .dai
27
+ """
28
+ if modelo is None:
29
+ add_placeholder_text(doc, "[Modelo não selecionado - inserir planilha manualmente]")
30
+ return
31
+
32
+ conteudo_gerado = False
33
+
34
+ # 1. RESUMO ESTATÍSTICO (como na aba Resumo do interface_estatisticas)
35
+ if modelo.modelos_resumos:
36
+ _adicionar_resumo_estatistico(doc, modelo.modelos_resumos)
37
+ conteudo_gerado = True
38
+
39
+ # 2. Tabela de Coeficientes
40
+ if not modelo.tabelas_coef.empty:
41
+ add_subsection_title(doc, "", "Coeficientes do Modelo")
42
+ _adicionar_tabela_coeficientes(doc, modelo.tabelas_coef)
43
+ doc.add_paragraph()
44
+ conteudo_gerado = True
45
+
46
+ # 3. Tabela de Valores Observados vs Calculados
47
+ if not modelo.tabelas_obs_calc.empty:
48
+ add_subsection_title(doc, "", "Valores Observados vs Calculados")
49
+ _adicionar_tabela_obs_calc(doc, modelo.tabelas_obs_calc)
50
+ conteudo_gerado = True
51
+
52
+ if not conteudo_gerado:
53
+ add_placeholder_text(doc, "[Dados de planilha não disponíveis no modelo]")
54
+
55
+
56
+ def _adicionar_resumo_estatistico(doc: Document, modelos_resumos: Dict[str, Any]) -> None:
57
+ """
58
+ Adiciona o resumo estatístico no formato da aba Resumo do interface_estatisticas.
59
+ """
60
+ resumo = reorganizar_modelos_resumos(modelos_resumos)
61
+
62
+ # Estatísticas Gerais
63
+ estat_gerais = resumo.get("estatisticas_gerais", {})
64
+ if estat_gerais:
65
+ add_subsection_title(doc, "", "Estatísticas Gerais")
66
+ dados = [["Indicador", "Valor"]]
67
+ for chave, info in estat_gerais.items():
68
+ nome = info.get("nome", chave)
69
+ valor = info.get("valor")
70
+ if valor is not None:
71
+ dados.append([nome, formatar_numero(valor)])
72
+ if len(dados) > 1:
73
+ add_simple_table(doc, dados, header_row=True)
74
+ doc.add_paragraph()
75
+
76
+ # Teste F
77
+ teste_f = resumo.get("teste_f", {})
78
+ if teste_f.get("estatistica") is not None:
79
+ add_subsection_title(doc, "", teste_f.get("nome", "Teste F"))
80
+ dados = [["Indicador", "Valor"]]
81
+ dados.append(["Estatística F", formatar_numero(teste_f["estatistica"])])
82
+ if teste_f.get("pvalor") is not None:
83
+ dados.append(["P-valor", formatar_numero(teste_f["pvalor"])])
84
+ add_simple_table(doc, dados, header_row=True)
85
+ if teste_f.get("interpretacao"):
86
+ _adicionar_interpretacao(doc, teste_f["interpretacao"])
87
+ doc.add_paragraph()
88
+
89
+ # Teste de Normalidade (Kolmogorov-Smirnov)
90
+ teste_ks = resumo.get("teste_ks", {})
91
+ if teste_ks.get("estatistica") is not None:
92
+ add_subsection_title(doc, "", teste_ks.get("nome", "Teste de Normalidade (Kolmogorov-Smirnov)"))
93
+ dados = [["Indicador", "Valor"]]
94
+ dados.append(["Estatística KS", formatar_numero(teste_ks["estatistica"])])
95
+ if teste_ks.get("pvalor") is not None:
96
+ dados.append(["P-valor", formatar_numero(teste_ks["pvalor"])])
97
+ add_simple_table(doc, dados, header_row=True)
98
+ if teste_ks.get("interpretacao"):
99
+ _adicionar_interpretacao(doc, teste_ks["interpretacao"])
100
+ doc.add_paragraph()
101
+
102
+ # Teste de Normalidade (Comparação com Curva Normal)
103
+ perc_resid = resumo.get("perc_resid", {})
104
+ if perc_resid.get("valor"):
105
+ add_subsection_title(doc, "", perc_resid.get("nome", "Teste de Normalidade (Comparação com a Curva Normal)"))
106
+ add_body_text(doc, f"Percentuais atingidos: {perc_resid['valor']}")
107
+ interpretacoes = perc_resid.get("interpretacao", [])
108
+ if interpretacoes:
109
+ add_body_text(doc, "Critérios ideais:")
110
+ for item in interpretacoes:
111
+ add_body_text(doc, f" • {item}")
112
+ doc.add_paragraph()
113
+
114
+ # Teste de Autocorrelação (Durbin-Watson)
115
+ teste_dw = resumo.get("teste_dw", {})
116
+ if teste_dw.get("estatistica") is not None:
117
+ add_subsection_title(doc, "", teste_dw.get("nome", "Teste de Autocorrelação (Durbin-Watson)"))
118
+ dados = [["Indicador", "Valor"]]
119
+ dados.append(["Estatística DW", formatar_numero(teste_dw["estatistica"])])
120
+ add_simple_table(doc, dados, header_row=True)
121
+ if teste_dw.get("interpretacao"):
122
+ _adicionar_interpretacao(doc, teste_dw["interpretacao"])
123
+ doc.add_paragraph()
124
+
125
+ # Teste de Homocedasticidade (Breusch-Pagan)
126
+ teste_bp = resumo.get("teste_bp", {})
127
+ if teste_bp.get("estatistica") is not None:
128
+ add_subsection_title(doc, "", teste_bp.get("nome", "Teste de Homocedasticidade (Breusch-Pagan)"))
129
+ dados = [["Indicador", "Valor"]]
130
+ dados.append(["Estatística LM", formatar_numero(teste_bp["estatistica"])])
131
+ if teste_bp.get("pvalor") is not None:
132
+ dados.append(["P-valor", formatar_numero(teste_bp["pvalor"])])
133
+ add_simple_table(doc, dados, header_row=True)
134
+ if teste_bp.get("interpretacao"):
135
+ _adicionar_interpretacao(doc, teste_bp["interpretacao"])
136
+ doc.add_paragraph()
137
+
138
+ # Equação do Modelo
139
+ equacao = resumo.get("equacao")
140
+ if equacao:
141
+ add_subsection_title(doc, "", "Equação do Modelo")
142
+ criar_paragrafo_formatado(
143
+ doc,
144
+ equacao,
145
+ tamanho=11,
146
+ alinhamento=WD_ALIGN_PARAGRAPH.CENTER,
147
+ espaco_antes=6,
148
+ espaco_depois=6,
149
+ )
150
+ doc.add_paragraph()
151
+
152
+
153
+ def _adicionar_interpretacao(doc: Document, texto: str) -> None:
154
+ """Adiciona texto de interpretação em itálico."""
155
+ criar_paragrafo_formatado(
156
+ doc,
157
+ f"Interpretação: {texto}",
158
+ italico=True,
159
+ tamanho=10,
160
+ espaco_antes=3,
161
+ espaco_depois=3,
162
+ )
163
+
164
+
165
+ def _adicionar_tabela_coeficientes(doc: Document, df) -> None:
166
+ """Adiciona tabela de coeficientes."""
167
+ headers = ["Variável"] + [str(col) for col in df.columns.tolist()]
168
+ dados = [headers]
169
+
170
+ for idx, row in df.iterrows():
171
+ linha = [str(idx)]
172
+ for val in row.values:
173
+ if isinstance(val, float):
174
+ linha.append(f"{val:.6f}")
175
+ else:
176
+ linha.append(str(val) if val is not None else "")
177
+ dados.append(linha)
178
+
179
+ add_simple_table(doc, dados, header_row=True, rotacao_cabecalho=True)
180
+
181
+
182
+ def _adicionar_tabela_obs_calc(doc: Document, df) -> None:
183
+ """Adiciona tabela de valores observados vs calculados (todos os dados com ID)."""
184
+ headers = ["ID"] + [str(col) for col in df.columns.tolist()]
185
+ dados = [headers]
186
+
187
+ for idx, (_, row) in enumerate(df.iterrows(), start=1):
188
+ linha = [str(idx)] # ID da amostra (1-based)
189
+ for val in row.values:
190
+ if isinstance(val, float):
191
+ linha.append(f"{val:.2f}")
192
+ else:
193
+ linha.append(str(val) if val is not None else "")
194
+ dados.append(linha)
195
+
196
+ add_simple_table(doc, dados, header_row=True, rotacao_cabecalho=True)
backend/app/main.py CHANGED
@@ -7,7 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware
7
  from fastapi.responses import JSONResponse
8
  from fastapi.staticfiles import StaticFiles
9
 
10
- from app.api import auth, elaboracao, health, logs, pesquisa, repositorio, session, trabalhos_tecnicos, visualizacao
11
  from app.runtime_paths import resolve_frontend_dist_dir
12
  from app.services import auth_service, visualizacao_service
13
 
@@ -55,6 +55,7 @@ app.include_router(pesquisa.router)
55
  app.include_router(repositorio.router)
56
  app.include_router(trabalhos_tecnicos.router)
57
  app.include_router(logs.router)
 
58
 
59
 
60
  @app.on_event("startup")
 
7
  from fastapi.responses import JSONResponse
8
  from fastapi.staticfiles import StaticFiles
9
 
10
+ from app.api import anexos, auth, elaboracao, health, logs, pesquisa, repositorio, session, trabalhos_tecnicos, visualizacao
11
  from app.runtime_paths import resolve_frontend_dist_dir
12
  from app.services import auth_service, visualizacao_service
13
 
 
55
  app.include_router(repositorio.router)
56
  app.include_router(trabalhos_tecnicos.router)
57
  app.include_router(logs.router)
58
+ app.include_router(anexos.router)
59
 
60
 
61
  @app.on_event("startup")
backend/app/services/anexos_service.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Sequence
5
+
6
+ from fastapi import HTTPException
7
+ from joblib import load
8
+
9
+ from app.core.anexos import AnexosGenerator, model_data_from_package
10
+ from app.core.elaboracao.core import _migrar_pacote_v1_para_v2
11
+ from app.runtime_paths import resolve_local_data_path
12
+ from app.services import model_repository
13
+ from app.services.serializers import sanitize_value
14
+
15
+
16
+ OUTPUT_DIR = resolve_local_data_path("anexos_gerados")
17
+ AVALIACAO_KEYS = {
18
+ "valores_x",
19
+ "extrapolacoes",
20
+ "estimado",
21
+ "tipo_y",
22
+ "coluna_area",
23
+ "valor_area",
24
+ "estimado_unitario",
25
+ "estimado_total",
26
+ "conversao_disponivel",
27
+ "motivo_conversao_indisponivel",
28
+ "ca_inf",
29
+ "ca_sup",
30
+ "ic_inf",
31
+ "ic_sup",
32
+ "perc_inf",
33
+ "perc_sup",
34
+ "amplitude",
35
+ "precisao",
36
+ "houve_extrapolacao",
37
+ "fronteira",
38
+ "perc_ext",
39
+ "qtd_extrapolacoes",
40
+ "fundamentacao",
41
+ }
42
+
43
+
44
+ def listar_modelos() -> dict[str, Any]:
45
+ return sanitize_value(model_repository.list_repository_models())
46
+
47
+
48
+ def _carregar_modelo(modelo_id: str):
49
+ caminho = model_repository.resolve_model_file(modelo_id)
50
+ try:
51
+ pacote = load(caminho)
52
+ except Exception as exc:
53
+ raise HTTPException(status_code=400, detail=f"Falha ao ler arquivo .dai: {exc}") from exc
54
+
55
+ if not isinstance(pacote, dict):
56
+ raise HTTPException(status_code=400, detail="Arquivo .dai invalido")
57
+ if "versao" not in pacote:
58
+ try:
59
+ pacote = _migrar_pacote_v1_para_v2(pacote)
60
+ except Exception as exc:
61
+ raise HTTPException(status_code=400, detail=f"Falha ao migrar arquivo .dai: {exc}") from exc
62
+
63
+ modelo = model_data_from_package(pacote, caminho, nome=caminho.stem)
64
+ if modelo is None or modelo.xy_preview.empty:
65
+ raise HTTPException(status_code=400, detail="Modelo sem dados suficientes para gerar anexos")
66
+ return modelo
67
+
68
+
69
+ def obter_colunas(modelo_id: str) -> dict[str, Any]:
70
+ modelo = _carregar_modelo(modelo_id)
71
+ return {
72
+ "modelo_id": modelo.nome,
73
+ "columns": [str(coluna) for coluna in modelo.xy_preview.columns.tolist()],
74
+ }
75
+
76
+
77
+ def _validar_colunas(modelo, colunas: Sequence[str] | None) -> list[str] | None:
78
+ if colunas is None:
79
+ return None
80
+
81
+ disponiveis = [str(coluna) for coluna in modelo.xy_preview.columns.tolist()]
82
+ selecionadas: list[str] = []
83
+ vistas: set[str] = set()
84
+ for coluna in colunas:
85
+ nome = str(coluna)
86
+ if nome in vistas:
87
+ continue
88
+ vistas.add(nome)
89
+ selecionadas.append(nome)
90
+
91
+ invalidas = [coluna for coluna in selecionadas if coluna not in disponiveis]
92
+ if invalidas:
93
+ raise HTTPException(
94
+ status_code=400,
95
+ detail=f"Colunas inválidas para o banco de dados: {', '.join(invalidas)}",
96
+ )
97
+ if not selecionadas:
98
+ raise HTTPException(status_code=400, detail="Selecione ao menos uma coluna para o banco de dados")
99
+ return selecionadas
100
+
101
+
102
+ def _avisos_estatisticos(modelo) -> list[str]:
103
+ resumo = modelo.modelos_resumos or {}
104
+ avisos: list[str] = []
105
+ testes = (
106
+ ("Teste F", resumo.get("Interpretacao_F")),
107
+ ("Teste de Normalidade (Kolmogorov-Smirnov)", resumo.get("Interpretacao_KS")),
108
+ ("Teste de Autocorrelação (Durbin-Watson)", resumo.get("Interpretacao_DW")),
109
+ ("Teste de Homocedasticidade (Breusch-Pagan)", resumo.get("Interpretacao_BP")),
110
+ )
111
+ for nome, interpretacao in testes:
112
+ texto = str(interpretacao or "").strip()
113
+ if texto and ("❌" in texto or "⚠" in texto):
114
+ avisos.append(f"{nome}: {texto}")
115
+ return avisos
116
+
117
+
118
+ def _normalizar_avaliacao(avaliacao: dict[str, Any] | None) -> dict[str, Any] | None:
119
+ if not isinstance(avaliacao, dict):
120
+ return None
121
+ payload = {
122
+ chave: valor
123
+ for chave, valor in avaliacao.items()
124
+ if chave in AVALIACAO_KEYS
125
+ }
126
+ return sanitize_value(payload)
127
+
128
+
129
+ def gerar_documento(
130
+ modelo_id: str,
131
+ banco_dados_colunas: Sequence[str] | None = None,
132
+ avaliacao: dict[str, Any] | None = None,
133
+ ) -> tuple[Path, list[str]]:
134
+ modelo = _carregar_modelo(modelo_id)
135
+ colunas = _validar_colunas(modelo, banco_dados_colunas)
136
+ avaliacao_normalizada = _normalizar_avaliacao(avaliacao)
137
+ try:
138
+ caminho = AnexosGenerator(OUTPUT_DIR).gerar(
139
+ modelo,
140
+ banco_dados_colunas=colunas,
141
+ avaliacao=avaliacao_normalizada,
142
+ )
143
+ except Exception as exc:
144
+ raise HTTPException(status_code=500, detail=f"Erro ao gerar anexos: {exc}") from exc
145
+ return caminho, _avisos_estatisticos(modelo)
146
+
147
+
148
+ def resolver_download(filename: str) -> Path:
149
+ nome_seguro = Path(str(filename or "")).name
150
+ if not nome_seguro or nome_seguro != filename or not nome_seguro.lower().endswith(".docx"):
151
+ raise HTTPException(status_code=400, detail="Nome de arquivo inválido")
152
+ caminho = OUTPUT_DIR / nome_seguro
153
+ if not caminho.is_file():
154
+ raise HTTPException(status_code=404, detail="Arquivo de anexos não encontrado")
155
+ return caminho
backend/app/services/pesquisa_service.py CHANGED
@@ -4,13 +4,16 @@ from branca.element import Element
4
  import json
5
  from html import escape
6
  import math
 
7
  import re
 
8
  import unicodedata
9
  from dataclasses import dataclass
10
  from datetime import date, datetime
11
  from pathlib import Path
12
  from threading import Event, Lock
13
  from typing import Any
 
14
 
15
  import folium
16
  from folium import plugins
@@ -18,6 +21,7 @@ import numpy as np
18
  import pandas as pd
19
  from fastapi import HTTPException
20
  from joblib import load
 
21
 
22
  from app.core.elaboracao import geocodificacao
23
  from app.core.shapefile_runtime import load_attribute_records
@@ -1229,11 +1233,33 @@ def _tooltip_mapa_modelo_html(modelo: dict[str, Any]) -> str:
1229
  )
1230
 
1231
 
1232
- def _tooltip_mapa_modelo_ponto_html(modelo: dict[str, Any], total_no_local: int) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1233
  tooltip = _tooltip_mapa_modelo_html(modelo)
1234
- if total_no_local <= 1:
 
 
1235
  return tooltip
1236
- detalhe = f"<br><span style='color:#555;'>Dados neste local:</span> <b>{total_no_local}</b>"
 
 
 
 
1237
  if tooltip.endswith("</div>"):
1238
  return f"{tooltip[:-6]}{detalhe}</div>"
1239
  return f"{tooltip}{detalhe}"
@@ -1250,23 +1276,38 @@ def _chave_coordenada_ponto(ponto: dict[str, Any], precisao: int = 7) -> tuple[f
1250
  return (round(lat, precisao), round(lon, precisao))
1251
 
1252
 
1253
- def _contagens_coordenadas_modelos(modelos_plotados: list[dict[str, Any]]) -> dict[tuple[float, float], int]:
1254
- contagens: dict[tuple[float, float], int] = {}
1255
- for modelo in modelos_plotados:
1256
- for ponto in modelo.get("pontos") or []:
1257
- chave = _chave_coordenada_ponto(ponto)
1258
- if chave is None:
1259
- continue
1260
- contagens[chave] = contagens.get(chave, 0) + 1
1261
- return contagens
 
 
 
 
 
 
 
 
 
 
 
 
1262
 
1263
 
1264
  def _ponto_mapa_modelo_payload_item(
1265
  modelo: dict[str, Any],
1266
  ponto: dict[str, Any],
1267
- total_no_local: int = 1,
1268
  ) -> dict[str, Any]:
1269
  cor = str(modelo.get("cor") or "#1f77b4")
 
 
 
1270
  item: dict[str, Any] = {
1271
  "lat": float(ponto["lat"]),
1272
  "lon": float(ponto["lon"]),
@@ -1275,16 +1316,28 @@ def _ponto_mapa_modelo_payload_item(
1275
  "stroke_color": cor,
1276
  "stroke_weight": 1.0,
1277
  "fill_opacity": 0.72,
1278
- "tooltip_html": _tooltip_mapa_modelo_ponto_html(modelo, total_no_local),
 
 
 
1279
  }
1280
  if total_no_local > 1:
1281
  item.update(
1282
  {
1283
  "grouped": True,
1284
  "count": total_no_local,
1285
- "group_title": f"{total_no_local} dados neste local",
 
 
1286
  "pane": "mesa-market-group-pane",
1287
  "tooltip_sticky": False,
 
 
 
 
 
 
 
1288
  }
1289
  )
1290
  return item
@@ -1292,7 +1345,6 @@ def _ponto_mapa_modelo_payload_item(
1292
 
1293
  def _pontos_mapa_modelo_payload(
1294
  modelo: dict[str, Any],
1295
- contagens_coordenadas: dict[tuple[float, float], int] | None = None,
1296
  agrupar_pontos_mercado: bool = False,
1297
  ) -> list[dict[str, Any]]:
1298
  if not agrupar_pontos_mercado:
@@ -1300,24 +1352,41 @@ def _pontos_mapa_modelo_payload(
1300
  for ponto in modelo.get("pontos") or []:
1301
  if _chave_coordenada_ponto(ponto) is None:
1302
  continue
1303
- pontos_payload.append(_ponto_mapa_modelo_payload_item(modelo, ponto))
1304
  return pontos_payload
1305
 
1306
- grupos: dict[tuple[float, float], list[dict[str, Any]]] = {}
1307
- for ponto in modelo.get("pontos") or []:
1308
- chave = _chave_coordenada_ponto(ponto)
1309
- if chave is None:
1310
- continue
1311
- grupos.setdefault(chave, []).append(ponto)
1312
-
1313
  pontos_payload: list[dict[str, Any]] = []
1314
- for chave, pontos_no_local in grupos.items():
1315
  ponto = pontos_no_local[0]
1316
- total_no_local = max(len(pontos_no_local), int((contagens_coordenadas or {}).get(chave) or 0))
1317
- pontos_payload.append(_ponto_mapa_modelo_payload_item(modelo, ponto, total_no_local))
1318
  return pontos_payload
1319
 
1320
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1321
  def _marker_payloads_avaliandos(avaliandos_geo: list[dict[str, Any]]) -> list[dict[str, Any]]:
1322
  payloads: list[dict[str, Any]] = []
1323
  for idx, avaliando in enumerate(avaliandos_geo):
@@ -1477,15 +1546,10 @@ def _build_mapa_modelos_payload(
1477
  if avaliandos_tecnicos_proximos is not None
1478
  else []
1479
  )
1480
- contagens_pontos = (
1481
- _contagens_coordenadas_modelos(modelos_plotados)
1482
- if modo_exibicao == "pontos" and agrupar_pontos_mercado
1483
- else {}
1484
- )
1485
-
1486
  for modelo in modelos_plotados:
 
1487
  layer: dict[str, Any] = {
1488
- "id": str(modelo.get("id") or ""),
1489
  "label": str(modelo.get("nome") or "Modelo"),
1490
  "show": True,
1491
  "hover_highlight_group": "pesquisa-modelos",
@@ -1493,12 +1557,23 @@ def _build_mapa_modelos_payload(
1493
  if modo_exibicao == "pontos":
1494
  layer["points"] = _pontos_mapa_modelo_payload(
1495
  modelo,
1496
- contagens_pontos,
1497
  agrupar_pontos_mercado=agrupar_pontos_mercado,
1498
  )
1499
  else:
1500
  layer["shapes"] = _shapes_modelo_payload(modelo, aval_lat, aval_lon)
1501
  overlay_layers.append(layer)
 
 
 
 
 
 
 
 
 
 
 
 
1502
 
1503
  if trabalhos_tecnicos_modelos:
1504
  overlay_layers.append(
@@ -1561,10 +1636,66 @@ def _build_mapa_modelos_payload(
1561
 
1562
  payload = build_leaflet_payload(bounds=bounds, overlay_layers=overlay_layers, show_bairros=True)
1563
  if payload and isinstance(payload.get("controls"), dict):
1564
- payload["controls"]["layer_control_collapsed"] = False
 
1565
  return payload
1566
 
1567
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1568
  def _renderizar_mapa_modelos(
1569
  modelos_plotados: list[dict[str, Any]],
1570
  bounds: list[list[float]],
 
4
  import json
5
  from html import escape
6
  import math
7
+ import os
8
  import re
9
+ import tempfile
10
  import unicodedata
11
  from dataclasses import dataclass
12
  from datetime import date, datetime
13
  from pathlib import Path
14
  from threading import Event, Lock
15
  from typing import Any
16
+ from uuid import uuid4
17
 
18
  import folium
19
  from folium import plugins
 
21
  import pandas as pd
22
  from fastapi import HTTPException
23
  from joblib import load
24
+ from openpyxl import Workbook
25
 
26
  from app.core.elaboracao import geocodificacao
27
  from app.core.shapefile_runtime import load_attribute_records
 
1233
  )
1234
 
1235
 
1236
+ def _indices_mapa_modelo_display(pontos_no_local: list[dict[str, Any]]) -> list[str]:
1237
+ indices: list[str] = []
1238
+ for ponto in pontos_no_local:
1239
+ indice = str(ponto.get("indice") or "").strip()
1240
+ if indice:
1241
+ indices.append(indice)
1242
+ return indices
1243
+
1244
+
1245
+ def _indices_mapa_modelo_badge(indices_no_local: list[str], limite: int = 28) -> str:
1246
+ texto = ", ".join(str(item).strip() for item in indices_no_local if str(item).strip())
1247
+ if len(texto) <= limite:
1248
+ return texto
1249
+ return texto[: max(0, limite - 1)].rstrip(" ,") + "…"
1250
+
1251
+
1252
+ def _tooltip_mapa_modelo_ponto_html(modelo: dict[str, Any], indices_no_local: list[str] | None = None) -> str:
1253
  tooltip = _tooltip_mapa_modelo_html(modelo)
1254
+ indices = [str(item).strip() for item in (indices_no_local or []) if str(item).strip()]
1255
+ total_no_local = len(indices)
1256
+ if total_no_local <= 0:
1257
  return tooltip
1258
+ detalhe = ""
1259
+ detalhe += (
1260
+ f"<br><span style='color:#555;'>{'Índices' if total_no_local > 1 else 'Índice'}:</span> "
1261
+ f"<b>{escape(', '.join(indices))}</b>"
1262
+ )
1263
  if tooltip.endswith("</div>"):
1264
  return f"{tooltip[:-6]}{detalhe}</div>"
1265
  return f"{tooltip}{detalhe}"
 
1276
  return (round(lat, precisao), round(lon, precisao))
1277
 
1278
 
1279
+ def _agrupar_pontos_modelo_por_coordenada(
1280
+ modelo: dict[str, Any],
1281
+ ) -> list[tuple[tuple[float, float], list[dict[str, Any]]]]:
1282
+ grupos: dict[tuple[float, float], list[dict[str, Any]]] = {}
1283
+ for ponto in modelo.get("pontos") or []:
1284
+ chave = _chave_coordenada_ponto(ponto)
1285
+ if chave is None:
1286
+ continue
1287
+ grupos.setdefault(chave, []).append(ponto)
1288
+ return list(grupos.items())
1289
+
1290
+
1291
+ def _build_marker_html_indice_modelo(indices_display: list[str]) -> str:
1292
+ return (
1293
+ '<div style="transform: translate(10px, -14px);display:inline-block;background: rgba(255, 255, 255, 0.9);'
1294
+ + 'border: 1px solid rgba(28, 45, 66, 0.45);border-radius: 10px;padding: 1px 6px;font-size: 11px;'
1295
+ + 'font-weight: 700;line-height: 1.2;color: #1f2f44;white-space: nowrap;box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);'
1296
+ + 'pointer-events: none;">'
1297
+ + escape(_indices_mapa_modelo_badge(indices_display))
1298
+ + "</div>"
1299
+ )
1300
 
1301
 
1302
  def _ponto_mapa_modelo_payload_item(
1303
  modelo: dict[str, Any],
1304
  ponto: dict[str, Any],
1305
+ pontos_no_local: list[dict[str, Any]] | None = None,
1306
  ) -> dict[str, Any]:
1307
  cor = str(modelo.get("cor") or "#1f77b4")
1308
+ pontos_agrupados = pontos_no_local if pontos_no_local is not None else [ponto]
1309
+ indices_display = _indices_mapa_modelo_display(pontos_agrupados)
1310
+ total_no_local = len(indices_display) or 1
1311
  item: dict[str, Any] = {
1312
  "lat": float(ponto["lat"]),
1313
  "lon": float(ponto["lon"]),
 
1316
  "stroke_color": cor,
1317
  "stroke_weight": 1.0,
1318
  "fill_opacity": 0.72,
1319
+ "tooltip_html": _tooltip_mapa_modelo_ponto_html(modelo, indices_display),
1320
+ "selection_model_id": str(modelo.get("id") or ""),
1321
+ "selection_model_name": str(modelo.get("nome") or modelo.get("id") or "Modelo"),
1322
+ "selection_indices": indices_display,
1323
  }
1324
  if total_no_local > 1:
1325
  item.update(
1326
  {
1327
  "grouped": True,
1328
  "count": total_no_local,
1329
+ "hide_count": True,
1330
+ "group_title": "Dados neste local",
1331
+ "hide_group_count": True,
1332
  "pane": "mesa-market-group-pane",
1333
  "tooltip_sticky": False,
1334
+ "group_items": [
1335
+ {
1336
+ "indice": indice,
1337
+ "label": f"Índice {indice}",
1338
+ }
1339
+ for indice in indices_display
1340
+ ],
1341
  }
1342
  )
1343
  return item
 
1345
 
1346
  def _pontos_mapa_modelo_payload(
1347
  modelo: dict[str, Any],
 
1348
  agrupar_pontos_mercado: bool = False,
1349
  ) -> list[dict[str, Any]]:
1350
  if not agrupar_pontos_mercado:
 
1352
  for ponto in modelo.get("pontos") or []:
1353
  if _chave_coordenada_ponto(ponto) is None:
1354
  continue
1355
+ pontos_payload.append(_ponto_mapa_modelo_payload_item(modelo, ponto, [ponto]))
1356
  return pontos_payload
1357
 
 
 
 
 
 
 
 
1358
  pontos_payload: list[dict[str, Any]] = []
1359
+ for _chave, pontos_no_local in _agrupar_pontos_modelo_por_coordenada(modelo):
1360
  ponto = pontos_no_local[0]
1361
+ pontos_payload.append(_ponto_mapa_modelo_payload_item(modelo, ponto, pontos_no_local))
 
1362
  return pontos_payload
1363
 
1364
 
1365
+ def _indices_mapa_modelo_payload(modelo: dict[str, Any]) -> list[dict[str, Any]]:
1366
+ payloads: list[dict[str, Any]] = []
1367
+ for _chave, pontos_no_local in _agrupar_pontos_modelo_por_coordenada(modelo):
1368
+ if not pontos_no_local:
1369
+ continue
1370
+ ponto = pontos_no_local[0]
1371
+ indices_display = _indices_mapa_modelo_display(pontos_no_local)
1372
+ if not indices_display:
1373
+ continue
1374
+ payloads.append(
1375
+ {
1376
+ "lat": float(ponto["lat"]),
1377
+ "lon": float(ponto["lon"]),
1378
+ "marker_html": _build_marker_html_indice_modelo(indices_display),
1379
+ "icon_size": [96 if len(indices_display) > 1 else 72, 24],
1380
+ "icon_anchor": [0, 0],
1381
+ "class_name": "mesa-indice-label",
1382
+ "interactive": False,
1383
+ "keyboard": False,
1384
+ "tooltip_html": _tooltip_mapa_modelo_ponto_html(modelo, indices_display),
1385
+ }
1386
+ )
1387
+ return payloads
1388
+
1389
+
1390
  def _marker_payloads_avaliandos(avaliandos_geo: list[dict[str, Any]]) -> list[dict[str, Any]]:
1391
  payloads: list[dict[str, Any]] = []
1392
  for idx, avaliando in enumerate(avaliandos_geo):
 
1546
  if avaliandos_tecnicos_proximos is not None
1547
  else []
1548
  )
 
 
 
 
 
 
1549
  for modelo in modelos_plotados:
1550
+ modelo_id = str(modelo.get("id") or "")
1551
  layer: dict[str, Any] = {
1552
+ "id": modelo_id,
1553
  "label": str(modelo.get("nome") or "Modelo"),
1554
  "show": True,
1555
  "hover_highlight_group": "pesquisa-modelos",
 
1557
  if modo_exibicao == "pontos":
1558
  layer["points"] = _pontos_mapa_modelo_payload(
1559
  modelo,
 
1560
  agrupar_pontos_mercado=agrupar_pontos_mercado,
1561
  )
1562
  else:
1563
  layer["shapes"] = _shapes_modelo_payload(modelo, aval_lat, aval_lon)
1564
  overlay_layers.append(layer)
1565
+ if modo_exibicao == "pontos":
1566
+ indices_markers = _indices_mapa_modelo_payload(modelo)
1567
+ if indices_markers:
1568
+ overlay_layers.append(
1569
+ {
1570
+ "id": f"{modelo_id}__indices",
1571
+ "label": f"Índices • {str(modelo.get('nome') or 'Modelo')}",
1572
+ "show": False,
1573
+ "required_overlay_ids": [modelo_id],
1574
+ "markers": indices_markers,
1575
+ }
1576
+ )
1577
 
1578
  if trabalhos_tecnicos_modelos:
1579
  overlay_layers.append(
 
1636
 
1637
  payload = build_leaflet_payload(bounds=bounds, overlay_layers=overlay_layers, show_bairros=True)
1638
  if payload and isinstance(payload.get("controls"), dict):
1639
+ payload["controls"]["market_lasso"] = modo_exibicao == "pontos"
1640
+ payload["controls"]["layer_control_collapsed"] = True
1641
  return payload
1642
 
1643
 
1644
+ def _normalizar_nome_aba_xlsx(nome: Any, fallback: str) -> str:
1645
+ texto = str(nome or "").strip() or fallback
1646
+ for char in ["\\", "/", "*", "?", ":", "[", "]"]:
1647
+ texto = texto.replace(char, " ")
1648
+ texto = re.sub(r"\s+", " ", texto).strip("' ").strip()
1649
+ return (texto or fallback)[:31]
1650
+
1651
+
1652
+ def exportar_indices_mapa_pesquisa_xlsx(modelos: list[dict[str, Any]] | None) -> tuple[str, str]:
1653
+ itens = []
1654
+ for idx, modelo in enumerate(modelos or [], start=1):
1655
+ if not isinstance(modelo, dict):
1656
+ continue
1657
+ nome = str(modelo.get("nome") or modelo.get("name") or modelo.get("modelo") or "").strip()
1658
+ indices = []
1659
+ vistos = set()
1660
+ for valor in modelo.get("indices") or []:
1661
+ texto = str(valor or "").strip()
1662
+ if not texto or texto in vistos:
1663
+ continue
1664
+ vistos.add(texto)
1665
+ indices.append(texto)
1666
+ if indices:
1667
+ itens.append({"nome": nome or f"Modelo {idx}", "indices": indices})
1668
+
1669
+ if not itens:
1670
+ raise HTTPException(status_code=400, detail="Nenhum indice selecionado para exportar.")
1671
+
1672
+ workbook = Workbook()
1673
+ worksheet = workbook.active
1674
+ nomes_usados: set[str] = set()
1675
+ for idx, item in enumerate(itens):
1676
+ sheet_name_base = _normalizar_nome_aba_xlsx(item["nome"], f"Modelo {idx + 1}")
1677
+ sheet_name = sheet_name_base
1678
+ suffix = 2
1679
+ while sheet_name.casefold() in nomes_usados:
1680
+ tail = f" {suffix}"
1681
+ sheet_name = f"{sheet_name_base[:31 - len(tail)]}{tail}"
1682
+ suffix += 1
1683
+ nomes_usados.add(sheet_name.casefold())
1684
+ if idx == 0:
1685
+ worksheet.title = sheet_name
1686
+ else:
1687
+ worksheet = workbook.create_sheet(sheet_name)
1688
+ worksheet.append(["INDICE"])
1689
+ for indice in item["indices"]:
1690
+ worksheet.append([indice])
1691
+ worksheet.column_dimensions["A"].width = 18
1692
+
1693
+ nome_arquivo = f"indices_mapa_pesquisa_{uuid4().hex[:8]}.xlsx"
1694
+ caminho = os.path.join(tempfile.gettempdir(), nome_arquivo)
1695
+ workbook.save(caminho)
1696
+ return caminho, nome_arquivo
1697
+
1698
+
1699
  def _renderizar_mapa_modelos(
1700
  modelos_plotados: list[dict[str, Any]],
1701
  bounds: list[list[float]],
backend/app/services/serializers.py CHANGED
@@ -27,14 +27,14 @@ def sanitize_value(value: Any) -> Any:
27
  return [sanitize_value(item) for item in value.tolist()]
28
  if isinstance(value, np.generic):
29
  return sanitize_value(value.item())
 
 
30
  if isinstance(value, (np.floating, float)):
31
  if math.isnan(value) or math.isinf(value):
32
  return None
33
  return float(value)
34
  if isinstance(value, (np.integer, int)):
35
  return int(value)
36
- if isinstance(value, (np.bool_, bool)):
37
- return bool(value)
38
  if isinstance(value, pd.Timestamp):
39
  return value.isoformat()
40
  if isinstance(value, pd.Timedelta):
 
27
  return [sanitize_value(item) for item in value.tolist()]
28
  if isinstance(value, np.generic):
29
  return sanitize_value(value.item())
30
+ if isinstance(value, (np.bool_, bool)):
31
+ return bool(value)
32
  if isinstance(value, (np.floating, float)):
33
  if math.isnan(value) or math.isinf(value):
34
  return None
35
  return float(value)
36
  if isinstance(value, (np.integer, int)):
37
  return int(value)
 
 
38
  if isinstance(value, pd.Timestamp):
39
  return value.isoformat()
40
  if isinstance(value, pd.Timedelta):
backend/requirements.txt CHANGED
@@ -16,3 +16,6 @@ pyshp
16
  geopandas
17
  fiona
18
  huggingface_hub>=0.30.0
 
 
 
 
16
  geopandas
17
  fiona
18
  huggingface_hub>=0.30.0
19
+ python-docx
20
+ Pillow
21
+ matplotlib
frontend/src/api.js CHANGED
@@ -407,6 +407,15 @@ export const api = {
407
  }),
408
  exportEquationViz: (sessionId, mode = 'excel') => getBlob(`/api/visualizacao/equation/export?session_id=${encodeURIComponent(sessionId)}&mode=${encodeURIComponent(String(mode || 'excel'))}`),
409
 
 
 
 
 
 
 
 
 
 
410
  repositorioListar: () => getJson('/api/repositorio/modelos'),
411
  repositorioUpload(files = [], { confirmarSubstituicao = false } = {}) {
412
  const form = new FormData()
 
407
  }),
408
  exportEquationViz: (sessionId, mode = 'excel') => getBlob(`/api/visualizacao/equation/export?session_id=${encodeURIComponent(sessionId)}&mode=${encodeURIComponent(String(mode || 'excel'))}`),
409
 
410
+ anexosModelos: () => getJson('/api/anexos/modelos'),
411
+ anexosModeloColunas: (modeloId) => getJson(`/api/anexos/modelos/${encodeURIComponent(String(modeloId || ''))}/columns`),
412
+ anexosGerar: (modeloId, bancoDadosColunas, avaliacao = null) => postJson('/api/anexos/generate', {
413
+ modelo_id: modeloId,
414
+ banco_dados_colunas: bancoDadosColunas,
415
+ avaliacao,
416
+ }),
417
+ anexosDownload: (filename) => getBlob(`/api/anexos/download/${encodeURIComponent(String(filename || ''))}`),
418
+
419
  repositorioListar: () => getJson('/api/repositorio/modelos'),
420
  repositorioUpload(files = [], { confirmarSubstituicao = false } = {}) {
421
  const form = new FormData()
frontend/src/components/AnexosModal.jsx ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useMemo, useState } from 'react'
2
+ import { api, downloadBlob } from '../api'
3
+
4
+ function normalizarChave(value) {
5
+ return String(value || '')
6
+ .normalize('NFD')
7
+ .replace(/[\u0300-\u036f]/g, '')
8
+ .toLowerCase()
9
+ .trim()
10
+ .replace(/\.dai$/i, '')
11
+ }
12
+
13
+ function resolverModeloId(chave, modelos) {
14
+ const normalizada = normalizarChave(chave)
15
+ if (!normalizada) return ''
16
+ for (const modelo of modelos || []) {
17
+ const candidatos = [modelo?.id, modelo?.arquivo, modelo?.nome_modelo]
18
+ .map(normalizarChave)
19
+ .filter(Boolean)
20
+ if (candidatos.includes(normalizada)) return String(modelo.id || '')
21
+ }
22
+ return ''
23
+ }
24
+
25
+ function formatarFonte(fonte) {
26
+ if (!fonte || typeof fonte !== 'object') return ''
27
+ if (String(fonte.provider || '').toLowerCase() === 'hf_dataset') {
28
+ const repo = String(fonte.repo_id || '').trim()
29
+ const revisao = String(fonte.revision || '').trim()
30
+ return `HF Dataset${repo ? ` (${repo})` : ''}${revisao ? ` | revisão ${revisao.slice(0, 8)}` : ''}`
31
+ }
32
+ return 'Pasta local'
33
+ }
34
+
35
+ export default function AnexosModal({
36
+ open,
37
+ defaultModeloId = '',
38
+ avaliacao = null,
39
+ lockModelo = false,
40
+ title = 'Gerar anexos',
41
+ onClose,
42
+ }) {
43
+ const [modelos, setModelos] = useState([])
44
+ const [fonte, setFonte] = useState(null)
45
+ const [modeloSelecionado, setModeloSelecionado] = useState('')
46
+ const [colunas, setColunas] = useState([])
47
+ const [loadingModelos, setLoadingModelos] = useState(false)
48
+ const [loadingColunas, setLoadingColunas] = useState(false)
49
+ const [generating, setGenerating] = useState(false)
50
+ const [downloading, setDownloading] = useState(false)
51
+ const [error, setError] = useState('')
52
+ const [result, setResult] = useState(null)
53
+
54
+ const colunasSelecionadas = useMemo(
55
+ () => colunas.filter((item) => item.selected).map((item) => item.name),
56
+ [colunas],
57
+ )
58
+ const busy = loadingModelos || loadingColunas || generating || downloading
59
+ const blocking = generating || downloading
60
+ const canGenerate = Boolean(modeloSelecionado && colunasSelecionadas.length && !busy)
61
+
62
+ useEffect(() => {
63
+ if (!open) return undefined
64
+ let active = true
65
+ setLoadingModelos(true)
66
+ setError('')
67
+ setResult(null)
68
+ setColunas([])
69
+ setModeloSelecionado('')
70
+
71
+ api.anexosModelos()
72
+ .then((response) => {
73
+ if (!active) return
74
+ const lista = Array.isArray(response?.modelos) ? response.modelos : []
75
+ const resolvido = resolverModeloId(defaultModeloId, lista)
76
+ setModelos(lista)
77
+ setFonte(response?.fonte || null)
78
+ if (lockModelo && defaultModeloId && !resolvido) {
79
+ setModeloSelecionado('')
80
+ setError('O modelo usado nesta avaliação não foi encontrado no repositório configurado.')
81
+ return
82
+ }
83
+ setModeloSelecionado(resolvido || String(lista[0]?.id || ''))
84
+ })
85
+ .catch((err) => {
86
+ if (!active) return
87
+ setModelos([])
88
+ setModeloSelecionado('')
89
+ setError(err?.message || 'Não foi possível carregar os modelos.')
90
+ })
91
+ .finally(() => {
92
+ if (active) setLoadingModelos(false)
93
+ })
94
+
95
+ return () => {
96
+ active = false
97
+ }
98
+ }, [open, defaultModeloId, lockModelo])
99
+
100
+ useEffect(() => {
101
+ if (!open || !modeloSelecionado) {
102
+ setColunas([])
103
+ return undefined
104
+ }
105
+ let active = true
106
+ setLoadingColunas(true)
107
+ setError('')
108
+ setResult(null)
109
+
110
+ api.anexosModeloColunas(modeloSelecionado)
111
+ .then((response) => {
112
+ if (!active) return
113
+ setColunas((response?.columns || []).map((name) => ({ name, selected: true })))
114
+ })
115
+ .catch((err) => {
116
+ if (!active) return
117
+ setColunas([])
118
+ setError(err?.message || 'Não foi possível carregar as colunas do modelo.')
119
+ })
120
+ .finally(() => {
121
+ if (active) setLoadingColunas(false)
122
+ })
123
+
124
+ return () => {
125
+ active = false
126
+ }
127
+ }, [open, modeloSelecionado])
128
+
129
+ if (!open) return null
130
+
131
+ function toggleColuna(nome) {
132
+ setColunas((current) => current.map((item) => (
133
+ item.name === nome ? { ...item, selected: !item.selected } : item
134
+ )))
135
+ setResult(null)
136
+ }
137
+
138
+ function moverColuna(index, direction) {
139
+ setColunas((current) => {
140
+ const destino = index + direction
141
+ if (destino < 0 || destino >= current.length) return current
142
+ const next = [...current]
143
+ ;[next[index], next[destino]] = [next[destino], next[index]]
144
+ return next
145
+ })
146
+ setResult(null)
147
+ }
148
+
149
+ async function gerar(event) {
150
+ event.preventDefault()
151
+ if (!canGenerate) return
152
+ setGenerating(true)
153
+ setError('')
154
+ setResult(null)
155
+ try {
156
+ const response = await api.anexosGerar(
157
+ modeloSelecionado,
158
+ colunasSelecionadas,
159
+ avaliacao,
160
+ )
161
+ setResult(response)
162
+ } catch (err) {
163
+ setError(err?.message || 'Falha ao gerar os anexos.')
164
+ } finally {
165
+ setGenerating(false)
166
+ }
167
+ }
168
+
169
+ async function baixar() {
170
+ const filename = String(result?.filename || '').trim()
171
+ if (!filename) return
172
+ setDownloading(true)
173
+ setError('')
174
+ try {
175
+ const blob = await api.anexosDownload(filename)
176
+ downloadBlob(blob, filename)
177
+ } catch (err) {
178
+ setError(err?.message || 'Falha ao baixar os anexos.')
179
+ } finally {
180
+ setDownloading(false)
181
+ }
182
+ }
183
+
184
+ function fechar() {
185
+ if (!blocking && typeof onClose === 'function') onClose()
186
+ }
187
+
188
+ return (
189
+ <div className="pesquisa-modal-backdrop" onClick={(event) => {
190
+ if (event.target === event.currentTarget) fechar()
191
+ }}>
192
+ <div className="pesquisa-modal anexos-modal" role="dialog" aria-modal="true" aria-label={title}>
193
+ <div className="pesquisa-modal-head">
194
+ <div>
195
+ <h4>{title}</h4>
196
+ {fonte ? <p>Fonte: {formatarFonte(fonte)}</p> : null}
197
+ </div>
198
+ <button type="button" className="pesquisa-modal-close" onClick={fechar} disabled={blocking}>
199
+ Fechar
200
+ </button>
201
+ </div>
202
+
203
+ <form className="pesquisa-modal-body anexos-modal-body" onSubmit={gerar}>
204
+ <label className="anexos-modal-model-field">
205
+ Modelo de avaliação
206
+ <select
207
+ value={modeloSelecionado}
208
+ onChange={(event) => setModeloSelecionado(event.target.value)}
209
+ disabled={busy || lockModelo || !modelos.length}
210
+ >
211
+ {!modelos.length ? <option value="">Nenhum modelo encontrado</option> : null}
212
+ {modelos.map((modelo) => (
213
+ <option key={String(modelo.id)} value={String(modelo.id)}>
214
+ {modelo.nome_modelo || modelo.arquivo || modelo.id}
215
+ </option>
216
+ ))}
217
+ </select>
218
+ </label>
219
+
220
+ <section className="anexos-columns-section">
221
+ <div className="anexos-columns-head">
222
+ <div>
223
+ <strong>Colunas do banco de dados</strong>
224
+ <span>{colunasSelecionadas.length} selecionadas</span>
225
+ </div>
226
+ <div className="anexos-columns-actions">
227
+ <button
228
+ type="button"
229
+ onClick={() => setColunas((current) => current.map((item) => ({ ...item, selected: true })))}
230
+ disabled={busy}
231
+ >
232
+ Todas
233
+ </button>
234
+ <button
235
+ type="button"
236
+ onClick={() => setColunas((current) => current.map((item) => ({ ...item, selected: false })))}
237
+ disabled={busy}
238
+ >
239
+ Nenhuma
240
+ </button>
241
+ </div>
242
+ </div>
243
+
244
+ {loadingColunas ? (
245
+ <div className="section1-empty-hint">Carregando colunas...</div>
246
+ ) : (
247
+ <div className="anexos-columns-list">
248
+ {colunas.map((coluna, index) => (
249
+ <div className="anexos-column-row" key={coluna.name}>
250
+ <label>
251
+ <input
252
+ type="checkbox"
253
+ checked={coluna.selected}
254
+ onChange={() => toggleColuna(coluna.name)}
255
+ disabled={busy}
256
+ />
257
+ <span>{coluna.name}</span>
258
+ </label>
259
+ <div className="anexos-reorder-actions">
260
+ <button
261
+ type="button"
262
+ title="Mover para cima"
263
+ aria-label={`Mover ${coluna.name} para cima`}
264
+ onClick={() => moverColuna(index, -1)}
265
+ disabled={busy || index === 0}
266
+ >
267
+
268
+ </button>
269
+ <button
270
+ type="button"
271
+ title="Mover para baixo"
272
+ aria-label={`Mover ${coluna.name} para baixo`}
273
+ onClick={() => moverColuna(index, 1)}
274
+ disabled={busy || index === colunas.length - 1}
275
+ >
276
+
277
+ </button>
278
+ </div>
279
+ </div>
280
+ ))}
281
+ </div>
282
+ )}
283
+ </section>
284
+
285
+ {error ? <div className="error-line inline-error">{error}</div> : null}
286
+
287
+ {result?.statistical_warnings?.length ? (
288
+ <div className="anexos-warning-panel">
289
+ <strong>Atenção</strong>
290
+ <ul>
291
+ {result.statistical_warnings.map((warning) => <li key={warning}>{warning}</li>)}
292
+ </ul>
293
+ </div>
294
+ ) : null}
295
+
296
+ {result ? (
297
+ <div className="anexos-result-panel">
298
+ <div>
299
+ <strong>{result.message}</strong>
300
+ <span>{result.filename}</span>
301
+ </div>
302
+ <button type="button" onClick={() => void baixar()} disabled={downloading}>
303
+ {downloading ? 'Baixando...' : 'Baixar DOCX'}
304
+ </button>
305
+ </div>
306
+ ) : null}
307
+
308
+ <div className="anexos-modal-footer">
309
+ <button type="submit" disabled={!canGenerate}>
310
+ {generating ? 'Gerando...' : 'Gerar DOCX'}
311
+ </button>
312
+ </div>
313
+ </form>
314
+ </div>
315
+ </div>
316
+ )
317
+ }
frontend/src/components/AvaliacaoTab.jsx CHANGED
@@ -3,6 +3,7 @@ import { api, downloadBlob } from '../api'
3
  import { buildCsvBlob } from '../csv'
4
  import { buildAvaliacaoModeloLink } from '../deepLinks'
5
  import LoadingOverlay from './LoadingOverlay'
 
6
  import MapFrame from './MapFrame'
7
  import ModeloObservacaoCard from './ModeloObservacaoCard'
8
  import ShareLinkButton from './ShareLinkButton'
@@ -585,6 +586,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
585
  const [repoFonteModelos, setRepoFonteModelos] = useState('')
586
 
587
  const [modeloAtualNome, setModeloAtualNome] = useState('')
 
588
  const [camposAvaliacao, setCamposAvaliacao] = useState([])
589
  const [equacaoSab, setEquacaoSab] = useState('')
590
  const valoresAvaliacaoRef = useRef({})
@@ -605,6 +607,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
605
  const [confirmarExclusaoCardId, setConfirmarExclusaoCardId] = useState('')
606
  const [avaliacaoPopup, setAvaliacaoPopup] = useState(null)
607
  const [avaliacaoInfoModal, setAvaliacaoInfoModal] = useState(null)
 
608
  const [knnDetalheAberto, setKnnDetalheAberto] = useState(false)
609
  const [knnDetalheLoading, setKnnDetalheLoading] = useState(false)
610
  const [knnDetalheErro, setKnnDetalheErro] = useState('')
@@ -921,12 +924,13 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
921
  setAvaliacaoFormVersion((prev) => prev + 1)
922
  }
923
 
924
- function aplicarRespostaExibicao(resp, nomeModelo) {
925
  const campos = resp?.campos_avaliacao || []
926
  setCamposAvaliacao(campos)
927
  resetCamposAvaliacao(campos)
928
  setEquacaoSab(String(resp?.equacoes?.excel_sab || ''))
929
  setModeloAtualNome(String(nomeModelo || '').trim())
 
930
  setModeloObservacao(String(resp?.meta_modelo?.observacao_modelo || '').trim())
931
  }
932
 
@@ -990,7 +994,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
990
  setBadgeHtml(removerObservacaoDoBadgeHtml(uploadResp?.badge_html || ''))
991
  const nomeModelo = uploadResp?.nome_modelo || arquivoUpload.name || ''
992
  const contextoResp = await api.evaluationContextViz(sessionId)
993
- aplicarRespostaExibicao(contextoResp, nomeModelo)
994
  if (typeof onRouteChange === 'function') {
995
  onRouteChange({ tab: 'avaliacao' })
996
  }
@@ -1018,7 +1022,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
1018
  const modeloSelecionado = repoModeloOptions.find((item) => item.value === modeloIdUsado)
1019
  const nomeModelo = uploadResp?.nome_modelo || modeloSelecionado?.label || ''
1020
  const contextoResp = await api.evaluationContextViz(sessionId)
1021
- aplicarRespostaExibicao(contextoResp, nomeModelo)
1022
  setUploadedFile(null)
1023
  if (typeof onRouteChange === 'function') {
1024
  onRouteChange({
@@ -1162,6 +1166,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
1162
  const card = {
1163
  id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
1164
  modelo: modeloAtualNome || 'Modelo sem nome',
 
1165
  createdAt: new Date().toISOString(),
1166
  avaliacao: ultima,
1167
  }
@@ -1839,6 +1844,18 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
1839
  <span>{item.modelo}</span>
1840
  </div>
1841
  <div className="avaliacao-modelos-card-actions">
 
 
 
 
 
 
 
 
 
 
 
 
1842
  {!exclusaoPendente ? (
1843
  <button
1844
  type="button"
@@ -2014,7 +2031,20 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
2014
  )}
2015
  </div>
2016
 
2017
- <LoadingOverlay show={loading} label="Processando dados..." />
 
 
 
 
 
 
 
 
 
 
 
 
 
2018
  {avaliacaoPopup?.html ? (
2019
  <div
2020
  className="avaliacao-popup-overlay"
 
3
  import { buildCsvBlob } from '../csv'
4
  import { buildAvaliacaoModeloLink } from '../deepLinks'
5
  import LoadingOverlay from './LoadingOverlay'
6
+ import AnexosModal from './AnexosModal'
7
  import MapFrame from './MapFrame'
8
  import ModeloObservacaoCard from './ModeloObservacaoCard'
9
  import ShareLinkButton from './ShareLinkButton'
 
586
  const [repoFonteModelos, setRepoFonteModelos] = useState('')
587
 
588
  const [modeloAtualNome, setModeloAtualNome] = useState('')
589
+ const [modeloAtualId, setModeloAtualId] = useState('')
590
  const [camposAvaliacao, setCamposAvaliacao] = useState([])
591
  const [equacaoSab, setEquacaoSab] = useState('')
592
  const valoresAvaliacaoRef = useRef({})
 
607
  const [confirmarExclusaoCardId, setConfirmarExclusaoCardId] = useState('')
608
  const [avaliacaoPopup, setAvaliacaoPopup] = useState(null)
609
  const [avaliacaoInfoModal, setAvaliacaoInfoModal] = useState(null)
610
+ const [anexosCardModal, setAnexosCardModal] = useState(null)
611
  const [knnDetalheAberto, setKnnDetalheAberto] = useState(false)
612
  const [knnDetalheLoading, setKnnDetalheLoading] = useState(false)
613
  const [knnDetalheErro, setKnnDetalheErro] = useState('')
 
924
  setAvaliacaoFormVersion((prev) => prev + 1)
925
  }
926
 
927
+ function aplicarRespostaExibicao(resp, nomeModelo, modeloId = '') {
928
  const campos = resp?.campos_avaliacao || []
929
  setCamposAvaliacao(campos)
930
  resetCamposAvaliacao(campos)
931
  setEquacaoSab(String(resp?.equacoes?.excel_sab || ''))
932
  setModeloAtualNome(String(nomeModelo || '').trim())
933
+ setModeloAtualId(String(modeloId || nomeModelo || '').trim())
934
  setModeloObservacao(String(resp?.meta_modelo?.observacao_modelo || '').trim())
935
  }
936
 
 
994
  setBadgeHtml(removerObservacaoDoBadgeHtml(uploadResp?.badge_html || ''))
995
  const nomeModelo = uploadResp?.nome_modelo || arquivoUpload.name || ''
996
  const contextoResp = await api.evaluationContextViz(sessionId)
997
+ aplicarRespostaExibicao(contextoResp, nomeModelo, uploadResp?.nome_modelo || arquivoUpload.name)
998
  if (typeof onRouteChange === 'function') {
999
  onRouteChange({ tab: 'avaliacao' })
1000
  }
 
1022
  const modeloSelecionado = repoModeloOptions.find((item) => item.value === modeloIdUsado)
1023
  const nomeModelo = uploadResp?.nome_modelo || modeloSelecionado?.label || ''
1024
  const contextoResp = await api.evaluationContextViz(sessionId)
1025
+ aplicarRespostaExibicao(contextoResp, nomeModelo, modeloIdUsado)
1026
  setUploadedFile(null)
1027
  if (typeof onRouteChange === 'function') {
1028
  onRouteChange({
 
1166
  const card = {
1167
  id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
1168
  modelo: modeloAtualNome || 'Modelo sem nome',
1169
+ modeloId: modeloAtualId || modeloAtualNome,
1170
  createdAt: new Date().toISOString(),
1171
  avaliacao: ultima,
1172
  }
 
1844
  <span>{item.modelo}</span>
1845
  </div>
1846
  <div className="avaliacao-modelos-card-actions">
1847
+ <button
1848
+ type="button"
1849
+ className="avaliacao-modelos-anexo-btn"
1850
+ onClick={() => setAnexosCardModal({
1851
+ modeloId: item.modeloId || item.modelo,
1852
+ avaliacao: aval,
1853
+ titulo: `Gerar anexo da Aval. ${idx + 1}`,
1854
+ })}
1855
+ disabled={loading}
1856
+ >
1857
+ Gerar anexo
1858
+ </button>
1859
  {!exclusaoPendente ? (
1860
  <button
1861
  type="button"
 
2031
  )}
2032
  </div>
2033
 
2034
+ <LoadingOverlay
2035
+ show={loading || avaliandoLocalizacaoLoading}
2036
+ label={avaliandoLocalizacaoLoading
2037
+ ? (avaliandoLocalizacaoModo === 'endereco' ? 'Buscando endereço...' : 'Buscando localização...')
2038
+ : 'Processando dados...'}
2039
+ />
2040
+ <AnexosModal
2041
+ open={Boolean(anexosCardModal)}
2042
+ defaultModeloId={anexosCardModal?.modeloId || ''}
2043
+ avaliacao={anexosCardModal?.avaliacao || null}
2044
+ lockModelo
2045
+ title={anexosCardModal?.titulo || 'Gerar anexo da avaliação'}
2046
+ onClose={() => setAnexosCardModal(null)}
2047
+ />
2048
  {avaliacaoPopup?.html ? (
2049
  <div
2050
  className="avaliacao-popup-overlay"
frontend/src/components/ElaboracaoTab.jsx CHANGED
@@ -1596,8 +1596,6 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
1596
  const [importPreview, setImportPreview] = useState(null)
1597
  const [importSelectedColumns, setImportSelectedColumns] = useState([])
1598
  const [requiresImportColumnSelection, setRequiresImportColumnSelection] = useState(false)
1599
- const [firstColumnsCount, setFirstColumnsCount] = useState('')
1600
- const [firstColumnsChecked, setFirstColumnsChecked] = useState(false)
1601
  const [elaborador, setElaborador] = useState(null)
1602
  const [modeloCarregadoInfo, setModeloCarregadoInfo] = useState(null)
1603
  const [repoModelos, setRepoModelos] = useState([])
@@ -3476,16 +3474,15 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3476
  setSheetOptions(resp.sheets || [])
3477
  const proximaImportPreview = resp?.import_preview || null
3478
  const proximaSelecaoColunasPendente = Boolean(resp?.requires_column_selection)
 
 
 
3479
  setImportPreview(proximaImportPreview)
3480
  setRequiresImportColumnSelection(proximaSelecaoColunasPendente)
3481
  if (proximaImportPreview && proximaSelecaoColunasPendente) {
3482
- setImportSelectedColumns([])
3483
- setFirstColumnsCount('')
3484
- setFirstColumnsChecked(false)
3485
  } else if (!proximaImportPreview) {
3486
  setImportSelectedColumns([])
3487
- setFirstColumnsCount('')
3488
- setFirstColumnsChecked(false)
3489
  }
3490
  const nomeModeloCarregado = String(resp?.nome_modelo || '').trim()
3491
  if (Boolean(resp.requires_sheet)) {
@@ -3609,8 +3606,6 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3609
  setImportPreview(null)
3610
  setImportSelectedColumns([])
3611
  setRequiresImportColumnSelection(false)
3612
- setFirstColumnsCount('')
3613
- setFirstColumnsChecked(false)
3614
  const nomeArquivo = String(arquivoUpload?.name || '').toLowerCase()
3615
  const uploadEhDai = nomeArquivo.endsWith('.dai')
3616
  setTipoFonteDados(uploadEhDai ? 'dai' : 'tabular')
@@ -3649,8 +3644,6 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3649
  setImportPreview(null)
3650
  setImportSelectedColumns([])
3651
  setRequiresImportColumnSelection(false)
3652
- setFirstColumnsCount('')
3653
- setFirstColumnsChecked(false)
3654
  setUploadedFile(null)
3655
  setTipoFonteDados('tabular')
3656
  const resp = await api.pasteElaboracaoTable(sessionId, textoColado)
@@ -3693,8 +3686,6 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3693
  setImportPreview(null)
3694
  setImportSelectedColumns([])
3695
  setRequiresImportColumnSelection(false)
3696
- setFirstColumnsCount('')
3697
- setFirstColumnsChecked(false)
3698
  setTipoFonteDados('dai')
3699
  const resp = await api.elaboracaoRepositorioCarregar(sessionId, modeloId)
3700
  aplicarRespostaCarregamento(resp, 'dai', { source: 'repo' })
@@ -3784,7 +3775,6 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3784
  function onToggleImportColumn(coluna) {
3785
  const valor = String(coluna || '').trim()
3786
  if (!valor) return
3787
- setFirstColumnsChecked(false)
3788
  setImportSelectedColumns((prev) => {
3789
  const next = prev.includes(valor)
3790
  ? prev.filter((item) => item !== valor)
@@ -3794,40 +3784,8 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3794
  setRequiresImportColumnSelection(true)
3795
  }
3796
 
3797
- function getFirstImportColumnsQuantity(value = firstColumnsCount) {
3798
- const limite = importPreviewColumns.length
3799
- const parsed = Number.parseInt(String(value || '0'), 10)
3800
- return Number.isFinite(parsed) ? Math.max(0, Math.min(limite, parsed)) : 0
3801
- }
3802
-
3803
- function onFirstColumnsCountChange(value) {
3804
- setFirstColumnsCount(value)
3805
- if (!firstColumnsChecked) return
3806
- const quantidade = getFirstImportColumnsQuantity(value)
3807
- setImportSelectedColumns(importPreviewColumns.slice(0, quantidade))
3808
- setRequiresImportColumnSelection(true)
3809
- setImportacaoErro('')
3810
- }
3811
-
3812
- function onToggleFirstImportColumns(event) {
3813
- const checked = event.target.checked
3814
- setFirstColumnsChecked(checked)
3815
- const quantidade = getFirstImportColumnsQuantity()
3816
- const primeiras = importPreviewColumns.slice(0, quantidade)
3817
- if (checked) {
3818
- setFirstColumnsCount(String(quantidade))
3819
- setImportSelectedColumns(primeiras)
3820
- } else {
3821
- const primeirasSet = new Set(primeiras)
3822
- setImportSelectedColumns((prev) => prev.filter((coluna) => !primeirasSet.has(coluna)))
3823
- }
3824
- setRequiresImportColumnSelection(true)
3825
- setImportacaoErro('')
3826
- }
3827
-
3828
  function onClearImportColumns() {
3829
  setImportSelectedColumns([])
3830
- setFirstColumnsChecked(false)
3831
  setRequiresImportColumnSelection(true)
3832
  setImportacaoErro('')
3833
  }
@@ -5683,26 +5641,6 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
5683
  </div>
5684
 
5685
  <div className="import-preview-tools">
5686
- <label className="import-preview-first-columns-toggle">
5687
- <input
5688
- type="checkbox"
5689
- checked={firstColumnsChecked}
5690
- onChange={onToggleFirstImportColumns}
5691
- disabled={loading || importPreviewColumns.length === 0}
5692
- />
5693
- Selecionar primeiras
5694
- </label>
5695
- <label className="import-preview-first-columns-field">
5696
- <input
5697
- type="number"
5698
- min="0"
5699
- max={importPreviewColumns.length}
5700
- value={firstColumnsCount}
5701
- onChange={(event) => onFirstColumnsCountChange(event.target.value)}
5702
- disabled={loading || importPreviewColumns.length === 0}
5703
- />
5704
- colunas
5705
- </label>
5706
  <button
5707
  type="button"
5708
  className="import-preview-clear-btn"
 
1596
  const [importPreview, setImportPreview] = useState(null)
1597
  const [importSelectedColumns, setImportSelectedColumns] = useState([])
1598
  const [requiresImportColumnSelection, setRequiresImportColumnSelection] = useState(false)
 
 
1599
  const [elaborador, setElaborador] = useState(null)
1600
  const [modeloCarregadoInfo, setModeloCarregadoInfo] = useState(null)
1601
  const [repoModelos, setRepoModelos] = useState([])
 
3474
  setSheetOptions(resp.sheets || [])
3475
  const proximaImportPreview = resp?.import_preview || null
3476
  const proximaSelecaoColunasPendente = Boolean(resp?.requires_column_selection)
3477
+ const proximaColunasImportacao = Array.isArray(proximaImportPreview?.columns)
3478
+ ? proximaImportPreview.columns.map((item) => String(item))
3479
+ : []
3480
  setImportPreview(proximaImportPreview)
3481
  setRequiresImportColumnSelection(proximaSelecaoColunasPendente)
3482
  if (proximaImportPreview && proximaSelecaoColunasPendente) {
3483
+ setImportSelectedColumns(proximaColunasImportacao)
 
 
3484
  } else if (!proximaImportPreview) {
3485
  setImportSelectedColumns([])
 
 
3486
  }
3487
  const nomeModeloCarregado = String(resp?.nome_modelo || '').trim()
3488
  if (Boolean(resp.requires_sheet)) {
 
3606
  setImportPreview(null)
3607
  setImportSelectedColumns([])
3608
  setRequiresImportColumnSelection(false)
 
 
3609
  const nomeArquivo = String(arquivoUpload?.name || '').toLowerCase()
3610
  const uploadEhDai = nomeArquivo.endsWith('.dai')
3611
  setTipoFonteDados(uploadEhDai ? 'dai' : 'tabular')
 
3644
  setImportPreview(null)
3645
  setImportSelectedColumns([])
3646
  setRequiresImportColumnSelection(false)
 
 
3647
  setUploadedFile(null)
3648
  setTipoFonteDados('tabular')
3649
  const resp = await api.pasteElaboracaoTable(sessionId, textoColado)
 
3686
  setImportPreview(null)
3687
  setImportSelectedColumns([])
3688
  setRequiresImportColumnSelection(false)
 
 
3689
  setTipoFonteDados('dai')
3690
  const resp = await api.elaboracaoRepositorioCarregar(sessionId, modeloId)
3691
  aplicarRespostaCarregamento(resp, 'dai', { source: 'repo' })
 
3775
  function onToggleImportColumn(coluna) {
3776
  const valor = String(coluna || '').trim()
3777
  if (!valor) return
 
3778
  setImportSelectedColumns((prev) => {
3779
  const next = prev.includes(valor)
3780
  ? prev.filter((item) => item !== valor)
 
3784
  setRequiresImportColumnSelection(true)
3785
  }
3786
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3787
  function onClearImportColumns() {
3788
  setImportSelectedColumns([])
 
3789
  setRequiresImportColumnSelection(true)
3790
  setImportacaoErro('')
3791
  }
 
5641
  </div>
5642
 
5643
  <div className="import-preview-tools">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5644
  <button
5645
  type="button"
5646
  className="import-preview-clear-btn"
frontend/src/components/LeafletMapFrame.jsx CHANGED
@@ -43,6 +43,18 @@ function normalizeCssColor(value, fallback = '#607d8b') {
43
  return fallback
44
  }
45
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  function buildGroupedMarketMarkerHtml(item, size = 22) {
47
  const count = Math.max(2, Number(item?.count) || 2)
48
  const color = normalizeCssColor(item?.color || item?.fill_color || '#607d8b')
@@ -50,9 +62,12 @@ function buildGroupedMarketMarkerHtml(item, size = 22) {
50
  const fontSize = Math.max(9, Math.min(12, markerSize * 0.38))
51
  const borderSize = markerSize >= 24 ? 3 : 2
52
  const haloSize = markerSize >= 24 ? 4 : 2
 
 
 
53
  return (
54
  `<div class="mesa-market-group-marker" style="--mesa-group-color:${color};--mesa-group-size:${markerSize}px;--mesa-group-font:${fontSize}px;--mesa-group-border:${borderSize}px;--mesa-group-halo:${haloSize}px;">`
55
- + `<span>${escapeHtml(count > 99 ? '99+' : count)}</span>`
56
  + '</div>'
57
  )
58
  }
@@ -318,6 +333,21 @@ function formatDistance(distanceMeters) {
318
  return `${value.toLocaleString('pt-BR', { maximumFractionDigits: 1 })} m`
319
  }
320
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  function computePolygonArea(latlngs) {
322
  if (!Array.isArray(latlngs) || latlngs.length < 3) return 0
323
  const d2r = Math.PI / 180
@@ -1156,6 +1186,7 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
1156
  })
1157
  mapRef.current = map
1158
  let restoreMapInteractions = null
 
1159
  const bairrosPane = map.createPane('mesa-bairros-pane')
1160
  const marketPane = map.createPane('mesa-market-pane')
1161
  const marketGroupPane = map.createPane('mesa-market-group-pane')
@@ -1451,26 +1482,11 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
1451
  })
1452
  }
1453
 
1454
- function updateHoverLegendState(groupKey, activeId) {
1455
- const metas = hoverHighlightMetas.filter((entry) => entry.groupKey === groupKey)
1456
- metas.forEach((meta) => {
1457
- const label = meta.labelElement
1458
- if (!label) return
1459
- const isActive = Boolean(activeId) && meta.id === activeId
1460
- label.style.cursor = 'pointer'
1461
- label.style.transition = 'opacity 0.16s ease, color 0.16s ease, font-weight 0.16s ease'
1462
- label.style.opacity = activeId ? (isActive ? '1' : '0.42') : ''
1463
- label.style.color = isActive ? '#123b63' : ''
1464
- label.style.fontWeight = isActive ? '700' : ''
1465
- })
1466
- }
1467
-
1468
  function clearHoverHighlight(groupKey) {
1469
  const metas = hoverHighlightMetas.filter((entry) => entry.groupKey === groupKey)
1470
  metas.forEach((meta) => {
1471
  applyGroupState(meta, 'normal')
1472
  })
1473
- updateHoverLegendState(groupKey, null)
1474
  }
1475
 
1476
  function setHoveredHighlight(groupKey, activeId) {
@@ -1488,47 +1504,6 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
1488
  }
1489
  applyGroupState(meta, meta.id === activeId ? 'highlight' : 'dim')
1490
  })
1491
- updateHoverLegendState(groupKey, activeId)
1492
- }
1493
-
1494
- function bindLayerControlHover(layerControl) {
1495
- const container = layerControl?.getContainer?.()
1496
- if (!container) return
1497
- const labels = Array.from(container.querySelectorAll('.leaflet-control-layers-overlays label'))
1498
- if (!labels.length) return
1499
-
1500
- const metaByLayerId = new Map()
1501
- hoverHighlightMetas.forEach((meta) => {
1502
- if (!meta?.layerGroup) return
1503
- metaByLayerId.set(String(L.Util.stamp(meta.layerGroup)), meta)
1504
- })
1505
-
1506
- labels.forEach((label) => {
1507
- const input = label.querySelector('input')
1508
- const layerId = String(input?.layerId || '')
1509
- const meta = metaByLayerId.get(layerId)
1510
- if (!meta) return
1511
- meta.labelElement = label
1512
- if (label.dataset.mesaModelHoverBound === '1') return
1513
- label.dataset.mesaModelHoverBound = '1'
1514
- const onEnter = () => setHoveredHighlight(meta.groupKey, meta.id)
1515
- const onLeave = (event) => {
1516
- const nextTarget = event?.relatedTarget || null
1517
- if (nextTarget && label.contains(nextTarget)) return
1518
- clearHoverHighlight(meta.groupKey)
1519
- }
1520
- label.addEventListener('mouseenter', onEnter)
1521
- label.addEventListener('mouseover', onEnter)
1522
- label.addEventListener('mouseleave', onLeave)
1523
- label.addEventListener('mouseout', onLeave)
1524
- })
1525
-
1526
- hoverHighlightMetas.forEach((meta) => {
1527
- if (meta.labelElement) {
1528
- meta.labelElement.style.cursor = 'pointer'
1529
- meta.labelElement.style.transition = 'opacity 0.16s ease, color 0.16s ease, font-weight 0.16s ease'
1530
- }
1531
- })
1532
  }
1533
 
1534
  function addPointMarkers(layerGroup, items) {
@@ -1538,6 +1513,42 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
1538
  const latlng = parseLatLonPair(item?.lat, item?.lon)
1539
  if (!latlng) return
1540
  if (item?.grouped) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1541
  const marker = L.marker(latlng, {
1542
  icon: buildGroupedMarketIcon(item, 16),
1543
  pane: String(item?.pane || 'mesa-market-group-pane'),
@@ -1561,6 +1572,7 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
1561
  setGroupSelection({
1562
  title: String(item?.group_title || `${groupItems.length} dados neste local`),
1563
  count: Number(item?.count) || groupItems.length,
 
1564
  items: groupItems,
1565
  })
1566
  })
@@ -1797,7 +1809,7 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
1797
  if (specId) {
1798
  overlayGroupsById.set(specId, layerGroup)
1799
  }
1800
- if (spec?.show !== false) {
1801
  layerGroup.addTo(map)
1802
  }
1803
  if (spec?.geojson_url || spec?.geojson_data) {
@@ -1823,7 +1835,6 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
1823
  id: specId,
1824
  groupKey: hoverGroup,
1825
  layerGroup,
1826
- labelElement: null,
1827
  })
1828
  }
1829
  })
@@ -1833,14 +1844,118 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
1833
  map.on('overlayremove', refreshDependencyAwareOverlays)
1834
  }
1835
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1836
  if (payload.controls?.layer_control) {
1837
- const collapsed = payload.controls?.layer_control_collapsed !== false
1838
  const layerControl = L.control.layers(baseLayers, overlayLayers, { collapsed }).addTo(map)
1839
- if (hoverHighlightMetas.length) {
1840
- window.requestAnimationFrame(() => bindLayerControlHover(layerControl))
1841
- window.setTimeout(() => bindLayerControlHover(layerControl), 40)
1842
- window.setTimeout(() => bindLayerControlHover(layerControl), 180)
1843
- }
1844
  }
1845
  if (payload.controls?.measure) {
1846
  const disableInteractionsForMeasure = () => {
@@ -2103,6 +2218,357 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
2103
  measureControl.addTo(map)
2104
  }
2105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2106
  if (payload.controls?.fullscreen) {
2107
  addFullscreenControl(map, onToggleFullscreen)
2108
  }
@@ -2142,6 +2608,7 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
2142
  }
2143
  disposed = true
2144
  restoreMapInteractions?.()
 
2145
  if (popupLoaderRef.current === carregarPopupRegistro) {
2146
  popupLoaderRef.current = null
2147
  }
@@ -2169,7 +2636,9 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
2169
  <div className="leaflet-market-group-modal-head">
2170
  <div>
2171
  <strong>{groupSelection.title}</strong>
2172
- <span>{Number(groupSelection.count) || groupSelection.items?.length || 0} registros sobrepostos</span>
 
 
2173
  </div>
2174
  <button type="button" onClick={closeGroupSelection} aria-label="Fechar">×</button>
2175
  </div>
 
43
  return fallback
44
  }
45
 
46
+ function isTrueFlag(value) {
47
+ return value === true || value === 1 || value === '1'
48
+ }
49
+
50
+ function isFalseFlag(value) {
51
+ return value === false || value === 0 || value === '0'
52
+ }
53
+
54
+ function naturalCompare(a, b) {
55
+ return String(a ?? '').localeCompare(String(b ?? ''), 'pt-BR', { numeric: true, sensitivity: 'base' })
56
+ }
57
+
58
  function buildGroupedMarketMarkerHtml(item, size = 22) {
59
  const count = Math.max(2, Number(item?.count) || 2)
60
  const color = normalizeCssColor(item?.color || item?.fill_color || '#607d8b')
 
62
  const fontSize = Math.max(9, Math.min(12, markerSize * 0.38))
63
  const borderSize = markerSize >= 24 ? 3 : 2
64
  const haloSize = markerSize >= 24 ? 4 : 2
65
+ const labelHtml = isTrueFlag(item?.hide_count)
66
+ ? ''
67
+ : `<span>${escapeHtml(count > 99 ? '99+' : count)}</span>`
68
  return (
69
  `<div class="mesa-market-group-marker" style="--mesa-group-color:${color};--mesa-group-size:${markerSize}px;--mesa-group-font:${fontSize}px;--mesa-group-border:${borderSize}px;--mesa-group-halo:${haloSize}px;">`
70
+ + labelHtml
71
  + '</div>'
72
  )
73
  }
 
333
  return `${value.toLocaleString('pt-BR', { maximumFractionDigits: 1 })} m`
334
  }
335
 
336
+ function pointInPolygon(point, polygon) {
337
+ if (!point || !Array.isArray(polygon) || polygon.length < 3) return false
338
+ let inside = false
339
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
340
+ const xi = polygon[i].x
341
+ const yi = polygon[i].y
342
+ const xj = polygon[j].x
343
+ const yj = polygon[j].y
344
+ const intersects = ((yi > point.y) !== (yj > point.y))
345
+ && (point.x < ((xj - xi) * (point.y - yi)) / ((yj - yi) || Number.EPSILON) + xi)
346
+ if (intersects) inside = !inside
347
+ }
348
+ return inside
349
+ }
350
+
351
  function computePolygonArea(latlngs) {
352
  if (!Array.isArray(latlngs) || latlngs.length < 3) return 0
353
  const d2r = Math.PI / 180
 
1186
  })
1187
  mapRef.current = map
1188
  let restoreMapInteractions = null
1189
+ let cleanupLasso = null
1190
  const bairrosPane = map.createPane('mesa-bairros-pane')
1191
  const marketPane = map.createPane('mesa-market-pane')
1192
  const marketGroupPane = map.createPane('mesa-market-group-pane')
 
1482
  })
1483
  }
1484
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1485
  function clearHoverHighlight(groupKey) {
1486
  const metas = hoverHighlightMetas.filter((entry) => entry.groupKey === groupKey)
1487
  metas.forEach((meta) => {
1488
  applyGroupState(meta, 'normal')
1489
  })
 
1490
  }
1491
 
1492
  function setHoveredHighlight(groupKey, activeId) {
 
1504
  }
1505
  applyGroupState(meta, meta.id === activeId ? 'highlight' : 'dim')
1506
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1507
  }
1508
 
1509
  function addPointMarkers(layerGroup, items) {
 
1513
  const latlng = parseLatLonPair(item?.lat, item?.lon)
1514
  if (!latlng) return
1515
  if (item?.grouped) {
1516
+ if (isTrueFlag(item?.hide_count)) {
1517
+ const marker = L.circleMarker(latlng, {
1518
+ renderer: canvasRenderer,
1519
+ pane: String(item?.pane || 'mesa-market-pane'),
1520
+ radius: Number(item?.base_radius) || 4,
1521
+ color: String(item?.stroke_color || '#000000'),
1522
+ weight: Number.isFinite(Number(item?.stroke_weight))
1523
+ ? Number(item.stroke_weight)
1524
+ : (Number(item?.base_radius) > 4 ? 1 : 0.8),
1525
+ fill: item?.fill !== false,
1526
+ fillColor: String(item?.color || item?.fill_color || '#FF8C00'),
1527
+ fillOpacity: Number.isFinite(Number(item?.fill_opacity)) ? Number(item.fill_opacity) : 0.68,
1528
+ interactive: item?.interactive !== false,
1529
+ bubblingMouseEvents: false,
1530
+ })
1531
+ marker.options.mesaBaseRadius = Number(item?.base_radius) || 4
1532
+ const tooltipHtml = String(item?.tooltip_html || '').trim()
1533
+ if (tooltipHtml) {
1534
+ marker.bindTooltip(tooltipHtml, { sticky: item?.tooltip_sticky !== false })
1535
+ }
1536
+ marker.on('click', () => {
1537
+ map.closePopup()
1538
+ marker.unbindPopup()
1539
+ const groupItems = Array.isArray(item?.group_items) ? item.group_items : []
1540
+ if (!groupItems.length) return
1541
+ groupSelectionMarkerRef.current = marker
1542
+ setGroupSelection({
1543
+ title: String(item?.group_title || 'Dados neste local'),
1544
+ count: Number(item?.count) || groupItems.length,
1545
+ hideCount: isTrueFlag(item?.hide_group_count),
1546
+ items: groupItems,
1547
+ })
1548
+ })
1549
+ layerGroup.addLayer(marker)
1550
+ return
1551
+ }
1552
  const marker = L.marker(latlng, {
1553
  icon: buildGroupedMarketIcon(item, 16),
1554
  pane: String(item?.pane || 'mesa-market-group-pane'),
 
1572
  setGroupSelection({
1573
  title: String(item?.group_title || `${groupItems.length} dados neste local`),
1574
  count: Number(item?.count) || groupItems.length,
1575
+ hideCount: isTrueFlag(item?.hide_group_count),
1576
  items: groupItems,
1577
  })
1578
  })
 
1809
  if (specId) {
1810
  overlayGroupsById.set(specId, layerGroup)
1811
  }
1812
+ if (!isFalseFlag(spec?.show)) {
1813
  layerGroup.addTo(map)
1814
  }
1815
  if (spec?.geojson_url || spec?.geojson_data) {
 
1835
  id: specId,
1836
  groupKey: hoverGroup,
1837
  layerGroup,
 
1838
  })
1839
  }
1840
  })
 
1844
  map.on('overlayremove', refreshDependencyAwareOverlays)
1845
  }
1846
 
1847
+ function syncInitialOverlayVisibility(layerControl = null) {
1848
+ overlaySpecs.forEach((spec) => {
1849
+ const label = String(spec?.label || spec?.id || '').trim()
1850
+ if (!label) return
1851
+ const layerGroup = overlayLayers[label]
1852
+ if (!layerGroup) return
1853
+ if (isFalseFlag(spec?.show)) {
1854
+ map.removeLayer(layerGroup)
1855
+ }
1856
+ })
1857
+
1858
+ const container = layerControl?.getContainer?.()
1859
+ if (!container) return
1860
+ const inputs = Array.from(container.querySelectorAll('.leaflet-control-layers-overlays input[type="checkbox"]'))
1861
+ inputs.forEach((input) => {
1862
+ const layerId = String(input?.layerId || '')
1863
+ const matchingLabel = Object.entries(overlayLayers).find(([, layer]) => String(L.Util.stamp(layer)) === layerId)?.[0]
1864
+ if (!matchingLabel) return
1865
+ const spec = overlaySpecs.find((item) => String(item?.label || item?.id || '').trim() === matchingLabel)
1866
+ if (isFalseFlag(spec?.show)) {
1867
+ input.checked = false
1868
+ }
1869
+ })
1870
+ }
1871
+
1872
+ function addLayerControlCloseButton(layerControl) {
1873
+ const container = layerControl?.getContainer?.()
1874
+ if (!container || container.querySelector('.mesa-layer-control-close')) return
1875
+ const button = L.DomUtil.create('button', 'mesa-layer-control-close', container)
1876
+ button.type = 'button'
1877
+ button.setAttribute('aria-label', 'Fechar camadas')
1878
+ button.title = 'Fechar camadas'
1879
+ button.textContent = 'Fechar'
1880
+ L.DomEvent.disableClickPropagation(button)
1881
+ L.DomEvent.on(button, 'click', (event) => {
1882
+ L.DomEvent.stop(event)
1883
+ setLayerControlOpen(layerControl, false)
1884
+ })
1885
+ }
1886
+
1887
+ function updateLayerControlToggleState(layerControl) {
1888
+ const container = layerControl?.getContainer?.()
1889
+ const toggle = layerControl?._layersLink || container?.querySelector?.('.leaflet-control-layers-toggle')
1890
+ if (!container || !toggle) return
1891
+ toggle.setAttribute('aria-expanded', container.classList.contains('leaflet-control-layers-expanded') ? 'true' : 'false')
1892
+ }
1893
+
1894
+ function setLayerControlOpen(layerControl, open) {
1895
+ const container = layerControl?.getContainer?.()
1896
+ if (!container) return
1897
+ if (open && typeof layerControl.expand === 'function') {
1898
+ layerControl.expand()
1899
+ } else if (!open && typeof layerControl.collapse === 'function') {
1900
+ layerControl.collapse()
1901
+ } else {
1902
+ container.classList.toggle('leaflet-control-layers-expanded', !!open)
1903
+ }
1904
+ updateLayerControlToggleState(layerControl)
1905
+ }
1906
+
1907
+ function enableLayerControlClickToggle(layerControl) {
1908
+ const container = layerControl?.getContainer?.()
1909
+ if (!container) return
1910
+ container.classList.add('mesa-layer-control-click-toggle')
1911
+ if (layerControl?._map && typeof layerControl.collapse === 'function') {
1912
+ layerControl._map.off('click', layerControl.collapse, layerControl)
1913
+ }
1914
+ if (typeof layerControl?._expandSafely === 'function') {
1915
+ L.DomEvent.off(container, 'mouseenter', layerControl._expandSafely, layerControl)
1916
+ }
1917
+ if (typeof layerControl?.collapse === 'function') {
1918
+ L.DomEvent.off(container, 'mouseleave', layerControl.collapse, layerControl)
1919
+ }
1920
+
1921
+ const currentToggle = layerControl?._layersLink || container.querySelector('.leaflet-control-layers-toggle')
1922
+ if (!currentToggle || currentToggle.dataset.mesaClickToggle === '1') {
1923
+ updateLayerControlToggleState(layerControl)
1924
+ return
1925
+ }
1926
+
1927
+ const toggle = currentToggle.cloneNode(true)
1928
+ toggle.dataset.mesaClickToggle = '1'
1929
+ toggle.href = '#'
1930
+ toggle.title = 'Camadas'
1931
+ toggle.setAttribute('role', 'button')
1932
+ toggle.setAttribute('aria-label', 'Abrir ou fechar camadas')
1933
+ currentToggle.parentNode.replaceChild(toggle, currentToggle)
1934
+ layerControl._layersLink = toggle
1935
+
1936
+ const togglePanel = (event) => {
1937
+ L.DomEvent.stop(event)
1938
+ const expanded = container.classList.contains('leaflet-control-layers-expanded')
1939
+ setLayerControlOpen(layerControl, !expanded)
1940
+ }
1941
+
1942
+ L.DomEvent.disableClickPropagation(toggle)
1943
+ L.DomEvent.on(toggle, 'click', togglePanel)
1944
+ L.DomEvent.on(toggle, 'keydown', (event) => {
1945
+ if (event.key !== 'Enter' && event.key !== ' ' && event.keyCode !== 13 && event.keyCode !== 32) return
1946
+ togglePanel(event)
1947
+ })
1948
+ updateLayerControlToggleState(layerControl)
1949
+ }
1950
+
1951
+ syncInitialOverlayVisibility()
1952
+
1953
  if (payload.controls?.layer_control) {
1954
+ const collapsed = !isFalseFlag(payload.controls?.layer_control_collapsed)
1955
  const layerControl = L.control.layers(baseLayers, overlayLayers, { collapsed }).addTo(map)
1956
+ enableLayerControlClickToggle(layerControl)
1957
+ addLayerControlCloseButton(layerControl)
1958
+ syncInitialOverlayVisibility(layerControl)
 
 
1959
  }
1960
  if (payload.controls?.measure) {
1961
  const disableInteractionsForMeasure = () => {
 
2218
  measureControl.addTo(map)
2219
  }
2220
 
2221
+ if (payload.controls?.market_lasso) {
2222
+ const lassoLayerGroup = L.layerGroup().addTo(map)
2223
+ const lassoState = {
2224
+ panelOpen: false,
2225
+ active: false,
2226
+ drawing: false,
2227
+ points: [],
2228
+ selectedModels: [],
2229
+ error: '',
2230
+ downloading: false,
2231
+ }
2232
+
2233
+ let lassoRoot = null
2234
+ let lassoInteraction = null
2235
+ let lassoHint = null
2236
+ let lassoResults = null
2237
+ let lassoStartButton = null
2238
+ let lassoCancelButton = null
2239
+ let lassoDownloadButton = null
2240
+ let lassoCloseButton = null
2241
+
2242
+ function disableInteractionsForLasso() {
2243
+ if (restoreMapInteractions) return
2244
+ const states = {
2245
+ dragging: !!map.dragging?.enabled?.(),
2246
+ doubleClickZoom: !!map.doubleClickZoom?.enabled?.(),
2247
+ boxZoom: !!map.boxZoom?.enabled?.(),
2248
+ keyboard: !!map.keyboard?.enabled?.(),
2249
+ touchZoom: !!map.touchZoom?.enabled?.(),
2250
+ scrollWheelZoom: !!map.scrollWheelZoom?.enabled?.(),
2251
+ }
2252
+ map.dragging?.disable?.()
2253
+ map.doubleClickZoom?.disable?.()
2254
+ map.boxZoom?.disable?.()
2255
+ map.keyboard?.disable?.()
2256
+ map.touchZoom?.disable?.()
2257
+ map.scrollWheelZoom?.disable?.()
2258
+ if (map.getContainer()) {
2259
+ map.getContainer().style.cursor = 'crosshair'
2260
+ }
2261
+ restoreMapInteractions = () => {
2262
+ if (states.dragging) map.dragging?.enable?.()
2263
+ if (states.doubleClickZoom) map.doubleClickZoom?.enable?.()
2264
+ if (states.boxZoom) map.boxZoom?.enable?.()
2265
+ if (states.keyboard) map.keyboard?.enable?.()
2266
+ if (states.touchZoom) map.touchZoom?.enable?.()
2267
+ if (states.scrollWheelZoom) map.scrollWheelZoom?.enable?.()
2268
+ if (map.getContainer()) {
2269
+ map.getContainer().style.cursor = ''
2270
+ }
2271
+ restoreMapInteractions = null
2272
+ }
2273
+ }
2274
+
2275
+ function renderLassoGeometry() {
2276
+ lassoLayerGroup.clearLayers()
2277
+ if (!lassoState.points.length) return
2278
+ if (lassoState.points.length >= 2) {
2279
+ L.polyline(lassoState.points, {
2280
+ color: '#0f766e',
2281
+ weight: 2.6,
2282
+ opacity: 0.95,
2283
+ dashArray: lassoState.drawing ? '7 5' : undefined,
2284
+ }).addTo(lassoLayerGroup)
2285
+ }
2286
+ if (!lassoState.drawing && lassoState.points.length >= 3) {
2287
+ L.polygon(lassoState.points, {
2288
+ color: '#0f766e',
2289
+ weight: 2,
2290
+ opacity: 0.95,
2291
+ fillColor: '#0f766e',
2292
+ fillOpacity: 0.12,
2293
+ }).addTo(lassoLayerGroup)
2294
+ }
2295
+ }
2296
+
2297
+ function getSelectedMarketModels(points) {
2298
+ if (!Array.isArray(points) || points.length < 3) return []
2299
+ const polygonPoints = points.map((latlng) => map.latLngToLayerPoint(latlng))
2300
+ const selectedByModel = new Map()
2301
+
2302
+ overlaySpecs.forEach((spec) => {
2303
+ const specId = String(spec?.id || '').trim()
2304
+ const modelLayer = specId ? overlayGroupsById.get(specId) : null
2305
+ if (!Array.isArray(spec?.points) || !spec.points.length) return
2306
+ if (modelLayer && !map.hasLayer(modelLayer)) return
2307
+
2308
+ spec.points.forEach((item) => {
2309
+ const latlng = parseLatLonPair(item?.lat, item?.lon)
2310
+ if (!latlng) return
2311
+ const layerPoint = map.latLngToLayerPoint(L.latLng(latlng[0], latlng[1]))
2312
+ if (!pointInPolygon(layerPoint, polygonPoints)) return
2313
+
2314
+ const modelKey = String(item?.selection_model_id || specId || spec?.label || 'modelo')
2315
+ const modelName = String(item?.selection_model_name || spec?.label || modelKey)
2316
+ const indices = Array.isArray(item?.selection_indices) && item.selection_indices.length
2317
+ ? item.selection_indices
2318
+ : [item?.indice]
2319
+ const entry = selectedByModel.get(modelKey) || {
2320
+ nome: modelName,
2321
+ indices: [],
2322
+ seen: new Set(),
2323
+ }
2324
+ indices.forEach((indice) => {
2325
+ const text = String(indice ?? '').trim()
2326
+ if (!text || entry.seen.has(text)) return
2327
+ entry.seen.add(text)
2328
+ entry.indices.push(text)
2329
+ })
2330
+ selectedByModel.set(modelKey, entry)
2331
+ })
2332
+ })
2333
+
2334
+ return Array.from(selectedByModel.values())
2335
+ .map((entry) => ({
2336
+ nome: entry.nome,
2337
+ indices: entry.indices.sort(naturalCompare),
2338
+ }))
2339
+ .filter((entry) => entry.indices.length)
2340
+ }
2341
+
2342
+ function selectedTotal(models) {
2343
+ return (models || []).reduce((total, item) => total + (item.indices?.length || 0), 0)
2344
+ }
2345
+
2346
+ function renderLassoPanel() {
2347
+ if (!lassoRoot || !lassoInteraction || !lassoHint || !lassoResults) return
2348
+ lassoRoot.classList.toggle('leaflet-control-measure-expanded', lassoState.panelOpen)
2349
+ lassoRoot.classList.toggle('is-active', lassoState.active)
2350
+ lassoInteraction.style.display = lassoState.panelOpen ? 'block' : 'none'
2351
+ if (!lassoState.panelOpen) return
2352
+
2353
+ const total = selectedTotal(lassoState.selectedModels)
2354
+ const hasSelection = total > 0
2355
+ const hasAttempt = lassoState.points.length > 0 || !!lassoState.error
2356
+
2357
+ if (lassoState.error) {
2358
+ lassoHint.textContent = lassoState.error
2359
+ } else if (lassoState.active) {
2360
+ lassoHint.textContent = 'Arraste no mapa para contornar os dados. Solte para concluir.'
2361
+ } else if (lassoState.selectedModels.length) {
2362
+ lassoHint.textContent = `${total.toLocaleString('pt-BR')} índice(s) selecionado(s).`
2363
+ } else {
2364
+ lassoHint.textContent = 'Crie um laço sobre os dados de mercado.'
2365
+ }
2366
+
2367
+ lassoResults.innerHTML = lassoState.selectedModels.length
2368
+ ? lassoState.selectedModels.map((item) => (
2369
+ '<div class="mesa-leaflet-measure-row">'
2370
+ + `<strong>${escapeHtml(item.nome)}</strong>`
2371
+ + `<span>${escapeHtml(String(item.indices.length))} índice(s)</span>`
2372
+ + '</div>'
2373
+ )).join('')
2374
+ : ''
2375
+ lassoResults.style.display = lassoState.selectedModels.length ? 'block' : 'none'
2376
+ lassoStartButton.style.display = lassoState.active ? 'none' : 'inline-flex'
2377
+ lassoStartButton.textContent = hasSelection ? 'Novo laço' : (hasAttempt ? 'Tentar novamente' : 'Criar laço')
2378
+ lassoStartButton.disabled = lassoState.downloading
2379
+ lassoStartButton.style.order = hasSelection ? '2' : '1'
2380
+ lassoCancelButton.style.display = lassoState.active ? 'inline-flex' : 'none'
2381
+ lassoCancelButton.textContent = 'Cancelar'
2382
+ lassoCancelButton.disabled = lassoState.downloading
2383
+ lassoCancelButton.style.order = '1'
2384
+ lassoDownloadButton.style.display = hasSelection && !lassoState.active ? 'inline-flex' : 'none'
2385
+ lassoDownloadButton.disabled = lassoState.downloading
2386
+ lassoDownloadButton.textContent = lassoState.downloading ? 'Baixando...' : 'Baixar XLSX'
2387
+ lassoDownloadButton.style.order = '1'
2388
+ lassoCloseButton.style.display = !lassoState.active ? 'inline-flex' : 'none'
2389
+ lassoCloseButton.disabled = lassoState.downloading
2390
+ lassoCloseButton.style.order = '3'
2391
+ }
2392
+
2393
+ function resetLassoState({ closePanel = false } = {}) {
2394
+ lassoState.active = false
2395
+ lassoState.drawing = false
2396
+ lassoState.points = []
2397
+ lassoState.selectedModels = []
2398
+ lassoState.error = ''
2399
+ lassoLayerGroup.clearLayers()
2400
+ restoreMapInteractions?.()
2401
+ if (closePanel) {
2402
+ lassoState.panelOpen = false
2403
+ }
2404
+ renderLassoPanel()
2405
+ }
2406
+
2407
+ function startLasso() {
2408
+ lassoState.panelOpen = true
2409
+ lassoState.active = true
2410
+ lassoState.drawing = false
2411
+ lassoState.points = []
2412
+ lassoState.selectedModels = []
2413
+ lassoState.error = ''
2414
+ lassoLayerGroup.clearLayers()
2415
+ disableInteractionsForLasso()
2416
+ renderLassoPanel()
2417
+ }
2418
+
2419
+ function finishLasso() {
2420
+ if (!lassoState.active) return
2421
+ lassoState.active = false
2422
+ lassoState.drawing = false
2423
+ restoreMapInteractions?.()
2424
+ if (lassoState.points.length < 3) {
2425
+ lassoState.error = 'Desenhe uma área maior para selecionar.'
2426
+ lassoState.selectedModels = []
2427
+ renderLassoGeometry()
2428
+ renderLassoPanel()
2429
+ return
2430
+ }
2431
+ lassoState.selectedModels = getSelectedMarketModels(lassoState.points)
2432
+ if (!lassoState.selectedModels.length) {
2433
+ lassoState.error = 'Nenhum dado de mercado selecionado.'
2434
+ }
2435
+ renderLassoGeometry()
2436
+ renderLassoPanel()
2437
+ }
2438
+
2439
+ function onLassoMouseDown(event) {
2440
+ if (!lassoState.active || !event?.latlng) return
2441
+ L.DomEvent.stop(event)
2442
+ lassoState.drawing = true
2443
+ lassoState.points = [event.latlng]
2444
+ lassoState.selectedModels = []
2445
+ lassoState.error = ''
2446
+ renderLassoGeometry()
2447
+ renderLassoPanel()
2448
+ }
2449
+
2450
+ function onLassoMouseMove(event) {
2451
+ if (!lassoState.active || !lassoState.drawing || !event?.latlng) return
2452
+ const lastPoint = lassoState.points[lassoState.points.length - 1]
2453
+ if (lastPoint && map.distance(lastPoint, event.latlng) < 2) return
2454
+ lassoState.points = [...lassoState.points, event.latlng]
2455
+ renderLassoGeometry()
2456
+ }
2457
+
2458
+ function onLassoMouseUp(event) {
2459
+ if (!lassoState.active || !lassoState.drawing) return
2460
+ L.DomEvent.stop(event)
2461
+ finishLasso()
2462
+ }
2463
+
2464
+ async function downloadLassoSelection() {
2465
+ if (!lassoState.selectedModels.length || lassoState.downloading) return
2466
+ lassoState.downloading = true
2467
+ lassoState.error = ''
2468
+ renderLassoPanel()
2469
+ try {
2470
+ const response = await fetch(apiUrl('/api/pesquisa/mapa-indices-selecionados/export'), {
2471
+ method: 'POST',
2472
+ headers: {
2473
+ 'Content-Type': 'application/json',
2474
+ ...(getAuthToken() ? { 'X-Auth-Token': getAuthToken() } : {}),
2475
+ },
2476
+ body: JSON.stringify({ modelos: lassoState.selectedModels }),
2477
+ })
2478
+ if (!response.ok) {
2479
+ const payloadResp = await readJsonSafely(response)
2480
+ throw new Error(String(payloadResp?.detail || 'Falha ao exportar seleção.'))
2481
+ }
2482
+ const blob = await response.blob()
2483
+ downloadBlob(blob, 'indices_mapa_pesquisa.xlsx')
2484
+ } catch (error) {
2485
+ lassoState.error = error?.message || 'Falha ao exportar seleção.'
2486
+ } finally {
2487
+ lassoState.downloading = false
2488
+ renderLassoPanel()
2489
+ }
2490
+ }
2491
+
2492
+ map.on('mousedown', onLassoMouseDown)
2493
+ map.on('mousemove', onLassoMouseMove)
2494
+ map.on('mouseup', onLassoMouseUp)
2495
+ cleanupLasso = () => {
2496
+ map.off('mousedown', onLassoMouseDown)
2497
+ map.off('mousemove', onLassoMouseMove)
2498
+ map.off('mouseup', onLassoMouseUp)
2499
+ lassoLayerGroup.clearLayers()
2500
+ }
2501
+
2502
+ const lassoControl = L.control({ position: 'topright' })
2503
+ lassoControl.onAdd = () => {
2504
+ lassoRoot = L.DomUtil.create('div', 'leaflet-control-measure mesa-leaflet-lasso leaflet-bar leaflet-control')
2505
+ lassoRoot.setAttribute('aria-haspopup', 'true')
2506
+
2507
+ const toggle = L.DomUtil.create('a', 'leaflet-control-measure-toggle mesa-leaflet-lasso-toggle', lassoRoot)
2508
+ toggle.href = '#'
2509
+ toggle.title = 'Selecionar dados por laço'
2510
+ toggle.setAttribute('aria-label', 'Selecionar dados por laço')
2511
+ toggle.innerHTML = '<span class="mesa-sr-only">Selecionar dados por laço</span>'
2512
+
2513
+ lassoInteraction = L.DomUtil.create('div', 'leaflet-control-measure-interaction mesa-leaflet-measure-interaction', lassoRoot)
2514
+ lassoInteraction.style.display = 'none'
2515
+
2516
+ const title = L.DomUtil.create('h3', 'mesa-leaflet-measure-title', lassoInteraction)
2517
+ title.textContent = 'Selecionar por laço'
2518
+
2519
+ lassoHint = L.DomUtil.create('p', 'mesa-leaflet-measure-hint', lassoInteraction)
2520
+ lassoResults = L.DomUtil.create('div', 'mesa-leaflet-measure-results', lassoInteraction)
2521
+
2522
+ const actions = L.DomUtil.create('div', 'mesa-leaflet-measure-actions', lassoInteraction)
2523
+ lassoStartButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn is-primary', actions)
2524
+ lassoStartButton.type = 'button'
2525
+ lassoStartButton.textContent = 'Criar laço'
2526
+
2527
+ lassoCancelButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn', actions)
2528
+ lassoCancelButton.type = 'button'
2529
+ lassoCancelButton.textContent = 'Cancelar'
2530
+
2531
+ lassoDownloadButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn is-primary', actions)
2532
+ lassoDownloadButton.type = 'button'
2533
+ lassoDownloadButton.textContent = 'Baixar XLSX'
2534
+
2535
+ lassoCloseButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn', actions)
2536
+ lassoCloseButton.type = 'button'
2537
+ lassoCloseButton.textContent = 'Fechar'
2538
+
2539
+ L.DomEvent.disableClickPropagation(lassoRoot)
2540
+ L.DomEvent.disableScrollPropagation(lassoRoot)
2541
+ L.DomEvent.on(toggle, 'click', (event) => {
2542
+ L.DomEvent.stop(event)
2543
+ if (lassoState.active) return
2544
+ lassoState.panelOpen = !lassoState.panelOpen
2545
+ renderLassoPanel()
2546
+ })
2547
+ L.DomEvent.on(lassoStartButton, 'click', (event) => {
2548
+ L.DomEvent.stop(event)
2549
+ startLasso()
2550
+ })
2551
+ L.DomEvent.on(lassoCancelButton, 'click', (event) => {
2552
+ L.DomEvent.stop(event)
2553
+ resetLassoState()
2554
+ })
2555
+ L.DomEvent.on(lassoDownloadButton, 'click', (event) => {
2556
+ L.DomEvent.stop(event)
2557
+ void downloadLassoSelection()
2558
+ })
2559
+ L.DomEvent.on(lassoCloseButton, 'click', (event) => {
2560
+ L.DomEvent.stop(event)
2561
+ if (lassoState.active) return
2562
+ lassoState.panelOpen = false
2563
+ renderLassoPanel()
2564
+ })
2565
+
2566
+ renderLassoPanel()
2567
+ return lassoRoot
2568
+ }
2569
+ lassoControl.addTo(map)
2570
+ }
2571
+
2572
  if (payload.controls?.fullscreen) {
2573
  addFullscreenControl(map, onToggleFullscreen)
2574
  }
 
2608
  }
2609
  disposed = true
2610
  restoreMapInteractions?.()
2611
+ cleanupLasso?.()
2612
  if (popupLoaderRef.current === carregarPopupRegistro) {
2613
  popupLoaderRef.current = null
2614
  }
 
2636
  <div className="leaflet-market-group-modal-head">
2637
  <div>
2638
  <strong>{groupSelection.title}</strong>
2639
+ {!groupSelection.hideCount ? (
2640
+ <span>{Number(groupSelection.count) || groupSelection.items?.length || 0} registros sobrepostos</span>
2641
+ ) : null}
2642
  </div>
2643
  <button type="button" onClick={closeGroupSelection} aria-label="Fechar">×</button>
2644
  </div>
frontend/src/components/PesquisaTab.jsx CHANGED
@@ -1033,7 +1033,6 @@ export default function PesquisaTab({
1033
  const [mapaHtmls, setMapaHtmls] = useState({ pontos: '', cobertura: '' })
1034
  const [mapaPayloads, setMapaPayloads] = useState({ pontos: null, cobertura: null })
1035
  const [mapaModoExibicao, setMapaModoExibicao] = useState('pontos')
1036
- const [mapaAgruparPontosMercado, setMapaAgruparPontosMercado] = useState(false)
1037
  const [mapaTrabalhosTecnicosModelosModo, setMapaTrabalhosTecnicosModelosModo] = useState('selecionados_e_outras_versoes')
1038
  const [mapaTrabalhosTecnicosProximidadeModo, setMapaTrabalhosTecnicosProximidadeModo] = useState('sem_proximidade')
1039
  const [mapaTrabalhosTecnicosRaio, setMapaTrabalhosTecnicosRaio] = useState(1000)
@@ -1163,7 +1162,6 @@ export default function PesquisaTab({
1163
  setMapaStatus('')
1164
  setMapaError('')
1165
  setMapaModoExibicao('pontos')
1166
- setMapaAgruparPontosMercado(false)
1167
  mapaTrabalhosTecnicosConfigRef.current = ''
1168
  }
1169
 
@@ -1194,7 +1192,6 @@ export default function PesquisaTab({
1194
  async function carregarMapaPesquisa(ids, overrides = {}) {
1195
  const idsValidos = (ids || []).map((item) => String(item || '').trim()).filter(Boolean)
1196
  const modoExibicaoSolicitado = String(overrides.modoExibicao || mapaModoExibicao || 'pontos')
1197
- const agruparPontosMercadoSolicitado = Boolean(overrides.agruparPontosMercado ?? mapaAgruparPontosMercado)
1198
  const trabalhosTecnicosConfig = getMapaTrabalhosTecnicosRequestConfig(overrides)
1199
 
1200
  if (!idsValidos.length) {
@@ -1217,7 +1214,7 @@ export default function PesquisaTab({
1217
  trabalhosTecnicosConfig.modelosModo,
1218
  trabalhosTecnicosConfig.proximidadeModo,
1219
  trabalhosTecnicosConfig.raio,
1220
- agruparPontosMercadoSolicitado,
1221
  )
1222
  const mapaHtmlSolicitado = String(
1223
  response.mapa_html
@@ -1785,18 +1782,6 @@ export default function PesquisaTab({
1785
  void carregarMapaPesquisa(selectedIds, { modoExibicao: nextModo })
1786
  }
1787
 
1788
- function onMapaAgrupamentoPontosChange(event) {
1789
- const nextAgrupar = String(event?.target?.value || 'individual') === 'agrupado'
1790
- setMapaAgruparPontosMercado(nextAgrupar)
1791
- setMapaHtmls((prev) => ({ ...prev, pontos: '' }))
1792
- setMapaPayloads((prev) => ({ ...prev, pontos: null }))
1793
- if (!mapaFoiGerado || mapaLoading || !selectedIds.length || mapaModoExibicao !== 'pontos') return
1794
- void carregarMapaPesquisa(selectedIds, {
1795
- modoExibicao: 'pontos',
1796
- agruparPontosMercado: nextAgrupar,
1797
- })
1798
- }
1799
-
1800
  async function onAdminConfigSalva() {
1801
  if (pesquisaInicializada) {
1802
  await buscarModelos(filters, avaliandosGeolocalizados)
@@ -2576,21 +2561,6 @@ export default function PesquisaTab({
2576
  </select>
2577
  </label>
2578
 
2579
- {mapaModoExibicao === 'pontos' ? (
2580
- <label className="pesquisa-field pesquisa-mapa-agrupamento-field">
2581
- Pontos sobrepostos
2582
- <select
2583
- {...buildSelectAutofillProps('mapaAgrupamentoPontos')}
2584
- value={mapaAgruparPontosMercado ? 'agrupado' : 'individual'}
2585
- onChange={onMapaAgrupamentoPontosChange}
2586
- disabled={mapaLoading || !selectedIds.length}
2587
- >
2588
- <option value="individual">Mostrar individualmente</option>
2589
- <option value="agrupado">Agrupar por coordenada</option>
2590
- </select>
2591
- </label>
2592
- ) : null}
2593
-
2594
  <label className="pesquisa-field pesquisa-mapa-trabalhos-field">
2595
  Exibição dos trabalhos técnicos
2596
  <select
@@ -2649,7 +2619,12 @@ export default function PesquisaTab({
2649
  </SectionBlock>
2650
  </div>
2651
 
2652
- <LoadingOverlay show={searchLoading} label="Pesquisando modelos..." />
 
 
 
 
 
2653
  <LoadingOverlay show={modeloAbertoLoading} label="Carregando modelo..." />
2654
  </div>
2655
  )
 
1033
  const [mapaHtmls, setMapaHtmls] = useState({ pontos: '', cobertura: '' })
1034
  const [mapaPayloads, setMapaPayloads] = useState({ pontos: null, cobertura: null })
1035
  const [mapaModoExibicao, setMapaModoExibicao] = useState('pontos')
 
1036
  const [mapaTrabalhosTecnicosModelosModo, setMapaTrabalhosTecnicosModelosModo] = useState('selecionados_e_outras_versoes')
1037
  const [mapaTrabalhosTecnicosProximidadeModo, setMapaTrabalhosTecnicosProximidadeModo] = useState('sem_proximidade')
1038
  const [mapaTrabalhosTecnicosRaio, setMapaTrabalhosTecnicosRaio] = useState(1000)
 
1162
  setMapaStatus('')
1163
  setMapaError('')
1164
  setMapaModoExibicao('pontos')
 
1165
  mapaTrabalhosTecnicosConfigRef.current = ''
1166
  }
1167
 
 
1192
  async function carregarMapaPesquisa(ids, overrides = {}) {
1193
  const idsValidos = (ids || []).map((item) => String(item || '').trim()).filter(Boolean)
1194
  const modoExibicaoSolicitado = String(overrides.modoExibicao || mapaModoExibicao || 'pontos')
 
1195
  const trabalhosTecnicosConfig = getMapaTrabalhosTecnicosRequestConfig(overrides)
1196
 
1197
  if (!idsValidos.length) {
 
1214
  trabalhosTecnicosConfig.modelosModo,
1215
  trabalhosTecnicosConfig.proximidadeModo,
1216
  trabalhosTecnicosConfig.raio,
1217
+ true,
1218
  )
1219
  const mapaHtmlSolicitado = String(
1220
  response.mapa_html
 
1782
  void carregarMapaPesquisa(selectedIds, { modoExibicao: nextModo })
1783
  }
1784
 
 
 
 
 
 
 
 
 
 
 
 
 
1785
  async function onAdminConfigSalva() {
1786
  if (pesquisaInicializada) {
1787
  await buscarModelos(filters, avaliandosGeolocalizados)
 
2561
  </select>
2562
  </label>
2563
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2564
  <label className="pesquisa-field pesquisa-mapa-trabalhos-field">
2565
  Exibição dos trabalhos técnicos
2566
  <select
 
2619
  </SectionBlock>
2620
  </div>
2621
 
2622
+ <LoadingOverlay
2623
+ show={searchLoading || localizacaoLoading}
2624
+ label={localizacaoLoading
2625
+ ? (localizacaoModo === 'endereco' ? 'Buscando endereço...' : 'Buscando localização...')
2626
+ : 'Pesquisando modelos...'}
2627
+ />
2628
  <LoadingOverlay show={modeloAbertoLoading} label="Carregando modelo..." />
2629
  </div>
2630
  )
frontend/src/components/RepositorioTab.jsx CHANGED
@@ -7,6 +7,7 @@ import {
7
  normalizeMapPointRadiusRange,
8
  } from '../mapPointSizing'
9
  import DataTable from './DataTable'
 
10
  import EquationFormatsPanel from './EquationFormatsPanel'
11
  import ListPagination from './ListPagination'
12
  import LoadingOverlay from './LoadingOverlay'
@@ -211,6 +212,7 @@ export default function RepositorioTab({
211
  const [modeloAbertoPlotCorr, setModeloAbertoPlotCorr] = useState(null)
212
  const [modeloAbertoLoadedTabs, setModeloAbertoLoadedTabs] = useState({})
213
  const [modeloAbertoLoadingTabs, setModeloAbertoLoadingTabs] = useState({})
 
214
  const lastOpenRequestKeyRef = useRef('')
215
  const pendingTabRequestsRef = useRef({})
216
  const modeloOpenVersionRef = useRef(0)
@@ -581,6 +583,7 @@ export default function RepositorioTab({
581
  setModeloAbertoError('')
582
  setModeloAbertoActiveTab('mapa')
583
  setModeloAbertoLoading(false)
 
584
  limparConteudoModeloAberto()
585
  if (typeof onRouteChange === 'function') {
586
  onRouteChange(nextIntent)
@@ -684,6 +687,14 @@ export default function RepositorioTab({
684
  </div>
685
  <div className="pesquisa-opened-model-actions">
686
  <ShareLinkButton href={shareHref} />
 
 
 
 
 
 
 
 
687
  {typeof onUsarModeloEmAvaliacao === 'function' ? (
688
  <button
689
  type="button"
@@ -879,6 +890,11 @@ export default function RepositorioTab({
879
  {modeloAbertoError ? <div className="error-line inline-error">{modeloAbertoError}</div> : null}
880
  </div>
881
  <LoadingOverlay show={modeloAbertoLoading || activeTabLoading} label={modeloLoadingLabel} />
 
 
 
 
 
882
  </div>
883
  )
884
  }
 
7
  normalizeMapPointRadiusRange,
8
  } from '../mapPointSizing'
9
  import DataTable from './DataTable'
10
+ import AnexosModal from './AnexosModal'
11
  import EquationFormatsPanel from './EquationFormatsPanel'
12
  import ListPagination from './ListPagination'
13
  import LoadingOverlay from './LoadingOverlay'
 
212
  const [modeloAbertoPlotCorr, setModeloAbertoPlotCorr] = useState(null)
213
  const [modeloAbertoLoadedTabs, setModeloAbertoLoadedTabs] = useState({})
214
  const [modeloAbertoLoadingTabs, setModeloAbertoLoadingTabs] = useState({})
215
+ const [anexosModalOpen, setAnexosModalOpen] = useState(false)
216
  const lastOpenRequestKeyRef = useRef('')
217
  const pendingTabRequestsRef = useRef({})
218
  const modeloOpenVersionRef = useRef(0)
 
583
  setModeloAbertoError('')
584
  setModeloAbertoActiveTab('mapa')
585
  setModeloAbertoLoading(false)
586
+ setAnexosModalOpen(false)
587
  limparConteudoModeloAberto()
588
  if (typeof onRouteChange === 'function') {
589
  onRouteChange(nextIntent)
 
687
  </div>
688
  <div className="pesquisa-opened-model-actions">
689
  <ShareLinkButton href={shareHref} />
690
+ <button
691
+ type="button"
692
+ className="pesquisa-opened-model-action-btn pesquisa-opened-model-action-btn-primary"
693
+ onClick={() => setAnexosModalOpen(true)}
694
+ disabled={modeloAbertoLoading}
695
+ >
696
+ Gerar anexos
697
+ </button>
698
  {typeof onUsarModeloEmAvaliacao === 'function' ? (
699
  <button
700
  type="button"
 
890
  {modeloAbertoError ? <div className="error-line inline-error">{modeloAbertoError}</div> : null}
891
  </div>
892
  <LoadingOverlay show={modeloAbertoLoading || activeTabLoading} label={modeloLoadingLabel} />
893
+ <AnexosModal
894
+ open={anexosModalOpen}
895
+ defaultModeloId={modeloAbertoMeta?.id || ''}
896
+ onClose={() => setAnexosModalOpen(false)}
897
+ />
898
  </div>
899
  )
900
  }
frontend/src/components/TrabalhosTecnicosTab.jsx CHANGED
@@ -1443,10 +1443,12 @@ export default function TrabalhosTecnicosTab({
1443
  ) : null}
1444
  </div>
1445
  <LoadingOverlay
1446
- show={trabalhoLoading || salvando}
1447
- label={salvando
 
 
1448
  ? (cadastrando ? 'Cadastrando trabalho técnico...' : 'Salvando trabalho técnico...')
1449
- : 'Abrindo trabalho técnico...'}
1450
  />
1451
  </div>
1452
  )
@@ -1855,10 +1857,12 @@ export default function TrabalhosTecnicosTab({
1855
  )}
1856
  </div>
1857
  <LoadingOverlay
1858
- show={loading || mapaLoading || trabalhoLoading}
1859
- label={trabalhoLoading
 
 
1860
  ? 'Abrindo trabalho técnico...'
1861
- : (mapaLoading ? 'Gerando mapa dos trabalhos técnicos...' : 'Carregando trabalhos técnicos...')}
1862
  />
1863
  </div>
1864
  )
 
1443
  ) : null}
1444
  </div>
1445
  <LoadingOverlay
1446
+ show={trabalhoLoading || salvando || trabalhoGeoLoading}
1447
+ label={trabalhoGeoLoading
1448
+ ? (trabalhoGeoModo === 'endereco' ? 'Buscando endereço...' : 'Buscando localização...')
1449
+ : (salvando
1450
  ? (cadastrando ? 'Cadastrando trabalho técnico...' : 'Salvando trabalho técnico...')
1451
+ : 'Abrindo trabalho técnico...')}
1452
  />
1453
  </div>
1454
  )
 
1857
  )}
1858
  </div>
1859
  <LoadingOverlay
1860
+ show={loading || mapaLoading || trabalhoLoading || localizacaoLoading}
1861
+ label={localizacaoLoading
1862
+ ? (localizacaoModo === 'endereco' ? 'Buscando endereço...' : 'Buscando localização...')
1863
+ : (trabalhoLoading
1864
  ? 'Abrindo trabalho técnico...'
1865
+ : (mapaLoading ? 'Gerando mapa dos trabalhos técnicos...' : 'Carregando trabalhos técnicos...'))}
1866
  />
1867
  </div>
1868
  )
frontend/src/styles.css CHANGED
@@ -3754,6 +3754,175 @@ button.pesquisa-coluna-remove:hover {
3754
  margin-top: 12px;
3755
  }
3756
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3757
  .pesquisa-card-values-modal {
3758
  width: min(920px, calc(100vw - 40px));
3759
  max-width: min(920px, calc(100vw - 40px));
@@ -4090,9 +4259,24 @@ button.pesquisa-coluna-remove:hover {
4090
  .avaliacao-modelos-card-actions {
4091
  display: inline-flex;
4092
  align-items: center;
 
 
 
4093
  flex: 0 0 auto;
4094
  }
4095
 
 
 
 
 
 
 
 
 
 
 
 
 
4096
  .avaliacao-card-delete-confirm {
4097
  display: inline-flex;
4098
  align-items: center;
@@ -5013,38 +5197,6 @@ button.paste-excel-clear-btn {
5013
  gap: 8px;
5014
  }
5015
 
5016
- .import-preview-first-columns-field {
5017
- display: inline-flex;
5018
- align-items: center;
5019
- gap: 7px;
5020
- color: #33485d;
5021
- font-size: 0.86rem;
5022
- font-weight: 700;
5023
- }
5024
-
5025
- .import-preview-first-columns-toggle {
5026
- display: inline-flex;
5027
- align-items: center;
5028
- gap: 7px;
5029
- min-height: 34px;
5030
- border: 1px solid #c8d8e8;
5031
- border-radius: 8px;
5032
- background: #f6f9fc;
5033
- color: #33485d;
5034
- font-size: 0.86rem;
5035
- font-weight: 800;
5036
- padding: 6px 9px;
5037
- }
5038
-
5039
- .import-preview-first-columns-toggle input {
5040
- width: auto;
5041
- margin: 0;
5042
- }
5043
-
5044
- .import-preview-first-columns-field input {
5045
- width: 78px;
5046
- }
5047
-
5048
  button.import-preview-apply-btn {
5049
  --btn-bg-start: #ff8c00;
5050
  --btn-bg-end: #e67900;
@@ -5952,10 +6104,6 @@ button.import-preview-clear-btn {
5952
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2324405b' d='M12 2 3 7l9 5 9-5-9-5Zm-7.5 8.6L12 15l7.5-4.4L21 12l-9 5-9-5 1.5-1.4Zm0 4.8L12 19.8l7.5-4.4L21 17l-9 5-9-5 1.5-1.6Z'/%3E%3C/svg%3E");
5953
  }
5954
 
5955
- .leaflet-map-host .leaflet-control-layers-toggle:hover {
5956
- background-color: #eef5fb;
5957
- }
5958
-
5959
  .leaflet-map-host .leaflet-control-layers-expanded {
5960
  padding: 6px 9px;
5961
  min-width: 220px;
@@ -5988,17 +6136,10 @@ button.import-preview-clear-btn {
5988
  font-weight: 500;
5989
  color: #27445f;
5990
  cursor: pointer;
5991
- transition: background-color 0.18s ease, color 0.18s ease;
5992
  line-height: 1.12;
5993
  white-space: normal;
5994
  }
5995
 
5996
- .leaflet-map-host .leaflet-control-layers label:hover {
5997
- background: #f3f7fb;
5998
- transform: none;
5999
- color: #1f5f9f;
6000
- }
6001
-
6002
  .leaflet-map-host .leaflet-control-layers input {
6003
  accent-color: #1f6fb2;
6004
  width: 16px;
@@ -6033,6 +6174,29 @@ button.import-preview-clear-btn {
6033
  border-top-color: rgba(207, 217, 227, 0.95);
6034
  }
6035
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6036
  .leaflet-map-host .mesa-leaflet-fullscreen,
6037
  .leaflet-map-host .leaflet-control-measure {
6038
  border: none;
@@ -6057,7 +6221,8 @@ button.import-preview-clear-btn {
6057
  color: #24405b;
6058
  }
6059
 
6060
- .leaflet-map-host .mesa-leaflet-measure {
 
6061
  background: transparent;
6062
  border: none;
6063
  box-shadow: none;
@@ -6101,7 +6266,8 @@ button.import-preview-clear-btn {
6101
  background: #eef5fb;
6102
  }
6103
 
6104
- .leaflet-map-host .mesa-leaflet-measure .mesa-leaflet-measure-toggle {
 
6105
  width: 38px;
6106
  height: 38px;
6107
  position: relative;
@@ -6112,6 +6278,11 @@ button.import-preview-clear-btn {
6112
  box-shadow: 0 10px 24px rgba(27, 48, 72, 0.16);
6113
  }
6114
 
 
 
 
 
 
6115
  .leaflet-map-host .mesa-leaflet-measure .mesa-leaflet-measure-toggle::before {
6116
  content: '';
6117
  position: absolute;
@@ -6125,7 +6296,21 @@ button.import-preview-clear-btn {
6125
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2324405b' d='M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25Zm2.92 2.33H5v-.92l8.12-8.12.92.92-8.12 8.12ZM20.71 7.04a1.003 1.003 0 0 0 0-1.42l-2.34-2.33a1.003 1.003 0 0 0-1.42 0l-1.83 1.83 3.75 3.75 1.84-1.83Z'/%3E%3C/svg%3E");
6126
  }
6127
 
6128
- .leaflet-map-host .mesa-leaflet-measure .mesa-leaflet-measure-toggle:hover {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6129
  background: #eef5fb;
6130
  }
6131
 
@@ -6202,6 +6387,15 @@ button.import-preview-clear-btn {
6202
  background: #f3f7fb;
6203
  }
6204
 
 
 
 
 
 
 
 
 
 
6205
  .leaflet-map-host .mesa-leaflet-measure-btn.is-primary {
6206
  background: #1f6fb2;
6207
  border-color: #1f6fb2;
@@ -6212,6 +6406,10 @@ button.import-preview-clear-btn {
6212
  background: #175a90;
6213
  }
6214
 
 
 
 
 
6215
  .table-wrapper {
6216
  overflow: auto;
6217
  border: 1px solid #d8e2ec;
@@ -8556,6 +8754,21 @@ button.btn-download-subtle {
8556
  flex-direction: column;
8557
  }
8558
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8559
  .avaliacao-localizacao-map-modal-actions {
8560
  width: 100%;
8561
  justify-content: flex-end;
 
3754
  margin-top: 12px;
3755
  }
3756
 
3757
+ .anexos-modal {
3758
+ width: min(760px, 100%);
3759
+ }
3760
+
3761
+ .anexos-modal-body {
3762
+ gap: 14px;
3763
+ }
3764
+
3765
+ .anexos-modal-model-field {
3766
+ display: grid;
3767
+ gap: 6px;
3768
+ color: #334b61;
3769
+ font-size: 0.84rem;
3770
+ font-weight: 700;
3771
+ }
3772
+
3773
+ .anexos-modal-model-field select {
3774
+ width: 100%;
3775
+ min-width: 0;
3776
+ }
3777
+
3778
+ .anexos-columns-section {
3779
+ display: grid;
3780
+ gap: 8px;
3781
+ padding-top: 12px;
3782
+ border-top: 1px solid #dbe5ef;
3783
+ }
3784
+
3785
+ .anexos-columns-head {
3786
+ display: flex;
3787
+ align-items: center;
3788
+ justify-content: space-between;
3789
+ gap: 10px;
3790
+ }
3791
+
3792
+ .anexos-columns-head > div:first-child {
3793
+ display: flex;
3794
+ align-items: baseline;
3795
+ gap: 8px;
3796
+ min-width: 0;
3797
+ }
3798
+
3799
+ .anexos-columns-head strong {
3800
+ color: #334b61;
3801
+ font-size: 0.84rem;
3802
+ }
3803
+
3804
+ .anexos-columns-head span {
3805
+ color: #687d91;
3806
+ font-size: 0.75rem;
3807
+ }
3808
+
3809
+ .anexos-columns-actions,
3810
+ .anexos-reorder-actions {
3811
+ display: inline-flex;
3812
+ align-items: center;
3813
+ gap: 6px;
3814
+ }
3815
+
3816
+ .anexos-columns-actions button,
3817
+ .anexos-reorder-actions button {
3818
+ min-height: 28px;
3819
+ padding: 3px 8px;
3820
+ font-size: 0.75rem;
3821
+ }
3822
+
3823
+ .anexos-reorder-actions button {
3824
+ width: 30px;
3825
+ min-width: 30px;
3826
+ padding: 0;
3827
+ font-size: 0.95rem;
3828
+ }
3829
+
3830
+ .anexos-columns-list {
3831
+ display: grid;
3832
+ max-height: 310px;
3833
+ overflow: auto;
3834
+ border: 1px solid #d5e0ea;
3835
+ border-radius: 8px;
3836
+ background: #f7f9fb;
3837
+ }
3838
+
3839
+ .anexos-column-row {
3840
+ display: grid;
3841
+ grid-template-columns: minmax(0, 1fr) auto;
3842
+ align-items: center;
3843
+ gap: 8px;
3844
+ min-height: 40px;
3845
+ padding: 4px 8px 4px 10px;
3846
+ border-bottom: 1px solid #e2e9f0;
3847
+ background: #fff;
3848
+ }
3849
+
3850
+ .anexos-column-row:last-child {
3851
+ border-bottom: 0;
3852
+ }
3853
+
3854
+ .anexos-column-row > label {
3855
+ display: grid;
3856
+ grid-template-columns: auto minmax(0, 1fr);
3857
+ align-items: center;
3858
+ gap: 9px;
3859
+ min-width: 0;
3860
+ color: #30485e;
3861
+ font-size: 0.82rem;
3862
+ font-weight: 600;
3863
+ }
3864
+
3865
+ .anexos-column-row input {
3866
+ width: 16px;
3867
+ height: 16px;
3868
+ margin: 0;
3869
+ }
3870
+
3871
+ .anexos-column-row span {
3872
+ min-width: 0;
3873
+ overflow-wrap: anywhere;
3874
+ }
3875
+
3876
+ .anexos-warning-panel,
3877
+ .anexos-result-panel {
3878
+ border-radius: 8px;
3879
+ padding: 10px 12px;
3880
+ font-size: 0.81rem;
3881
+ }
3882
+
3883
+ .anexos-warning-panel {
3884
+ border: 1px solid #e4bd55;
3885
+ background: #fff8df;
3886
+ color: #654b0c;
3887
+ }
3888
+
3889
+ .anexos-warning-panel ul {
3890
+ margin: 6px 0 0;
3891
+ padding-left: 18px;
3892
+ }
3893
+
3894
+ .anexos-result-panel {
3895
+ display: grid;
3896
+ grid-template-columns: minmax(0, 1fr) auto;
3897
+ align-items: center;
3898
+ gap: 10px;
3899
+ border: 1px solid #aed3b8;
3900
+ background: #f2faf4;
3901
+ color: #235b32;
3902
+ }
3903
+
3904
+ .anexos-result-panel > div {
3905
+ display: grid;
3906
+ gap: 3px;
3907
+ min-width: 0;
3908
+ }
3909
+
3910
+ .anexos-result-panel span {
3911
+ color: #486b51;
3912
+ overflow-wrap: anywhere;
3913
+ }
3914
+
3915
+ .anexos-modal-footer {
3916
+ display: flex;
3917
+ justify-content: flex-end;
3918
+ padding-top: 12px;
3919
+ border-top: 1px solid #dbe5ef;
3920
+ }
3921
+
3922
+ .anexos-modal-footer button {
3923
+ min-width: 150px;
3924
+ }
3925
+
3926
  .pesquisa-card-values-modal {
3927
  width: min(920px, calc(100vw - 40px));
3928
  max-width: min(920px, calc(100vw - 40px));
 
4259
  .avaliacao-modelos-card-actions {
4260
  display: inline-flex;
4261
  align-items: center;
4262
+ justify-content: flex-end;
4263
+ gap: 6px;
4264
+ flex-wrap: wrap;
4265
  flex: 0 0 auto;
4266
  }
4267
 
4268
+ .avaliacao-modelos-anexo-btn {
4269
+ min-height: 28px;
4270
+ padding: 3px 8px;
4271
+ font-size: 0.74rem;
4272
+ --btn-bg-start: #eaf3fb;
4273
+ --btn-bg-end: #dceaf7;
4274
+ --btn-border: #afc9df;
4275
+ --btn-shadow-soft: rgba(47, 121, 184, 0.1);
4276
+ --btn-shadow-strong: rgba(47, 121, 184, 0.16);
4277
+ color: #285b85;
4278
+ }
4279
+
4280
  .avaliacao-card-delete-confirm {
4281
  display: inline-flex;
4282
  align-items: center;
 
5197
  gap: 8px;
5198
  }
5199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5200
  button.import-preview-apply-btn {
5201
  --btn-bg-start: #ff8c00;
5202
  --btn-bg-end: #e67900;
 
6104
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2324405b' d='M12 2 3 7l9 5 9-5-9-5Zm-7.5 8.6L12 15l7.5-4.4L21 12l-9 5-9-5 1.5-1.4Zm0 4.8L12 19.8l7.5-4.4L21 17l-9 5-9-5 1.5-1.6Z'/%3E%3C/svg%3E");
6105
  }
6106
 
 
 
 
 
6107
  .leaflet-map-host .leaflet-control-layers-expanded {
6108
  padding: 6px 9px;
6109
  min-width: 220px;
 
6136
  font-weight: 500;
6137
  color: #27445f;
6138
  cursor: pointer;
 
6139
  line-height: 1.12;
6140
  white-space: normal;
6141
  }
6142
 
 
 
 
 
 
 
6143
  .leaflet-map-host .leaflet-control-layers input {
6144
  accent-color: #1f6fb2;
6145
  width: 16px;
 
6174
  border-top-color: rgba(207, 217, 227, 0.95);
6175
  }
6176
 
6177
+ .leaflet-map-host .mesa-layer-control-close {
6178
+ display: none;
6179
+ width: 100%;
6180
+ margin: 7px 0 0;
6181
+ padding: 7px 10px;
6182
+ appearance: none;
6183
+ border: 1px solid #c8d6e4;
6184
+ border-radius: 8px;
6185
+ background: #fff;
6186
+ color: #24405b;
6187
+ font: inherit;
6188
+ font-size: 0.73rem;
6189
+ font-weight: 600;
6190
+ line-height: 1;
6191
+ cursor: pointer;
6192
+ }
6193
+
6194
+ .leaflet-map-host .leaflet-control-layers-expanded .mesa-layer-control-close {
6195
+ display: inline-flex;
6196
+ align-items: center;
6197
+ justify-content: center;
6198
+ }
6199
+
6200
  .leaflet-map-host .mesa-leaflet-fullscreen,
6201
  .leaflet-map-host .leaflet-control-measure {
6202
  border: none;
 
6221
  color: #24405b;
6222
  }
6223
 
6224
+ .leaflet-map-host .mesa-leaflet-measure,
6225
+ .leaflet-map-host .mesa-leaflet-lasso {
6226
  background: transparent;
6227
  border: none;
6228
  box-shadow: none;
 
6266
  background: #eef5fb;
6267
  }
6268
 
6269
+ .leaflet-map-host .mesa-leaflet-measure .mesa-leaflet-measure-toggle,
6270
+ .leaflet-map-host .mesa-leaflet-lasso .mesa-leaflet-lasso-toggle {
6271
  width: 38px;
6272
  height: 38px;
6273
  position: relative;
 
6278
  box-shadow: 0 10px 24px rgba(27, 48, 72, 0.16);
6279
  }
6280
 
6281
+ .leaflet-map-host .mesa-leaflet-lasso.is-active .mesa-leaflet-lasso-toggle {
6282
+ background: #e6f6f4;
6283
+ border-color: #91cfc6;
6284
+ }
6285
+
6286
  .leaflet-map-host .mesa-leaflet-measure .mesa-leaflet-measure-toggle::before {
6287
  content: '';
6288
  position: absolute;
 
6296
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2324405b' d='M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25Zm2.92 2.33H5v-.92l8.12-8.12.92.92-8.12 8.12ZM20.71 7.04a1.003 1.003 0 0 0 0-1.42l-2.34-2.33a1.003 1.003 0 0 0-1.42 0l-1.83 1.83 3.75 3.75 1.84-1.83Z'/%3E%3C/svg%3E");
6297
  }
6298
 
6299
+ .leaflet-map-host .mesa-leaflet-lasso .mesa-leaflet-lasso-toggle::before {
6300
+ content: '';
6301
+ position: absolute;
6302
+ inset: 0;
6303
+ margin: auto;
6304
+ width: 20px;
6305
+ height: 20px;
6306
+ background-repeat: no-repeat;
6307
+ background-position: center;
6308
+ background-size: 20px 20px;
6309
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%2324405b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M5 9c0-3 3.1-5 7-5s7 2 7 5-3.1 5-7 5-7-2-7-5Zm10.8 4.2 4.2 4.8m0 0-3.2-.5m3.2.5-.6-3.3M8 18c2.5 1.4 5.5 1.4 8 0'/%3E%3C/svg%3E");
6310
+ }
6311
+
6312
+ .leaflet-map-host .mesa-leaflet-measure .mesa-leaflet-measure-toggle:hover,
6313
+ .leaflet-map-host .mesa-leaflet-lasso .mesa-leaflet-lasso-toggle:hover {
6314
  background: #eef5fb;
6315
  }
6316
 
 
6387
  background: #f3f7fb;
6388
  }
6389
 
6390
+ .leaflet-map-host .mesa-leaflet-measure-btn:disabled {
6391
+ opacity: 0.62;
6392
+ cursor: wait;
6393
+ }
6394
+
6395
+ .leaflet-map-host .mesa-leaflet-measure-btn:disabled:hover {
6396
+ background: #fff;
6397
+ }
6398
+
6399
  .leaflet-map-host .mesa-leaflet-measure-btn.is-primary {
6400
  background: #1f6fb2;
6401
  border-color: #1f6fb2;
 
6406
  background: #175a90;
6407
  }
6408
 
6409
+ .leaflet-map-host .mesa-leaflet-measure-btn.is-primary:disabled:hover {
6410
+ background: #1f6fb2;
6411
+ }
6412
+
6413
  .table-wrapper {
6414
  overflow: auto;
6415
  border: 1px solid #d8e2ec;
 
8754
  flex-direction: column;
8755
  }
8756
 
8757
+ .anexos-columns-head,
8758
+ .anexos-result-panel {
8759
+ grid-template-columns: 1fr;
8760
+ }
8761
+
8762
+ .anexos-columns-head {
8763
+ align-items: flex-start;
8764
+ flex-direction: column;
8765
+ }
8766
+
8767
+ .anexos-result-panel button,
8768
+ .anexos-modal-footer button {
8769
+ width: 100%;
8770
+ }
8771
+
8772
  .avaliacao-localizacao-map-modal-actions {
8773
  width: 100%;
8774
  justify-content: flex-end;