gui-sparim commited on
Commit
8d6c767
·
verified ·
1 Parent(s): 1336063

Upload 44 files

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ models/files/ABC/modelo.dai filter=lfs diff=lfs merge=lfs -text
CLAUDE.md ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ Sistema de Geração de Laudos de Avaliação Imobiliária - A web application for generating real estate appraisal reports (laudos) for property tax review processes in Porto Alegre, Brazil. The system uses statistical regression models and generates formatted DOCX documents.
8
+
9
+ ## Running the Application
10
+
11
+ ```bash
12
+ # Activate virtual environment
13
+ source .venv/bin/activate
14
+
15
+ # Install dependencies
16
+ pip install -r requirements.txt
17
+
18
+ # Run the application
19
+ python app.py
20
+ ```
21
+
22
+ The Gradio web interface starts at `http://0.0.0.0:7860`.
23
+
24
+ ## Architecture Overview
25
+
26
+ ```
27
+ ├── app.py # Entry point - launches Gradio server
28
+ ├── config/
29
+ │ ├── settings.py # Paths and server config
30
+ │ └── constants.py # UI dropdown options, field limits
31
+ ├── ui/
32
+ │ └── app_builder.py # Gradio interface (8 tabs), callbacks, document generation
33
+ ├── document/
34
+ │ ├── generator.py # LaudoGenerator class - main orchestrator
35
+ │ ├── numbering.py # Section numbering (NumeradorSecoes)
36
+ │ ├── formatters/ # DOCX formatting utilities
37
+ │ └── anexos/ # Annex generators
38
+ ├── core/entities/ # Data classes (Laudo, Solicitacao, Imovel)
39
+ ├── models/ # Model loading and registry
40
+ ├── extractors/ # PDF data extraction
41
+ ├── templates/
42
+ │ ├── header.docx # Document header template
43
+ │ └── textos/ # Text templates (CONSIDERACOES_INICIAIS.docx, etc.)
44
+ ├── utils/
45
+ │ ├── docx_loader.py # DOCX reading and merging utilities
46
+ │ ├── formatters.py # Value formatting utilities
47
+ │ └── estatisticas_utils.py # Statistics formatting utilities
48
+ └── output/ # Generated reports
49
+ ```
50
+
51
+ ---
52
+
53
+ ## Detailed File Reference
54
+
55
+ ### config/settings.py
56
+ **Purpose:** System paths and server configuration.
57
+
58
+ | Constant | Description |
59
+ |----------|-------------|
60
+ | `BASE_DIR` | Project root directory |
61
+ | `MODELS_DIR` | Path to `models/files/` |
62
+ | `TEMPLATES_DIR` | Path to `templates/` |
63
+ | `TEXTOS_DIR` | Path to `templates/textos/` (text templates) |
64
+ | `OUTPUT_DIR` | Path to `output/laudos_gerados/` |
65
+ | `SERVER_HOST` | Gradio server host (`0.0.0.0`) |
66
+ | `SERVER_PORT` | Gradio server port (`7860`) |
67
+
68
+ ### config/constants.py
69
+ **Purpose:** UI dropdown lists and field limits.
70
+
71
+ | Constant | Description |
72
+ |----------|-------------|
73
+ | `MAX_MOTIVOS_DESVALORIZANTES` | Max devaluation reasons (15) |
74
+ | `MAX_VALORES_MERCADO` | Max market value entries (15) |
75
+ | `MAX_DOCUMENTACAO_PADRAO` | Max standard docs (10) |
76
+ | `MAX_DOCUMENTACAO_ESPECIFICA` | Max specific docs (10) |
77
+ | `UNIDADES_DEMANDANTES` | Dropdown list for requesting units |
78
+ | `TECNICOS_RESPONSAVEIS` | Dropdown list for responsible technicians |
79
+ | `METODOS_AVALIACAO` | Dropdown list for evaluation methods |
80
+
81
+ ---
82
+
83
+ ### document/formatters/table.py
84
+ **Purpose:** Table creation and cell formatting for DOCX.
85
+
86
+ | Function | Description |
87
+ |----------|-------------|
88
+ | `set_cell_shading(cell, color)` | Sets cell background color (hex) |
89
+ | `set_cell_text_rotation(cell, direcao='btLr')` | Rotates text 90° vertical (`btLr`) or 270° (`tbRl`) |
90
+ | `formatar_celula_tabela(cell, texto, negrito, cor, shading_color, rotacao)` | Formats cell with text, bold, color, shading, rotation |
91
+ | `add_simple_table(doc, dados_tabela, header_row, largura_colunas, rotacao_cabecalho)` | Creates simple table. `rotacao_cabecalho=True` rotates headers vertically and auto-adjusts height |
92
+ | `configurar_linha_tabela_altura(row, altura_cm)` | Sets minimum row height in cm |
93
+ | `criar_celula_cabecalho_tabela(cell, texto)` | Formats section header cell (bold, underline, gray) |
94
+ | `criar_celula_dados_tabela(cell, texto, is_label)` | Formats data cell |
95
+
96
+ ### document/formatters/paragraph.py
97
+ **Purpose:** Paragraph and text formatting.
98
+
99
+ | Function | Description |
100
+ |----------|-------------|
101
+ | `aplicar_cor_run(run, cor)` | Applies RGB color to a run |
102
+ | `criar_paragrafo_formatado(doc, texto, negrito, sublinhado, italico, tamanho, cor, alinhamento, ...)` | Creates fully formatted paragraph |
103
+ | `add_body_text(doc, text, cor, indent)` | Adds body text with standard formatting |
104
+ | `add_placeholder_text(doc, text, indent)` | Adds red placeholder text |
105
+ | `add_bullet_text(doc, text, indent_level)` | Adds bulleted text |
106
+
107
+ ### document/formatters/heading.py
108
+ **Purpose:** Section titles and headings.
109
+
110
+ | Function | Description |
111
+ |----------|-------------|
112
+ | `add_heading_custom(doc, text, level)` | Adds custom heading (level 1 or 2) |
113
+ | `add_section_title(doc, number, text)` | Main section title (e.g., "1. SOLICITACAO") |
114
+ | `add_subsection_title(doc, number, text)` | Subsection title (e.g., "1.1 Consideracoes") |
115
+ | `add_subsubsection_title(doc, number, text)` | Sub-subsection (e.g., "3.2.1") |
116
+ | `add_subsubsubsection_title(doc, number, text)` | Sub-sub-subsection (e.g., "3.2.1.1") |
117
+
118
+ ### document/formatters/image.py
119
+ **Purpose:** Image insertion.
120
+
121
+ | Function | Description |
122
+ |----------|-------------|
123
+ | `inserir_imagem_de_documento(doc, imagem_data, largura_inches)` | Inserts image from another document |
124
+ | `add_image_placeholder(doc)` | Adds "[IMAGEM]" placeholder |
125
+
126
+ ---
127
+
128
+ ### document/generator.py
129
+ **Purpose:** Main document generation orchestrator.
130
+
131
+ **Class: `LaudoGenerator`**
132
+
133
+ | Method | Description |
134
+ |--------|-------------|
135
+ | `gerar(dados, motivos_formatados)` | Main entry point - generates complete laudo. Merges photo registry if provided |
136
+ | `_configurar_margens(doc)` | Sets document margins |
137
+ | `_substituir_processo_header(doc, numero_processo)` | Replaces "XXX" in header with process number |
138
+ | `_gerar_cabecalho(doc, dados)` | Generates initial data table |
139
+ | `_gerar_corpo(doc, dados, motivos_formatados)` | Generates main body sections. Loads "Considerações Iniciais" from template |
140
+ | `_gerar_tabela_valores_mercado(doc, valores_lista)` | Creates market values table |
141
+ | `_gerar_assinatura(doc, dados)` | Generates signature section |
142
+ | `_criar_titulo_anexo(doc, titulo)` | Creates annex title (size 10, centered, bold, underlined) |
143
+ | `_gerar_anexos(doc, dados)` | Generates all annexes (I-VI) |
144
+ | `_gerar_nome_arquivo(dados)` | Creates output filename |
145
+
146
+ ### document/numbering.py
147
+ **Purpose:** Automatic hierarchical section numbering.
148
+
149
+ **Class: `NumeradorSecoes`**
150
+
151
+ | Method | Description |
152
+ |--------|-------------|
153
+ | `secao()` | Increments and returns section number ("1", "2", ...) |
154
+ | `subsecao()` | Returns subsection number ("1.1", "1.2", ...) |
155
+ | `subsubsecao()` | Returns sub-subsection ("1.1.1", ...) |
156
+ | `subsubsubsecao()` | Returns sub-sub-subsection ("1.1.1.1", ...) |
157
+ | `numero_atual(nivel)` | Returns current number without incrementing |
158
+ | `definir_secao(numero)` | Manually sets section number |
159
+
160
+ ---
161
+
162
+ ### document/anexos/
163
+
164
+ | File | Main Function | Description |
165
+ |------|---------------|-------------|
166
+ | `banco_dados.py` | `gerar_anexo_banco_dados(doc, modelo)` | Generates sample data table from `modelo.xy_preview`. Uses `rotacao_cabecalho=True` |
167
+ | `estatisticas.py` | `gerar_anexo_estatisticas(doc, modelo)` | Generates descriptive statistics and test results. Descriptive table uses `rotacao_cabecalho=True` |
168
+ | `planilha_calculo.py` | `gerar_anexo_planilha(doc, modelo)` | Generates coefficients table and observed vs calculated. Both tables use `rotacao_cabecalho=True` |
169
+ | `graficos.py` | `gerar_anexo_graficos(doc, modelo)` | Generates diagnostic plots (obs vs calc, residuals, QQ, histogram) |
170
+ | `metodologia.py` | `gerar_anexo_metodologia(doc, modelo)` | Generates methodology section from model data |
171
+ | `calculo_valor.py` | `gerar_anexo_calculo(doc, modelo, valores_imovel)` | Generates value calculation section |
172
+
173
+ **graficos.py helper functions:**
174
+ - `_gerar_graficos_diagnostico(modelo)` - Creates all diagnostic plots
175
+ - `_criar_grafico_obs_calc(y_obs, y_calc)` - Observed vs Calculated scatter
176
+ - `_criar_grafico_residuos(y_calc, residuos)` - Residuals vs Fitted
177
+ - `_criar_qqplot(residuos)` - Q-Q plot for normality
178
+ - `_criar_histograma_residuos(residuos)` - Histogram with normal curve
179
+
180
+ ---
181
+
182
+ ### models/
183
+
184
+ | File | Description |
185
+ |------|-------------|
186
+ | `model_data.py` | `ModelData` dataclass with properties: `r2`, `r2_ajustado`, `equacao`, `variaveis`, `n_amostras`, `n_variaveis` |
187
+ | `model_loader.py` | `load_model(path)` - Loads `.dai` files via joblib |
188
+ | `registry.py` | `ModelRegistry` class, `list_available_models()`, `get_model(name)` |
189
+
190
+ **ModelData attributes:**
191
+ - `nome`, `path` - Model identification
192
+ - `xy_preview` - DataFrame with sample data
193
+ - `estatisticas` - Descriptive statistics DataFrame
194
+ - `tabelas_coef` - Coefficients DataFrame
195
+ - `tabelas_obs_calc` - Observed vs Calculated DataFrame
196
+ - `modelos_resumos` - Dict with R2, tests, equation
197
+ - `modelos_sm` - Statsmodels result object (for graphs)
198
+
199
+ ---
200
+
201
+ ### ui/app_builder.py
202
+ **Purpose:** Gradio interface construction and callbacks.
203
+
204
+ | Function | Description |
205
+ |----------|-------------|
206
+ | `toggle_custom_field(dropdown_value)` | Shows/hides custom field when "Outros" selected |
207
+ | `adicionar_campo(count, max_count)` | Adds dynamic field |
208
+ | `remover_campo(count, max_count)` | Removes dynamic field |
209
+ | `processar_upload_pdf(pdf_file)` | Extracts data from uploaded PDF |
210
+ | `gerar_campos_valores_mercado(ano_ini, ano_fim)` | Generates market value fields for year range |
211
+ | `gerar_documento_callback(...)` | Main callback that calls `LaudoGenerator.gerar()` |
212
+
213
+ ---
214
+
215
+ ### utils/
216
+
217
+ | File | Function | Description |
218
+ |------|----------|-------------|
219
+ | `docx_loader.py` | `ler_texto_docx(caminho)` | Reads text content from a DOCX template file |
220
+ | `docx_loader.py` | `mesclar_documento_fotos(doc, caminho_fotos)` | Merges photo registry DOCX into main document using `docxcompose` |
221
+ | `estatisticas_utils.py` | `reorganizar_modelos_resumos(resumo)` | Reorganizes model stats dict for display |
222
+ | `estatisticas_utils.py` | `formatar_numero(valor, casas)` | Formats number with decimal places |
223
+ | `formatters.py` | `formatar_valor_monetario(valor)` | Formats currency value |
224
+ | `formatters.py` | `formatar_motivos_desvalorizantes(...)` | Formats devaluation reasons for document |
225
+
226
+ ---
227
+
228
+ ### extractors/pdf_extractor.py
229
+ **Purpose:** Extracts property data from SIAT PDFs.
230
+
231
+ | Function | Description |
232
+ |----------|-------------|
233
+ | `extrair_dados_pdf(pdf_file)` | Main extraction function, returns dict with property data |
234
+
235
+ ---
236
+
237
+ ## Key Patterns
238
+
239
+ ### Model Loading
240
+ ```python
241
+ from models import get_model, list_available_models
242
+ models = list_available_models() # Returns folder names
243
+ model = get_model("MOD_V_TCOND_Z4_008C") # Returns ModelData
244
+ ```
245
+
246
+ ### Document Generation Flow
247
+ 1. `ui/app_builder.py` -> `gerar_documento_callback()`
248
+ 2. Formats data and calls `LaudoGenerator.gerar(dados, motivos)`
249
+ 3. Generator creates sections and annexes
250
+ 4. If photo registry uploaded, merges it after ANEXO VI using `docxcompose`
251
+ 5. Output saved to `output/laudos_gerados/`
252
+
253
+ ### UI Tabs Structure
254
+ | Tab | Name | Purpose |
255
+ |-----|------|---------|
256
+ | 1 | Solicitação | General request data, proposer info |
257
+ | 2 | Documentação | Standard and specific documentation lists |
258
+ | 3 | Motivos Desvalorizantes | Devaluation reasons with checkboxes |
259
+ | 4 | Imóvel Objeto | Property data with PDF extraction |
260
+ | 5 | Avaliação | Evaluator info and methodology |
261
+ | 6 | Valores de Mercado | Market values per year |
262
+ | 7 | Registro Fotográfico | Photo registry DOCX upload + link to generator |
263
+ | 8 | Gerar Documento | Generate button and download |
264
+
265
+ ### Table with Vertical Headers
266
+ ```python
267
+ from document.formatters import add_simple_table
268
+ # rotacao_cabecalho=True rotates headers 90 degrees and auto-adjusts row height
269
+ add_simple_table(doc, dados, header_row=True, rotacao_cabecalho=True)
270
+ ```
271
+
272
+ ### Dynamic Fields Pattern
273
+ Maximum counts defined in `config/constants.py`. UI callbacks in `ui/app_builder.py` use `adicionar_campo()` and `remover_campo()`.
274
+
275
+ ### Text Templates
276
+ Pre-written text content is loaded from DOCX files in `templates/textos/`:
277
+ ```python
278
+ from config.settings import TEXTOS_DIR
279
+ from utils.docx_loader import ler_texto_docx
280
+
281
+ texto = ler_texto_docx(TEXTOS_DIR / "CONSIDERACOES_INICIAIS.docx")
282
+ ```
283
+
284
+ Available templates:
285
+ - `CONSIDERACOES_INICIAIS.docx` - Initial considerations section text
286
+ - `DIAGNOSTICO_MERCADO.docx` - Market diagnosis text
287
+
288
+ ### Photo Registry Merge
289
+ Photo registry documents can be uploaded and merged into the laudo:
290
+ ```python
291
+ from utils.docx_loader import mesclar_documento_fotos
292
+
293
+ # After generating annexes, merge photo doc if provided
294
+ if registro_foto_path:
295
+ doc = mesclar_documento_fotos(doc, Path(registro_foto_path))
296
+ ```
297
+
298
+ External photo registry generator: https://huggingface.co/spaces/gui-sparim/gerador_fotos
299
+
300
+ ## Language
301
+
302
+ All UI text, comments, and documentation are in Brazilian Portuguese. Code identifiers mix Portuguese (domain terms) and English (technical terms).
app.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sistema de Geração de Laudos de Avaliação Imobiliária.
3
+
4
+ Ponto de entrada da aplicação.
5
+ """
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ # Adicionar diretório raiz ao path
10
+ sys.path.insert(0, str(Path(__file__).parent))
11
+
12
+ from ui.app_builder import build_interface
13
+ from config.settings import SERVER_HOST, SERVER_PORT
14
+
15
+
16
+ def main(fill_dummy: bool = True):
17
+ """
18
+ Função principal.
19
+
20
+ Args:
21
+ fill_dummy: Se True, preenche campos com valores de teste (default: True).
22
+ """
23
+ print("=" * 50)
24
+ print("Sistema de Geração de Laudos")
25
+ print("=" * 50)
26
+
27
+ # Verificar modelos disponíveis
28
+ from models import list_available_models
29
+ modelos = list_available_models()
30
+ print(f"\nModelos disponíveis: {modelos}")
31
+
32
+ if fill_dummy:
33
+ print("\n[MODO TESTE] Campos preenchidos com valores dummy")
34
+
35
+ # Construir e lançar interface
36
+ app = build_interface(fill_dummy=fill_dummy)
37
+
38
+ print(f"\nIniciando servidor em http://{SERVER_HOST}:{SERVER_PORT}")
39
+ print("Pressione Ctrl+C para encerrar.\n")
40
+
41
+ app.launch(
42
+ server_name=SERVER_HOST,
43
+ server_port=SERVER_PORT,
44
+ share=False
45
+ )
46
+
47
+
48
+ if __name__ == "__main__":
49
+ # Altere para fill_dummy=False para produção
50
+ main(fill_dummy=True)
config/__init__.py ADDED
File without changes
config/constants.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Constantes e listas para o sistema de geração de laudos.
3
+ """
4
+
5
+ # --- Limites de campos dinâmicos ---
6
+ MAX_MOTIVOS_DESVALORIZANTES = 15
7
+ MAX_VALORES_MERCADO = 15
8
+ MAX_DOCUMENTACAO_PADRAO = 10
9
+ MAX_DOCUMENTACAO_ESPECIFICA = 10
10
+
11
+ # --- Listas para Interface Gráfica ---
12
+
13
+ UNIDADES_DEMANDANTES = [
14
+ "Equipe da Planta Genérica de Valores – EPGV (DAI/RM/SMF)",
15
+ "Outros"
16
+ ]
17
+
18
+ TIPOS_REQUERIMENTO = [
19
+ "Revisão de Valor Venal - Imóvel Acima do Valor de Mercado",
20
+ "Outros"
21
+ ]
22
+
23
+ UNIDADES_RESPONSAVEIS = [
24
+ "Equipe de Avaliações – EAV (DAI/RM/SMF)",
25
+ "Equipe de Suporte, Judiciais e Locações– ESJL",
26
+ "Outros"
27
+ ]
28
+
29
+ TECNICOS_RESPONSAVEIS = [
30
+ "Eng. Adriana Kirsch Bissigo – CREA/RS 69.342",
31
+ "Eng. Clara Francisca Marques – CREA RS 202.777",
32
+ "Eng. David Schuch Bertoglio – CREA/RS: 114.44",
33
+ "Eng. Débora Fonseca Alves – CREA RS190257",
34
+ "Eng. Edgar Alejandro Vargas - CREA RS 222061",
35
+ "Eng. Gilmara Müller – CREA 100.093",
36
+ "Eng. Jones Ritta Rodrigues – CREA/RS 141453-D",
37
+ "Arq. Fernanda Pontel – CAU/RS A50731-8",
38
+ "Arq. Gustavo N. B. Bastos – CAU 51953-7",
39
+ "Arq. Jéssica Lange – CAU A64935-0",
40
+ "Arq. Roberta Brenner Ayub – CAU A29665-1",
41
+ "Outros"
42
+ ]
43
+
44
+ METODOS_AVALIACAO = [
45
+ "Método Comparativo Dados de Mercado - ABNT NBR 14.653-2",
46
+ "Método Comparativo Dados de Mercado - ABNT NBR 14.653-2 e Método de Quantificação de Custo",
47
+ "Método Involutivo conforme NBR 14.653",
48
+ "Outros"
49
+ ]
50
+
51
+ # --- Textos padrão ---
52
+
53
+ TEXTO_OBSERVACOES_COMPLEMENTARES = """- Pressupõe-se que todas as informações fornecidas por terceiros merecem credibilidade;
54
+
55
+ - Considerou-se que os documentos e/ou títulos de propriedade do avaliando espelham a realidade;
56
+
57
+ - Este Laudo de Avaliação consta de {{num_paginas}} folhas, numeradas de 1 a {{num_paginas}} e esta datada;
58
+
59
+ - Este Laudo de Avaliação utilizou o modelo de avaliação {{modelo_avaliacao}};
60
+
61
+ - Este Laudo de Avaliação tem como referência {{datas_referencia}}."""
config/settings.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configurações do sistema de geração de laudos.
3
+ """
4
+ from pathlib import Path
5
+
6
+ # Diretório base do projeto
7
+ BASE_DIR = Path(__file__).parent.parent
8
+
9
+ # Diretórios principais
10
+ MODELS_DIR = BASE_DIR / "models" / "files"
11
+ TEMPLATES_DIR = BASE_DIR / "templates"
12
+ OUTPUT_DIR = BASE_DIR / "output" / "laudos_gerados"
13
+
14
+ # Templates
15
+ HEADER_TEMPLATE = TEMPLATES_DIR / "header.docx"
16
+ TEXTOS_DIR = TEMPLATES_DIR / "textos"
17
+
18
+ # Configurações do servidor Gradio
19
+ SERVER_HOST = "0.0.0.0"
20
+ SERVER_PORT = 7860
21
+
22
+ # Garantir que diretórios de saída existam
23
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
core/__init__.py ADDED
File without changes
core/entities/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Entidades do domínio.
3
+ """
4
+ from .solicitacao import Solicitacao
5
+ from .imovel import Imovel, MotivoDesvalorizante, ValorMercado
6
+ from .laudo import Laudo
7
+
8
+ __all__ = [
9
+ 'Solicitacao',
10
+ 'Imovel',
11
+ 'MotivoDesvalorizante',
12
+ 'ValorMercado',
13
+ 'Laudo',
14
+ ]
core/entities/imovel.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Entidade Imóvel - dados do imóvel avaliado.
3
+ """
4
+ from dataclasses import dataclass, field
5
+ from typing import List, Optional
6
+ from decimal import Decimal
7
+
8
+
9
+ @dataclass
10
+ class MotivoDesvalorizante:
11
+ """Representa um motivo desvalorizante do imóvel."""
12
+
13
+ descricao: str
14
+ alegado: bool = True
15
+ confirmado: bool = False
16
+
17
+
18
+ @dataclass
19
+ class ValorMercado:
20
+ """Representa um valor de mercado para um ano específico."""
21
+
22
+ ano: int
23
+ valor: Decimal
24
+ extenso: str = ""
25
+
26
+
27
+ @dataclass
28
+ class Imovel:
29
+ """Representa o imóvel objeto da avaliação."""
30
+
31
+ # Identificação
32
+ inscricao: str
33
+ endereco: str
34
+ bairro: str
35
+
36
+ # Localização cadastral
37
+ setor: str = ""
38
+ quarteirao: str = ""
39
+ lote: str = ""
40
+
41
+ # Características
42
+ finalidade: str = ""
43
+ area_territorial: str = ""
44
+ construcoes: str = ""
45
+
46
+ # Valores venais (do cadastro)
47
+ valores_venais: str = ""
48
+
49
+ # Motivos desvalorizantes
50
+ motivos_desvalorizantes: List[MotivoDesvalorizante] = field(default_factory=list)
51
+
52
+ # Valores de mercado por ano
53
+ valores_mercado: List[ValorMercado] = field(default_factory=list)
54
+
55
+ # Documentação
56
+ documentacao_padrao: List[str] = field(default_factory=list)
57
+ documentacao_especifica: List[str] = field(default_factory=list)
58
+
59
+ @property
60
+ def motivos_alegados(self) -> List[MotivoDesvalorizante]:
61
+ """Retorna motivos alegados pelo contribuinte."""
62
+ return [m for m in self.motivos_desvalorizantes if m.alegado]
63
+
64
+ @property
65
+ def motivos_confirmados(self) -> List[MotivoDesvalorizante]:
66
+ """Retorna motivos confirmados pela avaliação."""
67
+ return [m for m in self.motivos_desvalorizantes if m.confirmado]
core/entities/laudo.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Entidade Laudo - representa um laudo de avaliação completo.
3
+ """
4
+ from dataclasses import dataclass, field
5
+ from datetime import date
6
+ from typing import Optional
7
+
8
+ from .solicitacao import Solicitacao
9
+ from .imovel import Imovel
10
+
11
+
12
+ @dataclass
13
+ class Laudo:
14
+ """Representa um laudo de avaliação imobiliária completo."""
15
+
16
+ # Dados da solicitação
17
+ solicitacao: Solicitacao
18
+
19
+ # Dados do imóvel
20
+ imovel: Imovel
21
+
22
+ # Modelo e método de avaliação
23
+ modelo_avaliacao: str
24
+ metodo_avaliacao: str
25
+
26
+ # Responsáveis
27
+ tecnico_responsavel: str
28
+ unidade_responsavel: str
29
+
30
+ # Datas de referência
31
+ datas_referencia: str = ""
32
+
33
+ # Campos customizados
34
+ metodo_avaliacao_custom: Optional[str] = None
35
+ tecnico_responsavel_custom: Optional[str] = None
36
+ unidade_responsavel_custom: Optional[str] = None
37
+
38
+ @property
39
+ def numero(self) -> str:
40
+ """Retorna número do laudo (da solicitação)."""
41
+ return self.solicitacao.numero
42
+
43
+ @property
44
+ def processo(self) -> str:
45
+ """Retorna número do processo."""
46
+ return self.solicitacao.processo
47
+
48
+ @property
49
+ def data(self) -> date:
50
+ """Retorna data do laudo."""
51
+ return self.solicitacao.data
52
+
53
+ @property
54
+ def metodo_avaliacao_final(self) -> str:
55
+ """Retorna método de avaliação customizado se 'Outros' selecionado."""
56
+ if self.metodo_avaliacao == "Outros" and self.metodo_avaliacao_custom:
57
+ return self.metodo_avaliacao_custom
58
+ return self.metodo_avaliacao
59
+
60
+ @property
61
+ def tecnico_responsavel_final(self) -> str:
62
+ """Retorna técnico responsável customizado se 'Outros' selecionado."""
63
+ if self.tecnico_responsavel == "Outros" and self.tecnico_responsavel_custom:
64
+ return self.tecnico_responsavel_custom
65
+ return self.tecnico_responsavel
66
+
67
+ @property
68
+ def unidade_responsavel_final(self) -> str:
69
+ """Retorna unidade responsável customizada se 'Outros' selecionado."""
70
+ if self.unidade_responsavel == "Outros" and self.unidade_responsavel_custom:
71
+ return self.unidade_responsavel_custom
72
+ return self.unidade_responsavel
core/entities/solicitacao.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Entidade Solicitação - dados da solicitação do laudo.
3
+ """
4
+ from dataclasses import dataclass, field
5
+ from datetime import date
6
+ from typing import Optional
7
+
8
+
9
+ @dataclass
10
+ class Solicitacao:
11
+ """Representa uma solicitação de laudo de avaliação."""
12
+
13
+ numero: str
14
+ processo: str
15
+ unidade_demandante: str
16
+ tipo_requerimento: str
17
+ data: date = field(default_factory=date.today)
18
+
19
+ # Campos opcionais customizados
20
+ unidade_demandante_custom: Optional[str] = None
21
+ tipo_requerimento_custom: Optional[str] = None
22
+
23
+ @property
24
+ def unidade_demandante_final(self) -> str:
25
+ """Retorna unidade demandante customizada se 'Outros' selecionado."""
26
+ if self.unidade_demandante == "Outros" and self.unidade_demandante_custom:
27
+ return self.unidade_demandante_custom
28
+ return self.unidade_demandante
29
+
30
+ @property
31
+ def tipo_requerimento_final(self) -> str:
32
+ """Retorna tipo de requerimento customizado se 'Outros' selecionado."""
33
+ if self.tipo_requerimento == "Outros" and self.tipo_requerimento_custom:
34
+ return self.tipo_requerimento_custom
35
+ return self.tipo_requerimento
core/services/__init__.py ADDED
File without changes
document/__init__.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Módulo de geração de documentos DOCX.
3
+ """
4
+ from .numbering import NumeradorSecoes
5
+ from .generator import LaudoGenerator
6
+ from .formatters import (
7
+ # Parágrafos
8
+ criar_paragrafo_formatado,
9
+ add_body_text,
10
+ add_placeholder_text,
11
+ add_bullet_text,
12
+ # Títulos
13
+ add_section_title,
14
+ add_subsection_title,
15
+ add_subsubsection_title,
16
+ add_subsubsubsection_title,
17
+ # Tabelas
18
+ add_simple_table,
19
+ formatar_celula_tabela,
20
+ configurar_linha_tabela_altura,
21
+ criar_celula_cabecalho_tabela,
22
+ criar_celula_dados_tabela,
23
+ )
24
+
25
+ __all__ = [
26
+ 'NumeradorSecoes',
27
+ 'LaudoGenerator',
28
+ 'criar_paragrafo_formatado',
29
+ 'add_body_text',
30
+ 'add_placeholder_text',
31
+ 'add_bullet_text',
32
+ 'add_section_title',
33
+ 'add_subsection_title',
34
+ 'add_subsubsection_title',
35
+ 'add_subsubsubsection_title',
36
+ 'add_simple_table',
37
+ 'formatar_celula_tabela',
38
+ 'configurar_linha_tabela_altura',
39
+ 'criar_celula_cabecalho_tabela',
40
+ 'criar_celula_dados_tabela',
41
+ ]
document/anexos/__init__.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Geradores de anexos a partir dos dados do modelo (.dai).
3
+ """
4
+ from .metodologia import gerar_anexo_metodologia
5
+ from .banco_dados import gerar_anexo_banco_dados
6
+ from .planilha_calculo import gerar_anexo_planilha
7
+ from .graficos import gerar_anexo_graficos
8
+ from .calculo_valor import gerar_anexo_calculo
9
+ from .estatisticas import gerar_anexo_estatisticas
10
+
11
+ __all__ = [
12
+ 'gerar_anexo_metodologia',
13
+ 'gerar_anexo_banco_dados',
14
+ 'gerar_anexo_planilha',
15
+ 'gerar_anexo_graficos',
16
+ 'gerar_anexo_calculo',
17
+ 'gerar_anexo_estatisticas',
18
+ ]
document/anexos/banco_dados.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gerador do Anexo de Banco de Dados.
3
+ Gera a tabela com os dados das amostras (Xy_preview).
4
+ """
5
+ from typing import Optional
6
+ import pandas as pd
7
+ from docx import Document
8
+ from docx.shared import Pt, Inches
9
+
10
+ from models.model_data import ModelData
11
+ from document.formatters import (
12
+ add_body_text,
13
+ add_placeholder_text,
14
+ add_subsection_title,
15
+ add_simple_table,
16
+ )
17
+
18
+ # Hífen que não permite quebra de linha (non-breaking hyphen)
19
+ HIFEN_NAO_QUEBRAVEL = '\u2011'
20
+
21
+
22
+ def _formatar_numero(val: float, casas_decimais: int = 2) -> str:
23
+ """
24
+ Formata número substituindo hífen por hífen não-quebrável.
25
+ Evita que o Word quebre a linha entre o sinal negativo e o número.
26
+ """
27
+ texto = f"{val:.{casas_decimais}f}"
28
+ return texto.replace('-', HIFEN_NAO_QUEBRAVEL)
29
+
30
+
31
+ def gerar_anexo_banco_dados(doc: Document, modelo: Optional[ModelData]) -> None:
32
+ """
33
+ Gera o conteúdo do Anexo de Banco de Dados.
34
+
35
+ Args:
36
+ doc: Documento DOCX
37
+ modelo: Dados do modelo carregado do .dai
38
+ """
39
+ if modelo is None:
40
+ add_placeholder_text(doc, "[Modelo não selecionado - inserir banco de dados manualmente]")
41
+ return
42
+
43
+ if modelo.xy_preview.empty:
44
+ add_placeholder_text(doc, "[Banco de dados não disponível no modelo]")
45
+ return
46
+
47
+ df = modelo.xy_preview
48
+
49
+ # Detectar colunas que contêm apenas valores inteiros
50
+ colunas_inteiras = set()
51
+ for col in df.columns:
52
+ valores = df[col].dropna()
53
+ if len(valores) > 0 and all(
54
+ isinstance(v, (int, float)) and float(v) == int(v)
55
+ for v in valores
56
+ ):
57
+ colunas_inteiras.add(col)
58
+
59
+ # Informações gerais
60
+ add_body_text(doc, f"Total de amostras: {len(df)}")
61
+ add_body_text(doc, f"Variáveis: {', '.join(df.columns.tolist())}")
62
+
63
+ doc.add_paragraph()
64
+
65
+ # Preparar dados para tabela (exibir todos os dados com ID)
66
+ headers = ["ID"] + df.columns.tolist()
67
+ dados = [headers]
68
+
69
+ for idx, (_, row) in enumerate(df.iterrows(), start=1):
70
+ linha = [str(idx)] # ID da amostra (1-based)
71
+ for col, val in zip(df.columns, row.values):
72
+ if val is None or (isinstance(val, float) and pd.isna(val)):
73
+ linha.append("")
74
+ elif col in colunas_inteiras:
75
+ # Inteiros também podem ser negativos
76
+ texto = str(int(val))
77
+ linha.append(texto.replace('-', HIFEN_NAO_QUEBRAVEL))
78
+ elif isinstance(val, (int, float)):
79
+ linha.append(_formatar_numero(val, casas_decimais=2))
80
+ else:
81
+ linha.append(str(val))
82
+ dados.append(linha)
83
+
84
+ # Calcular larguras proporcionais (ID mais estreito)
85
+ n_cols = len(headers)
86
+ larguras = [0.5] + [1] * (n_cols - 1) # ID mais estreito, demais iguais
87
+
88
+ add_simple_table(doc, dados, header_row=True, largura_colunas=larguras, rotacao_cabecalho=True)
document/anexos/calculo_valor.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gerador do Anexo de Cálculo do Valor.
3
+ Apresenta a equação e resultado do cálculo.
4
+ """
5
+ from typing import Optional, Dict, Any
6
+ from docx import Document
7
+
8
+ from models.model_data import ModelData
9
+ from document.formatters import (
10
+ add_body_text,
11
+ add_placeholder_text,
12
+ add_subsection_title,
13
+ criar_paragrafo_formatado,
14
+ )
15
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
16
+
17
+
18
+ def gerar_anexo_calculo(
19
+ doc: Document,
20
+ modelo: Optional[ModelData],
21
+ valores_imovel: Optional[Dict[str, Any]] = None
22
+ ) -> None:
23
+ """
24
+ Gera o conteúdo do Anexo de Cálculo do Valor.
25
+
26
+ Args:
27
+ doc: Documento DOCX
28
+ modelo: Dados do modelo carregado do .dai
29
+ valores_imovel: Valores das variáveis do imóvel avaliado (opcional)
30
+ """
31
+ if modelo is None:
32
+ add_placeholder_text(doc, "[Modelo não selecionado - inserir cálculo manualmente]")
33
+ return
34
+
35
+ # Equação do modelo
36
+ if modelo.equacao:
37
+ add_subsection_title(doc, "", "Equação do Modelo")
38
+ criar_paragrafo_formatado(
39
+ doc,
40
+ modelo.equacao,
41
+ tamanho=11,
42
+ alinhamento=WD_ALIGN_PARAGRAPH.CENTER,
43
+ espaco_depois=12
44
+ )
45
+
46
+ # Variáveis utilizadas
47
+ if modelo.variaveis:
48
+ add_subsection_title(doc, "", "Variáveis do Modelo")
49
+ for var in modelo.variaveis:
50
+ add_body_text(doc, f"• {var}")
51
+
52
+ doc.add_paragraph()
53
+
54
+ # Coeficientes
55
+ if not modelo.tabelas_coef.empty:
56
+ add_subsection_title(doc, "", "Coeficientes")
57
+ df = modelo.tabelas_coef
58
+
59
+ # Mostrar coeficientes principais
60
+ if 'coef' in df.columns or 'Coeficiente' in df.columns:
61
+ col_coef = 'coef' if 'coef' in df.columns else 'Coeficiente'
62
+ for idx, row in df.iterrows():
63
+ coef = row[col_coef]
64
+ if isinstance(coef, float):
65
+ add_body_text(doc, f"• {idx}: {coef:.6f}")
66
+ else:
67
+ add_body_text(doc, f"• {idx}: {coef}")
68
+
69
+ doc.add_paragraph()
70
+
71
+ # Seção para cálculo específico do imóvel (placeholder para preenchimento)
72
+ add_subsection_title(doc, "", "Cálculo do Valor do Imóvel")
73
+
74
+ if valores_imovel:
75
+ _calcular_valor(doc, modelo, valores_imovel)
76
+ else:
77
+ add_placeholder_text(doc, "[Inserir valores das variáveis para o imóvel avaliado]")
78
+ add_placeholder_text(doc, "[Inserir cálculo e resultado final]")
79
+
80
+
81
+ def _calcular_valor(doc: Document, modelo: ModelData, valores: Dict[str, Any]) -> None:
82
+ """Calcula e apresenta o valor do imóvel."""
83
+ add_body_text(doc, "Valores utilizados:")
84
+
85
+ for var, val in valores.items():
86
+ if isinstance(val, float):
87
+ add_body_text(doc, f"• {var} = {val:.2f}")
88
+ else:
89
+ add_body_text(doc, f"• {var} = {val}")
90
+
91
+ doc.add_paragraph()
92
+ add_placeholder_text(doc, "[Resultado do cálculo]")
document/anexos/estatisticas.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gerador do Anexo de Estatísticas.
3
+ Apresenta o resumo estatístico do modelo seguindo a estrutura do interface_estatisticas.
4
+ """
5
+ from typing import Optional, Dict, Any
6
+ from docx import Document
7
+ from docx.shared import Pt
8
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
9
+
10
+ from models.model_data import ModelData
11
+ from document.formatters import (
12
+ add_body_text,
13
+ add_placeholder_text,
14
+ add_subsection_title,
15
+ add_simple_table,
16
+ criar_paragrafo_formatado,
17
+ )
18
+ from utils.estatisticas_utils import reorganizar_modelos_resumos, formatar_numero
19
+
20
+
21
+ def gerar_anexo_estatisticas(doc: Document, modelo: Optional[ModelData]) -> None:
22
+ """
23
+ Gera o conteúdo do Anexo de Estatísticas.
24
+
25
+ Args:
26
+ doc: Documento DOCX
27
+ modelo: Dados do modelo carregado do .dai
28
+ """
29
+ if modelo is None:
30
+ add_placeholder_text(doc, "[Modelo não selecionado - inserir estatísticas manualmente]")
31
+ return
32
+
33
+ # Estatísticas descritivas das variáveis
34
+ if not modelo.estatisticas.empty:
35
+ add_subsection_title(doc, "", "Estatísticas Descritivas das Variáveis")
36
+ _adicionar_estatisticas_descritivas(doc, modelo.estatisticas)
37
+ doc.add_paragraph()
38
+
39
+ # Resumo do modelo (usando estrutura reorganizada)
40
+ if modelo.modelos_resumos:
41
+ resumo = reorganizar_modelos_resumos(modelo.modelos_resumos)
42
+
43
+ # Estatísticas Gerais
44
+ _adicionar_estatisticas_gerais(doc, resumo.get("estatisticas_gerais", {}))
45
+
46
+ # Testes Estatísticos
47
+ _adicionar_teste_f(doc, resumo.get("teste_f", {}))
48
+ _adicionar_teste_ks(doc, resumo.get("teste_ks", {}))
49
+ _adicionar_perc_resid(doc, resumo.get("perc_resid", {}))
50
+ _adicionar_teste_dw(doc, resumo.get("teste_dw", {}))
51
+ _adicionar_teste_bp(doc, resumo.get("teste_bp", {}))
52
+
53
+ # Equação do Modelo
54
+ _adicionar_equacao(doc, resumo.get("equacao"))
55
+
56
+ if modelo.estatisticas.empty and not modelo.modelos_resumos:
57
+ add_placeholder_text(doc, "[Estatísticas não disponíveis no modelo]")
58
+
59
+
60
+ def _adicionar_estatisticas_descritivas(doc: Document, df) -> None:
61
+ """Adiciona tabela de estatísticas descritivas."""
62
+ headers = [str(col) for col in df.columns.tolist()]
63
+
64
+ if df.index.name or len(df.index) > 0:
65
+ headers = ["Variável"] + headers
66
+ dados = [headers]
67
+
68
+ for idx, row in df.iterrows():
69
+ linha = [str(idx)]
70
+ for val in row.values:
71
+ linha.append(formatar_numero(val))
72
+ dados.append(linha)
73
+ else:
74
+ dados = [headers]
75
+ for _, row in df.iterrows():
76
+ linha = [formatar_numero(val) for val in row.values]
77
+ dados.append(linha)
78
+
79
+ add_simple_table(doc, dados, header_row=True, rotacao_cabecalho=True)
80
+
81
+
82
+ def _adicionar_estatisticas_gerais(doc: Document, estat_gerais: Dict[str, Any]) -> None:
83
+ """Adiciona tabela de estatísticas gerais do modelo."""
84
+ if not estat_gerais:
85
+ return
86
+
87
+ add_subsection_title(doc, "", "Estatísticas Gerais do Modelo")
88
+
89
+ dados = [["Indicador", "Valor"]]
90
+
91
+ for chave, info in estat_gerais.items():
92
+ nome = info.get("nome", chave)
93
+ valor = info.get("valor")
94
+ if valor is not None:
95
+ dados.append([nome, formatar_numero(valor)])
96
+
97
+ if len(dados) > 1:
98
+ add_simple_table(doc, dados, header_row=True)
99
+
100
+ doc.add_paragraph()
101
+
102
+
103
+ def _adicionar_teste_f(doc: Document, teste: Dict[str, Any]) -> None:
104
+ """Adiciona resultado do Teste F."""
105
+ if not teste.get("estatistica"):
106
+ return
107
+
108
+ add_subsection_title(doc, "", teste.get("nome", "Teste F"))
109
+
110
+ dados = [["Indicador", "Valor"]]
111
+ dados.append(["Estatística F", formatar_numero(teste["estatistica"])])
112
+
113
+ if teste.get("pvalor") is not None:
114
+ dados.append(["P-valor", formatar_numero(teste["pvalor"])])
115
+
116
+ add_simple_table(doc, dados, header_row=True)
117
+
118
+ if teste.get("interpretacao"):
119
+ _adicionar_interpretacao(doc, teste["interpretacao"])
120
+
121
+ doc.add_paragraph()
122
+
123
+
124
+ def _adicionar_teste_ks(doc: Document, teste: Dict[str, Any]) -> None:
125
+ """Adiciona resultado do Teste de Kolmogorov-Smirnov."""
126
+ if not teste.get("estatistica"):
127
+ return
128
+
129
+ add_subsection_title(doc, "", teste.get("nome", "Teste de Normalidade (Kolmogorov-Smirnov)"))
130
+
131
+ dados = [["Indicador", "Valor"]]
132
+ dados.append(["Estatística KS", formatar_numero(teste["estatistica"])])
133
+
134
+ if teste.get("pvalor") is not None:
135
+ dados.append(["P-valor", formatar_numero(teste["pvalor"])])
136
+
137
+ add_simple_table(doc, dados, header_row=True)
138
+
139
+ if teste.get("interpretacao"):
140
+ _adicionar_interpretacao(doc, teste["interpretacao"])
141
+
142
+ doc.add_paragraph()
143
+
144
+
145
+ def _adicionar_perc_resid(doc: Document, perc: Dict[str, Any]) -> None:
146
+ """Adiciona resultado do teste de comparação com curva normal."""
147
+ if not perc.get("valor"):
148
+ return
149
+
150
+ add_subsection_title(doc, "", perc.get("nome", "Teste de Normalidade (Comparação com a Curva Normal)"))
151
+
152
+ add_body_text(doc, f"Percentuais atingidos: {perc['valor']}")
153
+
154
+ interpretacoes = perc.get("interpretacao", [])
155
+ if interpretacoes:
156
+ add_body_text(doc, "Critérios ideais:")
157
+ for item in interpretacoes:
158
+ add_body_text(doc, f" • {item}")
159
+
160
+ doc.add_paragraph()
161
+
162
+
163
+ def _adicionar_teste_dw(doc: Document, teste: Dict[str, Any]) -> None:
164
+ """Adiciona resultado do Teste de Durbin-Watson."""
165
+ if not teste.get("estatistica"):
166
+ return
167
+
168
+ add_subsection_title(doc, "", teste.get("nome", "Teste de Autocorrelação (Durbin-Watson)"))
169
+
170
+ dados = [["Indicador", "Valor"]]
171
+ dados.append(["Estatística DW", formatar_numero(teste["estatistica"])])
172
+
173
+ add_simple_table(doc, dados, header_row=True)
174
+
175
+ if teste.get("interpretacao"):
176
+ _adicionar_interpretacao(doc, teste["interpretacao"])
177
+
178
+ doc.add_paragraph()
179
+
180
+
181
+ def _adicionar_teste_bp(doc: Document, teste: Dict[str, Any]) -> None:
182
+ """Adiciona resultado do Teste de Breusch-Pagan."""
183
+ if not teste.get("estatistica"):
184
+ return
185
+
186
+ add_subsection_title(doc, "", teste.get("nome", "Teste de Homocedasticidade (Breusch-Pagan)"))
187
+
188
+ dados = [["Indicador", "Valor"]]
189
+ dados.append(["Estatística LM", formatar_numero(teste["estatistica"])])
190
+
191
+ if teste.get("pvalor") is not None:
192
+ dados.append(["P-valor", formatar_numero(teste["pvalor"])])
193
+
194
+ add_simple_table(doc, dados, header_row=True)
195
+
196
+ if teste.get("interpretacao"):
197
+ _adicionar_interpretacao(doc, teste["interpretacao"])
198
+
199
+ doc.add_paragraph()
200
+
201
+
202
+ def _adicionar_equacao(doc: Document, equacao: Optional[str]) -> None:
203
+ """Adiciona equação do modelo centralizada."""
204
+ if not equacao:
205
+ return
206
+
207
+ add_subsection_title(doc, "", "Equação do Modelo")
208
+
209
+ criar_paragrafo_formatado(
210
+ doc,
211
+ equacao,
212
+ tamanho=11,
213
+ alinhamento=WD_ALIGN_PARAGRAPH.CENTER,
214
+ espaco_antes=6,
215
+ espaco_depois=6,
216
+ )
217
+
218
+ doc.add_paragraph()
219
+
220
+
221
+ def _adicionar_interpretacao(doc: Document, texto: str) -> None:
222
+ """Adiciona texto de interpretação em itálico."""
223
+ criar_paragrafo_formatado(
224
+ doc,
225
+ f"Interpretação: {texto}",
226
+ italico=True,
227
+ tamanho=10,
228
+ espaco_antes=3,
229
+ espaco_depois=3,
230
+ )
document/anexos/graficos.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gerador do Anexo de Gráficos.
3
+ Insere gráficos de diagnóstico do modelo no documento.
4
+ """
5
+ import base64
6
+ import io
7
+ from typing import Optional, List, Tuple
8
+ from docx import Document
9
+ from docx.shared import Inches
10
+
11
+ import matplotlib
12
+ matplotlib.use('Agg') # Backend não-interativo para geração de imagens
13
+ import matplotlib.pyplot as plt
14
+ import numpy as np
15
+ from scipy import stats
16
+
17
+ from models.model_data import ModelData
18
+ from document.formatters import (
19
+ add_body_text,
20
+ add_placeholder_text,
21
+ add_subsection_title,
22
+ )
23
+
24
+
25
+ def gerar_anexo_graficos(doc: Document, modelo: Optional[ModelData]) -> None:
26
+ """
27
+ Gera o conteúdo do Anexo de Gráficos.
28
+
29
+ Args:
30
+ doc: Documento DOCX
31
+ modelo: Dados do modelo carregado do .dai
32
+ """
33
+ if modelo is None:
34
+ add_placeholder_text(doc, "[Modelo não selecionado - inserir gráficos manualmente]")
35
+ return
36
+
37
+ graficos_inseridos = 0
38
+
39
+ # Tentar gerar gráficos a partir dos dados do modelo
40
+ if modelo.modelos_sm is not None or not modelo.tabelas_obs_calc.empty:
41
+ graficos = _gerar_graficos_diagnostico(modelo)
42
+
43
+ for titulo, img_buffer in graficos:
44
+ # Título já está dentro do gráfico, não precisa adicionar em texto
45
+ # Tamanho reduzido para caber 2 gráficos por página
46
+ doc.add_picture(img_buffer, width=Inches(5.0))
47
+ graficos_inseridos += 1
48
+
49
+ # Fallback: tentar inserir graf_model existente
50
+ if graficos_inseridos == 0 and modelo.graf_model:
51
+ add_subsection_title(doc, "", "Gráficos do Modelo")
52
+ if _inserir_grafico(doc, modelo.graf_model):
53
+ graficos_inseridos += 1
54
+
55
+ if graficos_inseridos == 0:
56
+ add_placeholder_text(doc, "[Gráficos não disponíveis no modelo]")
57
+
58
+
59
+ def _gerar_graficos_diagnostico(modelo: ModelData) -> List[Tuple[str, io.BytesIO]]:
60
+ """
61
+ Gera gráficos de diagnóstico do modelo de regressão.
62
+
63
+ Args:
64
+ modelo: Dados do modelo
65
+
66
+ Returns:
67
+ Lista de tuplas (título, buffer de imagem)
68
+ """
69
+ graficos = []
70
+
71
+ # Obter dados para os gráficos
72
+ obs_calc = modelo.tabelas_obs_calc
73
+ modelos_sm = modelo.modelos_sm
74
+
75
+ # Identificar colunas de valores observados e calculados
76
+ y_obs = None
77
+ y_calc = None
78
+ residuos = None
79
+
80
+ if not obs_calc.empty:
81
+ # Tentar identificar colunas
82
+ cols_lower = {c.lower(): c for c in obs_calc.columns}
83
+
84
+ for nome_obs in ['observado', 'obs', 'y_obs', 'y', 'valor_observado']:
85
+ if nome_obs in cols_lower:
86
+ y_obs = obs_calc[cols_lower[nome_obs]].values
87
+ break
88
+
89
+ for nome_calc in ['calculado', 'calc', 'y_calc', 'y_hat', 'previsto', 'predicted', 'valor_calculado']:
90
+ if nome_calc in cols_lower:
91
+ y_calc = obs_calc[cols_lower[nome_calc]].values
92
+ break
93
+
94
+ for nome_res in ['residuo', 'residuos', 'resid', 'residual']:
95
+ if nome_res in cols_lower:
96
+ residuos = obs_calc[cols_lower[nome_res]].values
97
+ break
98
+
99
+ # Se temos statsmodels result, extrair dados dele
100
+ if modelos_sm is not None:
101
+ try:
102
+ if y_obs is None and hasattr(modelos_sm, 'model') and hasattr(modelos_sm.model, 'endog'):
103
+ y_obs = modelos_sm.model.endog
104
+ if y_calc is None and hasattr(modelos_sm, 'fittedvalues'):
105
+ y_calc = modelos_sm.fittedvalues
106
+ if residuos is None and hasattr(modelos_sm, 'resid'):
107
+ residuos = modelos_sm.resid
108
+ except Exception:
109
+ pass
110
+
111
+ # Calcular resíduos se temos obs e calc
112
+ if residuos is None and y_obs is not None and y_calc is not None:
113
+ residuos = np.array(y_obs) - np.array(y_calc)
114
+
115
+ # Gráfico 1: Valores Observados vs Calculados
116
+ if y_obs is not None and y_calc is not None:
117
+ graf = _criar_grafico_obs_calc(y_obs, y_calc)
118
+ if graf:
119
+ graficos.append(("Valores Observados vs Calculados", graf))
120
+
121
+ # Gráfico 2: Resíduos vs Valores Ajustados
122
+ if residuos is not None and y_calc is not None:
123
+ graf = _criar_grafico_residuos(y_calc, residuos)
124
+ if graf:
125
+ graficos.append(("Resíduos vs Valores Ajustados", graf))
126
+
127
+ # Gráfico 3: QQ-Plot dos Resíduos
128
+ if residuos is not None:
129
+ graf = _criar_qqplot(residuos)
130
+ if graf:
131
+ graficos.append(("Gráfico Q-Q dos Resíduos", graf))
132
+
133
+ # Gráfico 4: Histograma dos Resíduos
134
+ if residuos is not None:
135
+ graf = _criar_histograma_residuos(residuos)
136
+ if graf:
137
+ graficos.append(("Distribuição dos Resíduos", graf))
138
+
139
+ return graficos
140
+
141
+
142
+ def _criar_grafico_obs_calc(y_obs: np.ndarray, y_calc: np.ndarray) -> Optional[io.BytesIO]:
143
+ """Cria gráfico de valores observados vs calculados."""
144
+ try:
145
+ fig, ax = plt.subplots(figsize=(8, 5))
146
+
147
+ ax.scatter(y_calc, y_obs, alpha=0.6, edgecolors='black', linewidths=0.5, color='#FF8C00')
148
+
149
+ # Linha de identidade (45 graus)
150
+ min_val = min(min(y_obs), min(y_calc))
151
+ max_val = max(max(y_obs), max(y_calc))
152
+ ax.plot([min_val, max_val], [min_val, max_val], 'r--', linewidth=1.5, label='Linha de identidade')
153
+
154
+ ax.set_xlabel('Valores Calculados', fontsize=11)
155
+ ax.set_ylabel('Valores Observados', fontsize=11)
156
+ ax.set_title('Valores Observados vs Calculados', fontsize=12, fontweight='bold')
157
+ ax.legend(loc='upper left')
158
+ ax.grid(True, alpha=0.3)
159
+
160
+ # Ajustar limites
161
+ margin = (max_val - min_val) * 0.05
162
+ ax.set_xlim(min_val - margin, max_val + margin)
163
+ ax.set_ylim(min_val - margin, max_val + margin)
164
+
165
+ plt.tight_layout()
166
+
167
+ buf = io.BytesIO()
168
+ fig.savefig(buf, format='png', dpi=150, bbox_inches='tight')
169
+ buf.seek(0)
170
+ plt.close(fig)
171
+
172
+ return buf
173
+ except Exception as e:
174
+ print(f"Erro ao criar gráfico obs vs calc: {e}")
175
+ return None
176
+
177
+
178
+ def _criar_grafico_residuos(y_calc: np.ndarray, residuos: np.ndarray) -> Optional[io.BytesIO]:
179
+ """Cria gráfico de resíduos vs valores ajustados."""
180
+ try:
181
+ fig, ax = plt.subplots(figsize=(8, 5))
182
+
183
+ ax.scatter(y_calc, residuos, alpha=0.6, edgecolors='black', linewidths=0.5, color='#FF8C00')
184
+ ax.axhline(y=0, color='red', linestyle='--', linewidth=1.5)
185
+
186
+ ax.set_xlabel('Valores Ajustados', fontsize=11)
187
+ ax.set_ylabel('Resíduos', fontsize=11)
188
+ ax.set_title('Resíduos vs Valores Ajustados', fontsize=12, fontweight='bold')
189
+ ax.grid(True, alpha=0.3)
190
+
191
+ plt.tight_layout()
192
+
193
+ buf = io.BytesIO()
194
+ fig.savefig(buf, format='png', dpi=150, bbox_inches='tight')
195
+ buf.seek(0)
196
+ plt.close(fig)
197
+
198
+ return buf
199
+ except Exception as e:
200
+ print(f"Erro ao criar gráfico de resíduos: {e}")
201
+ return None
202
+
203
+
204
+ def _criar_qqplot(residuos: np.ndarray) -> Optional[io.BytesIO]:
205
+ """Cria QQ-Plot dos resíduos."""
206
+ try:
207
+ fig, ax = plt.subplots(figsize=(8, 5))
208
+
209
+ # Padronizar resíduos
210
+ residuos_std = (residuos - np.mean(residuos)) / np.std(residuos)
211
+
212
+ stats.probplot(residuos_std, dist="norm", plot=ax)
213
+
214
+ ax.set_xlabel('Quantis Teóricos', fontsize=11)
215
+ ax.set_ylabel('Quantis Amostrais', fontsize=11)
216
+ ax.set_title('Gráfico Q-Q (Normalidade dos Resíduos)', fontsize=12, fontweight='bold')
217
+ ax.grid(True, alpha=0.3)
218
+
219
+ # Mudar cor dos pontos para laranja
220
+ ax.get_lines()[0].set_markerfacecolor('#FF8C00')
221
+ ax.get_lines()[0].set_markeredgecolor('black')
222
+ ax.get_lines()[0].set_markeredgewidth(0.5)
223
+
224
+ plt.tight_layout()
225
+
226
+ buf = io.BytesIO()
227
+ fig.savefig(buf, format='png', dpi=150, bbox_inches='tight')
228
+ buf.seek(0)
229
+ plt.close(fig)
230
+
231
+ return buf
232
+ except Exception as e:
233
+ print(f"Erro ao criar QQ-plot: {e}")
234
+ return None
235
+
236
+
237
+ def _criar_histograma_residuos(residuos: np.ndarray) -> Optional[io.BytesIO]:
238
+ """Cria histograma dos resíduos com curva normal."""
239
+ try:
240
+ fig, ax = plt.subplots(figsize=(8, 5))
241
+
242
+ # Histograma
243
+ n, bins, patches = ax.hist(residuos, bins='auto', density=True, alpha=0.7,
244
+ color='#FF8C00', edgecolor='black', linewidth=0.5)
245
+
246
+ # Curva normal ajustada
247
+ mu, sigma = np.mean(residuos), np.std(residuos)
248
+ x = np.linspace(min(residuos), max(residuos), 100)
249
+ y = stats.norm.pdf(x, mu, sigma)
250
+ ax.plot(x, y, 'r-', linewidth=2, label='Curva Normal')
251
+
252
+ ax.set_xlabel('Resíduos', fontsize=11)
253
+ ax.set_ylabel('Densidade', fontsize=11)
254
+ ax.set_title('Distribuição dos Resíduos', fontsize=12, fontweight='bold')
255
+ ax.legend(loc='upper right')
256
+ ax.grid(True, alpha=0.3)
257
+
258
+ plt.tight_layout()
259
+
260
+ buf = io.BytesIO()
261
+ fig.savefig(buf, format='png', dpi=150, bbox_inches='tight')
262
+ buf.seek(0)
263
+ plt.close(fig)
264
+
265
+ return buf
266
+ except Exception as e:
267
+ print(f"Erro ao criar histograma: {e}")
268
+ return None
269
+
270
+
271
+ def _inserir_grafico(doc: Document, graf_data: str) -> bool:
272
+ """
273
+ Tenta inserir um gráfico no documento.
274
+
275
+ Args:
276
+ doc: Documento DOCX
277
+ graf_data: Dados do gráfico (base64, caminho, HTML, ou string)
278
+
279
+ Returns:
280
+ True se conseguiu inserir, False caso contrário
281
+ """
282
+ try:
283
+ # Verificar se é HTML (Plotly)
284
+ if graf_data.strip().startswith('<html') or graf_data.strip().startswith('<div'):
285
+ add_body_text(doc, "[Gráfico em formato HTML/Plotly - gráficos de diagnóstico gerados automaticamente]")
286
+ return False
287
+
288
+ # Tentar como base64
289
+ if graf_data.startswith('data:image'):
290
+ header, encoded = graf_data.split(',', 1)
291
+ image_data = base64.b64decode(encoded)
292
+ image_stream = io.BytesIO(image_data)
293
+ doc.add_picture(image_stream, width=Inches(5.0))
294
+ return True
295
+
296
+ # Tentar como base64 puro
297
+ try:
298
+ image_data = base64.b64decode(graf_data)
299
+ image_stream = io.BytesIO(image_data)
300
+ doc.add_picture(image_stream, width=Inches(5.0))
301
+ return True
302
+ except Exception:
303
+ pass
304
+
305
+ # Tentar como caminho de arquivo
306
+ from pathlib import Path
307
+ if len(graf_data) < 500 and Path(graf_data).exists():
308
+ doc.add_picture(graf_data, width=Inches(5.0))
309
+ return True
310
+
311
+ return False
312
+
313
+ except Exception as e:
314
+ print(f"Erro ao inserir gráfico: {e}")
315
+ return False
document/anexos/metodologia.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gerador do Anexo de Metodologia.
3
+ Gera a seção de metodologia a partir dos dados do modelo (.dai).
4
+ """
5
+ from typing import Optional
6
+ from docx import Document
7
+ from docx.shared import Pt
8
+
9
+ from models.model_data import ModelData
10
+ from document.formatters import (
11
+ add_body_text,
12
+ add_placeholder_text,
13
+ add_subsection_title,
14
+ add_simple_table,
15
+ )
16
+
17
+
18
+ def gerar_anexo_metodologia(doc: Document, modelo: Optional[ModelData], num_secao: str = "") -> None:
19
+ """
20
+ Gera o conteúdo do Anexo de Metodologia.
21
+
22
+ Args:
23
+ doc: Documento DOCX
24
+ modelo: Dados do modelo carregado do .dai
25
+ num_secao: Número da seção para títulos
26
+ """
27
+ if modelo is None:
28
+ add_placeholder_text(doc, "[Modelo não selecionado - inserir metodologia manualmente]")
29
+ return
30
+
31
+ # Informações do modelo
32
+ add_subsection_title(doc, "", "Modelo Utilizado")
33
+ add_body_text(doc, f"Modelo: {modelo.nome}")
34
+
35
+ if modelo.n_amostras:
36
+ add_body_text(doc, f"Número de amostras: {modelo.n_amostras}")
37
+
38
+ if modelo.n_variaveis:
39
+ add_body_text(doc, f"Número de variáveis: {modelo.n_variaveis}")
40
+
41
+ # Variáveis do modelo
42
+ if modelo.variaveis:
43
+ add_subsection_title(doc, "", "Variáveis do Modelo")
44
+ for var in modelo.variaveis:
45
+ add_body_text(doc, f"• {var}")
46
+
47
+ # Equação
48
+ if modelo.equacao:
49
+ add_subsection_title(doc, "", "Equação do Modelo")
50
+ add_body_text(doc, modelo.equacao)
51
+
52
+ # Coeficientes
53
+ if not modelo.tabelas_coef.empty:
54
+ add_subsection_title(doc, "", "Coeficientes")
55
+ _adicionar_tabela_coeficientes(doc, modelo)
56
+
57
+ # Estatísticas do modelo
58
+ if modelo.modelos_resumos:
59
+ add_subsection_title(doc, "", "Indicadores Estatísticos")
60
+ _adicionar_resumo_modelo(doc, modelo)
61
+
62
+
63
+ def _adicionar_tabela_coeficientes(doc: Document, modelo: ModelData) -> None:
64
+ """Adiciona tabela de coeficientes."""
65
+ df = modelo.tabelas_coef
66
+
67
+ # Preparar dados para tabela
68
+ headers = ["Variável"] + [str(col) for col in df.columns.tolist()]
69
+ dados = [headers]
70
+
71
+ for idx, row in df.iterrows():
72
+ linha = [str(idx)] + [f"{v:.4f}" if isinstance(v, float) else str(v) for v in row.values]
73
+ dados.append(linha)
74
+
75
+ add_simple_table(doc, dados, header_row=True)
76
+
77
+
78
+ def _adicionar_resumo_modelo(doc: Document, modelo: ModelData) -> None:
79
+ """Adiciona resumo estatístico do modelo."""
80
+ resumo = modelo.modelos_resumos
81
+
82
+ indicadores = []
83
+
84
+ if 'r2' in resumo:
85
+ indicadores.append(f"R² = {resumo['r2']:.4f}")
86
+
87
+ if 'r2_ajustado' in resumo:
88
+ indicadores.append(f"R² ajustado = {resumo['r2_ajustado']:.4f}")
89
+
90
+ if 'fc' in resumo:
91
+ indicadores.append(f"Fc = {resumo['fc']:.2f}")
92
+
93
+ if 'sig' in resumo:
94
+ indicadores.append(f"Significância = {resumo['sig']:.4f}")
95
+
96
+ if 'n' in resumo:
97
+ indicadores.append(f"n = {resumo['n']}")
98
+
99
+ if 'k' in resumo:
100
+ indicadores.append(f"k = {resumo['k']}")
101
+
102
+ for ind in indicadores:
103
+ add_body_text(doc, f"• {ind}")
document/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 models.model_data import ModelData
10
+ from document.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 utils.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)
document/formatters/__init__.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ criar_celula_cabecalho_tabela,
24
+ criar_celula_dados_tabela,
25
+ )
26
+ from .image import (
27
+ inserir_imagem_de_documento,
28
+ add_image_placeholder,
29
+ )
30
+ from .section import (
31
+ iniciar_secao_paisagem,
32
+ iniciar_secao_retrato,
33
+ )
34
+
35
+ __all__ = [
36
+ # Parágrafos
37
+ 'aplicar_cor_run',
38
+ 'criar_paragrafo_formatado',
39
+ 'add_body_text',
40
+ 'add_placeholder_text',
41
+ 'add_bullet_text',
42
+ # Títulos
43
+ 'add_heading_custom',
44
+ 'add_section_title',
45
+ 'add_subsection_title',
46
+ 'add_subsubsection_title',
47
+ 'add_subsubsubsection_title',
48
+ # Tabelas
49
+ 'set_cell_shading',
50
+ 'formatar_celula_tabela',
51
+ 'add_simple_table',
52
+ 'configurar_linha_tabela_altura',
53
+ 'criar_celula_cabecalho_tabela',
54
+ 'criar_celula_dados_tabela',
55
+ # Imagens
56
+ 'inserir_imagem_de_documento',
57
+ 'add_image_placeholder',
58
+ # Seções
59
+ 'iniciar_secao_paisagem',
60
+ 'iniciar_secao_retrato',
61
+ ]
document/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
+ )
document/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
document/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
+ )
document/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)
document/formatters/table.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Formatadores de tabelas.
3
+ """
4
+ from docx import Document
5
+ from docx.shared import Pt, Inches, Cm
6
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
7
+ from docx.enum.table import WD_TABLE_ALIGNMENT, WD_CELL_VERTICAL_ALIGNMENT
8
+ from docx.oxml.ns import qn
9
+ from docx.oxml import OxmlElement
10
+ from typing import List, Optional, Union, Dict, Any
11
+
12
+ from .paragraph import aplicar_cor_run
13
+
14
+ WD_ALIGN_VERTICAL = WD_CELL_VERTICAL_ALIGNMENT
15
+
16
+
17
+ def set_cell_shading(cell, color: str) -> None:
18
+ """
19
+ Define a cor de fundo de uma célula.
20
+
21
+ Args:
22
+ cell: Célula da tabela
23
+ color: Cor em hex (ex: "E6E6E6")
24
+ """
25
+ shading = OxmlElement('w:shd')
26
+ shading.set(qn('w:fill'), color)
27
+ cell._tc.get_or_add_tcPr().append(shading)
28
+
29
+
30
+ def set_cell_text_rotation(cell, direcao: str = 'btLr') -> None:
31
+ """
32
+ Define a direção/rotação do texto em uma célula.
33
+
34
+ Args:
35
+ cell: Célula da tabela
36
+ direcao: Direção do texto:
37
+ - 'btLr': bottom-to-top, left-to-right (90° - vertical)
38
+ - 'tbRl': top-to-bottom, right-to-left (270°)
39
+ """
40
+ tcPr = cell._tc.get_or_add_tcPr()
41
+ textDirection = OxmlElement('w:textDirection')
42
+ textDirection.set(qn('w:val'), direcao)
43
+ tcPr.append(textDirection)
44
+
45
+
46
+ def formatar_celula_tabela(cell, texto: str, negrito: bool = False, cor=None,
47
+ shading_color: Optional[str] = None, rotacao: bool = False):
48
+ """
49
+ Formata uma célula de tabela com configurações padrão.
50
+
51
+ Args:
52
+ cell: Célula da tabela
53
+ texto: Texto a inserir
54
+ negrito: Se o texto deve ser negrito
55
+ cor: Cor do texto (tuple ou RGBColor)
56
+ shading_color: Cor de fundo da célula (hex string)
57
+ rotacao: Se True, rotaciona o texto 90° (vertical)
58
+
59
+ Returns:
60
+ Run criado
61
+ """
62
+ p = cell.paragraphs[0]
63
+
64
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
65
+ p.paragraph_format.left_indent = Cm(0)
66
+ p.paragraph_format.right_indent = Cm(0)
67
+ p.paragraph_format.first_line_indent = Cm(0)
68
+ p.paragraph_format.space_before = Pt(0)
69
+ p.paragraph_format.space_after = Pt(0)
70
+
71
+ cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
72
+
73
+ run = p.add_run(texto)
74
+ run.font.size = Pt(9)
75
+ run.font.name = 'Arial'
76
+ run.bold = negrito
77
+
78
+ if cor:
79
+ aplicar_cor_run(run, cor)
80
+
81
+ if shading_color:
82
+ set_cell_shading(cell, shading_color)
83
+
84
+ if rotacao:
85
+ set_cell_text_rotation(cell)
86
+
87
+ return run
88
+
89
+
90
+ def add_simple_table(
91
+ doc: Document,
92
+ dados_tabela: List[List[Union[str, Dict[str, Any]]]],
93
+ header_row: bool = True,
94
+ largura_colunas: Optional[List[int]] = None,
95
+ rotacao_cabecalho: bool = False
96
+ ):
97
+ """
98
+ Adiciona uma tabela simples ao documento.
99
+
100
+ Args:
101
+ doc: Documento
102
+ dados_tabela: Lista de listas. Cada célula pode ser string ou dict {'texto', 'cor'}
103
+ header_row: Se True, primeira linha é cabeçalho (negrito com fundo cinza)
104
+ largura_colunas: Lista de inteiros representando proporção da largura de cada coluna.
105
+ rotacao_cabecalho: Se True, rotaciona o texto dos cabeçalhos 90° (vertical)
106
+
107
+ Returns:
108
+ Tabela criada ou None se dados vazios
109
+ """
110
+ if not dados_tabela or not dados_tabela[0]:
111
+ return None
112
+
113
+ num_colunas = len(dados_tabela[0])
114
+
115
+ if largura_colunas is None:
116
+ largura_colunas = [1] * num_colunas
117
+ elif len(largura_colunas) != num_colunas:
118
+ raise ValueError("largura_colunas deve ter o mesmo número de colunas de dados_tabela")
119
+
120
+ total = sum(largura_colunas)
121
+ proporcoes = [x / total for x in largura_colunas]
122
+
123
+ table = doc.add_table(rows=len(dados_tabela), cols=num_colunas)
124
+ table.style = 'Table Grid'
125
+ table.alignment = WD_TABLE_ALIGNMENT.CENTER
126
+
127
+ largura_total = Inches(6.5)
128
+ larguras_em_colunas = [largura_total * p for p in proporcoes]
129
+
130
+ for i, row_data in enumerate(dados_tabela):
131
+ row = table.rows[i]
132
+ for j, cell_data in enumerate(row_data):
133
+ if j >= len(row.cells):
134
+ continue
135
+
136
+ cell = row.cells[j]
137
+
138
+ if isinstance(cell_data, dict):
139
+ texto = cell_data.get('texto', '')
140
+ cor = cell_data.get('cor')
141
+ else:
142
+ texto = str(cell_data) if cell_data else ""
143
+ cor = None
144
+
145
+ is_header = header_row and i == 0
146
+
147
+ formatar_celula_tabela(
148
+ cell, texto,
149
+ negrito=is_header,
150
+ cor=cor,
151
+ shading_color="E6E6E6" if is_header else None,
152
+ rotacao=is_header and rotacao_cabecalho
153
+ )
154
+
155
+ cell.width = larguras_em_colunas[j]
156
+
157
+ # Ajustar altura do cabeçalho para texto rotacionado
158
+ if header_row and rotacao_cabecalho and dados_tabela:
159
+ # Calcular o maior texto do cabeçalho
160
+ header_texts = []
161
+ for cell_data in dados_tabela[0]:
162
+ if isinstance(cell_data, dict):
163
+ header_texts.append(cell_data.get('texto', ''))
164
+ else:
165
+ header_texts.append(str(cell_data) if cell_data else "")
166
+
167
+ if header_texts:
168
+ max_len = max(len(t) for t in header_texts)
169
+ # Aproximadamente 0.18cm por caractere em Arial 9pt + margem
170
+ altura_cm = max(1.0, max_len * 0.18 + 0.3)
171
+ configurar_linha_tabela_altura(table.rows[0], altura_cm)
172
+
173
+ doc.add_paragraph()
174
+ return table
175
+
176
+
177
+ def configurar_linha_tabela_altura(row, altura_cm: float = 0.6) -> None:
178
+ """
179
+ Configura altura mínima de uma linha de tabela.
180
+
181
+ Args:
182
+ row: Linha da tabela
183
+ altura_cm: Altura em centímetros
184
+ """
185
+ tr = row._tr
186
+ trPr = tr.get_or_add_trPr()
187
+ trHeight = OxmlElement('w:trHeight')
188
+ trHeight.set(qn('w:val'), str(int(Cm(altura_cm).twips)))
189
+ trHeight.set(qn('w:hRule'), 'atLeast')
190
+ trPr.append(trHeight)
191
+
192
+
193
+ def criar_celula_cabecalho_tabela(cell, texto: str, recuo_primeira_linha_cm: float = 1.0) -> None:
194
+ """
195
+ Formata uma célula de cabeçalho de seção na tabela principal.
196
+
197
+ Args:
198
+ cell: Célula da tabela
199
+ texto: Texto do cabeçalho
200
+ recuo_primeira_linha_cm: Recuo da primeira linha
201
+ """
202
+ cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
203
+
204
+ p = cell.paragraphs[0]
205
+ p.paragraph_format.left_indent = Cm(0)
206
+ p.paragraph_format.first_line_indent = Cm(recuo_primeira_linha_cm)
207
+ p.alignment = WD_ALIGN_PARAGRAPH.LEFT
208
+
209
+ run = p.add_run(texto)
210
+ run.bold = True
211
+ run.underline = True
212
+ run.font.size = Pt(11)
213
+ run.font.name = 'Arial'
214
+
215
+ set_cell_shading(cell, "E6E6E6")
216
+
217
+
218
+ def criar_celula_dados_tabela(cell, texto: str, is_label: bool = False) -> None:
219
+ """
220
+ Formata uma célula de dados na tabela principal.
221
+
222
+ Args:
223
+ cell: Célula da tabela
224
+ texto: Texto da célula
225
+ is_label: Se True, formata como rótulo (negrito)
226
+ """
227
+ cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
228
+
229
+ p = cell.paragraphs[0]
230
+ p.paragraph_format.left_indent = Cm(0)
231
+ p.paragraph_format.first_line_indent = Cm(0)
232
+ p.paragraph_format.space_before = Pt(0)
233
+ p.paragraph_format.space_after = Pt(0)
234
+
235
+ run = p.add_run(texto)
236
+ run.bold = is_label
237
+ run.font.size = Pt(9)
238
+ run.font.name = 'Arial'
document/generator.py ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gerador principal de laudos de avaliação.
3
+ """
4
+ import os
5
+ import re
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Dict, List, Optional
9
+
10
+ from docx import Document
11
+ from docx.shared import Pt, Inches, Cm
12
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
13
+ from docx.enum.table import WD_TABLE_ALIGNMENT
14
+
15
+ from .numbering import NumeradorSecoes
16
+ from .formatters import (
17
+ criar_paragrafo_formatado,
18
+ add_body_text,
19
+ add_placeholder_text,
20
+ add_section_title,
21
+ add_subsection_title,
22
+ add_subsubsection_title,
23
+ add_simple_table,
24
+ configurar_linha_tabela_altura,
25
+ criar_celula_cabecalho_tabela,
26
+ criar_celula_dados_tabela,
27
+ iniciar_secao_paisagem,
28
+ iniciar_secao_retrato,
29
+ )
30
+
31
+ from config.settings import TEMPLATES_DIR, TEXTOS_DIR, OUTPUT_DIR
32
+ from models import get_model
33
+ from utils.docx_loader import ler_texto_docx, mesclar_documento_fotos
34
+ from .anexos import (
35
+ gerar_anexo_metodologia,
36
+ gerar_anexo_banco_dados,
37
+ gerar_anexo_planilha,
38
+ gerar_anexo_graficos,
39
+ gerar_anexo_calculo,
40
+ )
41
+
42
+
43
+ class LaudoGenerator:
44
+ """Gerador de laudos de avaliação imobiliária."""
45
+
46
+ def __init__(self):
47
+ self.header_path = TEMPLATES_DIR / "header.docx"
48
+ self.output_dir = OUTPUT_DIR
49
+
50
+ def gerar(self, dados: Dict, motivos_formatados: Dict) -> Optional[Path]:
51
+ """
52
+ Gera um laudo completo.
53
+
54
+ Args:
55
+ dados: Dicionário com todos os dados do laudo
56
+ motivos_formatados: Dicionário com motivos desvalorizantes formatados
57
+
58
+ Returns:
59
+ Path do arquivo gerado ou None se falhar
60
+ """
61
+ try:
62
+ # Criar documento
63
+ doc = None
64
+ if self.header_path.exists():
65
+ try:
66
+ doc = Document(str(self.header_path))
67
+ self._substituir_processo_header(doc, dados.get('numero_processo', ''))
68
+ except Exception:
69
+ # Header inválido ou corrompido, criar documento vazio
70
+ doc = None
71
+
72
+ if doc is None:
73
+ doc = Document()
74
+ self._configurar_margens(doc)
75
+
76
+ # Atualizar dados com motivos formatados
77
+ dados['motivos_alegados'] = motivos_formatados.get('alegados_texto', '')
78
+ dados['motivos_existentes'] = motivos_formatados.get('confirmados_texto', '')
79
+
80
+ # Gerar seções
81
+ self._gerar_cabecalho(doc, dados)
82
+ self._gerar_corpo(doc, dados, motivos_formatados)
83
+ self._gerar_assinatura(doc, dados)
84
+ self._gerar_anexos(doc, dados)
85
+
86
+ # Mesclar documento de registro fotográfico se fornecido
87
+ registro_foto_path = dados.get('registro_fotografico_path')
88
+ if registro_foto_path:
89
+ doc = mesclar_documento_fotos(doc, Path(registro_foto_path))
90
+
91
+ # Salvar
92
+ output_path = self._gerar_nome_arquivo(dados)
93
+ doc.save(str(output_path))
94
+
95
+ return output_path
96
+
97
+ except Exception as e:
98
+ print(f"Erro ao gerar laudo: {e}")
99
+ import traceback
100
+ traceback.print_exc()
101
+ return None
102
+
103
+ def _configurar_margens(self, doc: Document) -> None:
104
+ """Configura margens do documento."""
105
+ for section in doc.sections:
106
+ section.top_margin = Cm(2.5)
107
+ section.bottom_margin = Cm(2.5)
108
+ section.left_margin = Cm(3)
109
+ section.right_margin = Cm(2)
110
+
111
+ def _substituir_processo_header(self, doc: Document, numero_processo: str) -> None:
112
+ """Substitui número do processo no header."""
113
+ if not numero_processo:
114
+ return
115
+
116
+ for section in doc.sections:
117
+ if section.header:
118
+ for paragraph in section.header.paragraphs:
119
+ if "XXX" in paragraph.text:
120
+ new_text = paragraph.text.replace("XXX", numero_processo)
121
+ for run in paragraph.runs:
122
+ run.clear()
123
+ run = paragraph.add_run(new_text)
124
+ run.bold = True
125
+ run.font.name = 'Arial'
126
+ run.font.size = Pt(10)
127
+
128
+ def _gerar_cabecalho(self, doc: Document, dados: Dict) -> None:
129
+ """Gera o cabeçalho/tabela inicial do laudo."""
130
+ # Título
131
+ criar_paragrafo_formatado(
132
+ doc, "LAUDO DE AVALIAÇÃO", negrito=True, sublinhado=True, tamanho=14,
133
+ alinhamento=WD_ALIGN_PARAGRAPH.CENTER
134
+ )
135
+
136
+ # Número do laudo
137
+ criar_paragrafo_formatado(
138
+ doc, dados.get('numero_laudo', 'LA_XXX_XXXX'), tamanho=12,
139
+ alinhamento=WD_ALIGN_PARAGRAPH.CENTER, espaco_depois=12
140
+ )
141
+
142
+ # Tabela de dados
143
+ table = doc.add_table(rows=0, cols=2)
144
+ table.style = 'Table Grid'
145
+ table.alignment = WD_TABLE_ALIGNMENT.CENTER
146
+
147
+ def add_section_header(text):
148
+ row = table.add_row()
149
+ row.height = Cm(1.2)
150
+ cell = row.cells[0]
151
+ cell.merge(row.cells[1])
152
+ criar_celula_cabecalho_tabela(cell, text)
153
+
154
+ def add_row(label, value):
155
+ row = table.add_row()
156
+ configurar_linha_tabela_altura(row, 0.6)
157
+ criar_celula_dados_tabela(row.cells[0], label, is_label=True)
158
+ criar_celula_dados_tabela(row.cells[1], str(value) if value else "")
159
+
160
+ # Definição das seções
161
+ secoes = [
162
+ ("SOLICITAÇÃO", [
163
+ ("Unidade demandante:", 'unidade_demandante'),
164
+ ("Requerimento:", 'dados_requerimento'),
165
+ ("Requerente/Representante legal:", 'representante_legal'),
166
+ ("Motivos desvalorizantes alegados:", 'motivos_alegados'),
167
+ ("Valor venal proposto contribuinte:", 'valor_proposto'),
168
+ ("Documentação Padrão:", 'documentacao_padrao'),
169
+ ("Documentação Específica:", 'documentacao_especifica'),
170
+ ]),
171
+ ("IMÓVEL OBJETO", [
172
+ ("Endereço:", 'endereco_imovel'),
173
+ ("Bairro (Setor/Quarteirão):", 'bairro_imovel'),
174
+ ("Lote Fiscal:", 'lote_imovel'),
175
+ ("Inscrição:", 'inscricao_imovel'),
176
+ ("Finalidade Imóvel:", 'finalidade_imovel'),
177
+ ("Área Territorial Total / Privativa:", 'area_territorial'),
178
+ ("Área construída:", 'construcoes_imovel'),
179
+ ("Valores Venais Guias IPTU:", 'valores_venais'),
180
+ ]),
181
+ ("ANÁLISE VALOR DE MERCADO", [
182
+ ("Unidade responsável:", 'unidade_responsavel'),
183
+ ("Técnico responsável:", 'tecnico_responsavel'),
184
+ ("Método de Avaliação:", 'metodo_avaliacao'),
185
+ ("Modelo de Avaliação utilizado:", 'modelo_avaliacao'),
186
+ ]),
187
+ ("CONCLUSÃO TÉCNICA", [
188
+ ("Valores de Mercado:", 'valores_mercado'),
189
+ ("Referências:", 'datas_referencia'),
190
+ ("Motivos desvalorizantes existentes:", 'motivos_existentes'),
191
+ ]),
192
+ ]
193
+
194
+ for header, rows in secoes:
195
+ add_section_header(header)
196
+ for label, key in rows:
197
+ add_row(label, dados.get(key, ''))
198
+
199
+ for row in table.rows:
200
+ row.cells[0].width = Inches(2.5)
201
+ row.cells[1].width = Inches(4.0)
202
+
203
+ def _gerar_corpo(self, doc: Document, dados: Dict, motivos_formatados: Dict) -> None:
204
+ """Gera o corpo principal do laudo."""
205
+ num = NumeradorSecoes()
206
+
207
+ # 1. SOLICITAÇÃO
208
+ add_section_title(doc, num.secao(), "SOLICITAÇÃO")
209
+ add_subsection_title(doc, num.subsecao(), "CONSIDERAÇÕES INICIAIS")
210
+
211
+ # Carregar texto de considerações iniciais do template
212
+ texto_consideracoes = ler_texto_docx(TEXTOS_DIR / "CONSIDERACOES_INICIAIS.docx")
213
+ if texto_consideracoes:
214
+ for paragrafo in texto_consideracoes.split("\n\n"):
215
+ if paragrafo.strip():
216
+ add_body_text(doc, paragrafo.strip())
217
+ else:
218
+ add_placeholder_text(doc, "[Inserir considerações iniciais]")
219
+
220
+ add_subsection_title(doc, num.subsecao(), "DOCUMENTAÇÃO APRESENTADA")
221
+ add_placeholder_text(doc)
222
+
223
+ # 2. IMÓVEL OBJETO
224
+ add_section_title(doc, num.secao(), "IMÓVEL OBJETO")
225
+ add_subsection_title(doc, num.subsecao(), "DESCRIÇÃO DO IMÓVEL")
226
+ add_placeholder_text(doc)
227
+
228
+ add_subsection_title(doc, num.subsecao(), "CARACTERÍSTICAS PARTICULARMENTE DESVALORIZANTES")
229
+
230
+ secoes_motivos = motivos_formatados.get('secoes', [])
231
+ if not secoes_motivos:
232
+ add_placeholder_text(doc)
233
+ else:
234
+ for secao in secoes_motivos:
235
+ add_subsubsection_title(doc, num.subsubsecao(), secao['titulo'])
236
+ p = doc.add_paragraph()
237
+ p.add_run(f"Status: {secao['status']}").italic = True
238
+ p.paragraph_format.left_indent = Inches(0.5)
239
+ add_placeholder_text(doc)
240
+
241
+ # 3. ANÁLISE DE MERCADO
242
+ add_section_title(doc, num.secao(), "ANÁLISE VALOR DE MERCADO")
243
+ add_subsection_title(doc, num.subsecao(), "DIAGNÓSTICO DE MERCADO")
244
+ add_placeholder_text(doc, "[Inserir diagnóstico de mercado]")
245
+
246
+ add_subsection_title(doc, num.subsecao(), "METODOLOGIA DE AVALIAÇÃO")
247
+ # Tentar gerar metodologia a partir do modelo
248
+ modelo_nome = dados.get('modelo_avaliacao', '')
249
+ modelo = get_model(modelo_nome) if modelo_nome else None
250
+ if modelo:
251
+ gerar_anexo_metodologia(doc, modelo)
252
+ else:
253
+ add_placeholder_text(doc, "[Inserir metodologia - selecione um modelo]")
254
+
255
+ # 4. ESPECIFICAÇÃO
256
+ add_section_title(doc, num.secao(), "ESPECIFICAÇÃO DA AVALIAÇÃO")
257
+ add_body_text(doc, f"Método: {dados.get('metodo_avaliacao', '')}")
258
+ add_placeholder_text(doc)
259
+
260
+ # 5. CONCLUSÃO
261
+ add_section_title(doc, num.secao(), "CONCLUSÃO TÉCNICA")
262
+ add_body_text(doc, "Em face do acima exposto, esta EAV concluiu que:")
263
+
264
+ add_subsection_title(doc, num.subsecao(), "SOBRE AS CARACTERÍSTICAS PARTICULARMENTE DESVALORIZANTES")
265
+ if not secoes_motivos:
266
+ add_body_text(doc, "- não foram identificados motivos desvalorizantes;")
267
+ else:
268
+ for i, secao in enumerate(secoes_motivos, 1):
269
+ if secao['confirmado']:
270
+ texto = f"- {secao['titulo'].lower()}, conforme item 2.2.{i};"
271
+ else:
272
+ texto = f"- quanto ao motivo \"{secao['titulo'].lower()}\", não foi confirmado;"
273
+ add_body_text(doc, texto)
274
+
275
+ add_subsection_title(doc, num.subsecao(), "SOBRE O VALOR DE MERCADO")
276
+ valores_mercado_lista = dados.get('valores_mercado_lista', [])
277
+ if valores_mercado_lista:
278
+ self._gerar_tabela_valores_mercado(doc, valores_mercado_lista)
279
+ else:
280
+ add_placeholder_text(doc)
281
+
282
+ add_subsection_title(doc, num.subsecao(), "OBSERVAÇÕES COMPLEMENTARES")
283
+ add_placeholder_text(doc, "[Inserir observações]")
284
+
285
+ # 6. ANEXOS (lista)
286
+ add_section_title(doc, num.secao(), "ANEXOS")
287
+ for anexo in ["I. BANCO DE DADOS", "II. PLANILHA DE CÁLCULO", "III. GRÁFICOS",
288
+ "IV. CÁLCULO DO VALOR", "V. DADOS DO IMÓVEL", "VI. REGISTRO FOTOGRÁFICO"]:
289
+ add_body_text(doc, anexo)
290
+
291
+ def _gerar_tabela_valores_mercado(self, doc: Document, valores_lista: List[Dict]) -> None:
292
+ """Gera tabela de valores de mercado."""
293
+ dados_tabela = [
294
+ ["Exercício IPTU", "Ano Base", "VTOTAL Imóvel"]
295
+ ]
296
+
297
+ for item in valores_lista:
298
+ ano = item.get('ano', '')
299
+ valor = item.get('valor', '')
300
+ valor_extenso = item.get('valor_extenso', '')
301
+
302
+ if ano and valor:
303
+ exercicio = str(ano)
304
+ ano_base = str(int(ano) - 1) if ano else ''
305
+
306
+ if valor_extenso:
307
+ vtotal = f"R$ {valor} ({valor_extenso})"
308
+ else:
309
+ vtotal = f"R$ {valor}"
310
+
311
+ dados_tabela.append([exercicio, ano_base, vtotal])
312
+
313
+ if len(dados_tabela) > 1:
314
+ add_simple_table(doc, dados_tabela, header_row=True, largura_colunas=[1, 1, 8])
315
+
316
+ def _gerar_assinatura(self, doc: Document, dados: Dict) -> None:
317
+ """Gera a seção de assinatura."""
318
+ doc.add_paragraph()
319
+
320
+ data_laudo = dados.get('data_laudo', datetime.now().strftime('%d de %B de %Y'))
321
+ criar_paragrafo_formatado(
322
+ doc, f"Porto Alegre, {data_laudo}.",
323
+ tamanho=11, alinhamento=WD_ALIGN_PARAGRAPH.RIGHT
324
+ )
325
+
326
+ doc.add_paragraph()
327
+ doc.add_paragraph()
328
+
329
+ p = doc.add_paragraph()
330
+ p.paragraph_format.first_line_indent = Inches(0)
331
+ p.paragraph_format.left_indent = Inches(0)
332
+ p.add_run("_" * 40)
333
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
334
+
335
+ if dados.get('tecnico_responsavel'):
336
+ criar_paragrafo_formatado(
337
+ doc, dados.get('tecnico_responsavel'), tamanho=11,
338
+ alinhamento=WD_ALIGN_PARAGRAPH.CENTER
339
+ )
340
+
341
+ p = doc.add_paragraph()
342
+ p.paragraph_format.first_line_indent = Inches(0)
343
+ p.paragraph_format.left_indent = Inches(0)
344
+ run = p.add_run("Equipe de Avaliações\nDAI-RM-SMF")
345
+ run.bold = True
346
+ run.font.size = Pt(11)
347
+ run.font.name = 'Arial'
348
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
349
+
350
+ def _criar_titulo_anexo(self, doc: Document, titulo: str) -> None:
351
+ """Cria título de anexo com formatação padrão (tamanho 10, centralizado)."""
352
+ criar_paragrafo_formatado(
353
+ doc, titulo, negrito=True, sublinhado=True, tamanho=10,
354
+ espaco_antes=18, espaco_depois=12, alinhamento=WD_ALIGN_PARAGRAPH.CENTER
355
+ )
356
+
357
+ def _gerar_anexos(self, doc: Document, dados: Dict) -> None:
358
+ """Gera as páginas de anexos a partir dos dados do modelo."""
359
+ # Tentar carregar o modelo selecionado
360
+ modelo_nome = dados.get('modelo_avaliacao', '')
361
+ modelo = get_model(modelo_nome) if modelo_nome else None
362
+
363
+ # ANEXO I - BANCO DE DADOS (orientação paisagem)
364
+ iniciar_secao_paisagem(doc)
365
+ self._criar_titulo_anexo(doc, "ANEXO I – BANCO DE DADOS")
366
+ gerar_anexo_banco_dados(doc, modelo)
367
+
368
+ # ANEXO II - PLANILHA DE CÁLCULO (volta para retrato)
369
+ iniciar_secao_retrato(doc)
370
+ self._criar_titulo_anexo(doc, "ANEXO II – PLANILHA DE CÁLCULO E RESULTADOS ESTATÍSTICOS")
371
+ gerar_anexo_planilha(doc, modelo)
372
+
373
+ # ANEXO III - GRÁFICOS
374
+ doc.add_page_break()
375
+ self._criar_titulo_anexo(doc, "ANEXO III – GRÁFICOS")
376
+ gerar_anexo_graficos(doc, modelo)
377
+
378
+ # ANEXO IV - CÁLCULO DO VALOR
379
+ doc.add_page_break()
380
+ self._criar_titulo_anexo(doc, "ANEXO IV – CÁLCULO DO VALOR")
381
+ gerar_anexo_calculo(doc, modelo)
382
+
383
+ # ANEXO V - DADOS E LOCALIZAÇÃO (placeholder manual)
384
+ doc.add_page_break()
385
+ self._criar_titulo_anexo(doc, "ANEXO V – DADOS E LOCALIZAÇÃO DO IMÓVEL")
386
+ add_placeholder_text(doc, "[Inserir dados e localização do imóvel]")
387
+
388
+ # ANEXO VI - REGISTRO FOTOGRÁFICO
389
+ # Se houver documento de fotos, não gerar título nem placeholder
390
+ # (o documento já contém o título do anexo)
391
+ registro_foto_path = dados.get('registro_fotografico_path')
392
+ if not registro_foto_path:
393
+ doc.add_page_break()
394
+ self._criar_titulo_anexo(doc, "ANEXO VI – REGISTRO FOTOGRÁFICO")
395
+ add_placeholder_text(doc, "[Inserir registro fotográfico]")
396
+
397
+ def _gerar_nome_arquivo(self, dados: Dict) -> Path:
398
+ """Gera nome do arquivo de saída."""
399
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
400
+ inscricao = dados.get('inscricao_imovel', 'sem_inscricao')
401
+ numero = dados.get('numero_laudo', 'sem_numero')
402
+
403
+ inscricao_safe = re.sub(r'[^\w\-]', '_', inscricao)
404
+ numero_safe = re.sub(r'[^\w\-]', '_', numero)
405
+
406
+ filename = f"Laudo_{numero_safe}_{inscricao_safe}_{timestamp}.docx"
407
+ return self.output_dir / filename
document/numbering.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Numeração automática hierárquica de seções.
3
+ """
4
+
5
+
6
+ class NumeradorSecoes:
7
+ """
8
+ Gerencia numeração automática hierárquica de seções.
9
+
10
+ Uso:
11
+ num = NumeradorSecoes()
12
+ num.secao() # "1"
13
+ num.subsecao() # "1.1"
14
+ num.subsecao() # "1.2"
15
+ num.secao() # "2"
16
+ num.subsubsecao() # "2.0.1" (se não chamou subsecao antes)
17
+ """
18
+
19
+ def __init__(self, secao_inicial: int = 0):
20
+ """
21
+ Args:
22
+ secao_inicial: Número inicial para seções (0 = começa em 1)
23
+ """
24
+ self.contadores = [secao_inicial, 0, 0, 0]
25
+
26
+ def _reset_niveis_abaixo(self, nivel: int) -> None:
27
+ """Reseta contadores de níveis abaixo do especificado."""
28
+ for i in range(nivel + 1, len(self.contadores)):
29
+ self.contadores[i] = 0
30
+
31
+ def secao(self) -> str:
32
+ """Incrementa e retorna número da seção (ex: '1')."""
33
+ self.contadores[0] += 1
34
+ self._reset_niveis_abaixo(0)
35
+ return str(self.contadores[0])
36
+
37
+ def subsecao(self) -> str:
38
+ """Incrementa e retorna número da subseção (ex: '1.1')."""
39
+ self.contadores[1] += 1
40
+ self._reset_niveis_abaixo(1)
41
+ return f"{self.contadores[0]}.{self.contadores[1]}"
42
+
43
+ def subsubsecao(self) -> str:
44
+ """Incrementa e retorna número da sub-subseção (ex: '1.1.1')."""
45
+ self.contadores[2] += 1
46
+ self._reset_niveis_abaixo(2)
47
+ return f"{self.contadores[0]}.{self.contadores[1]}.{self.contadores[2]}"
48
+
49
+ def subsubsubsecao(self) -> str:
50
+ """Incrementa e retorna número da sub-sub-subseção (ex: '1.1.1.1')."""
51
+ self.contadores[3] += 1
52
+ return f"{self.contadores[0]}.{self.contadores[1]}.{self.contadores[2]}.{self.contadores[3]}"
53
+
54
+ def numero_atual(self, nivel: int = 0) -> str:
55
+ """Retorna o número atual de um nível sem incrementar."""
56
+ if nivel == 0:
57
+ return str(self.contadores[0])
58
+ elif nivel == 1:
59
+ return f"{self.contadores[0]}.{self.contadores[1]}"
60
+ elif nivel == 2:
61
+ return f"{self.contadores[0]}.{self.contadores[1]}.{self.contadores[2]}"
62
+ else:
63
+ return f"{self.contadores[0]}.{self.contadores[1]}.{self.contadores[2]}.{self.contadores[3]}"
64
+
65
+ def definir_secao(self, numero: int) -> None:
66
+ """Define manualmente o número da seção atual."""
67
+ self.contadores[0] = numero
68
+ self._reset_niveis_abaixo(0)
document/sections/__init__.py ADDED
File without changes
extractors/__init__.py ADDED
File without changes
extractors/pdf_extractor.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Extrator de dados do PDF do SIAT.
3
+ """
4
+ import re
5
+ from typing import Optional, Dict
6
+ from pypdf import PdfReader
7
+
8
+
9
+ def extrair_dados_pdf(pdf_file) -> Optional[Dict]:
10
+ """Extrai dados do PDF do SIAT."""
11
+ if pdf_file is None:
12
+ return None
13
+
14
+ try:
15
+ reader = PdfReader(pdf_file)
16
+ text = ""
17
+ for page in reader.pages:
18
+ page_text = page.extract_text()
19
+ if page_text:
20
+ text += page_text
21
+
22
+ dados = {
23
+ "inscricao_imovel": None,
24
+ "endereco_imovel": None,
25
+ "bairro_imovel": None,
26
+ "setor_imovel": None,
27
+ "quarteirao_imovel": None,
28
+ "lote_imovel": None,
29
+ "finalidade_imovel": None,
30
+ "area_territorial": None,
31
+ "construcoes_imovel": "Sem construções cadastradas",
32
+ "valores_venais": None
33
+ }
34
+
35
+ # INSCRIÇÃO
36
+ m = re.search(r'(\d{0,10})Inscriç', text)
37
+ if m:
38
+ dados["inscricao_imovel"] = m.group(1)
39
+
40
+ # BAIRRO
41
+ pattern_bairro = re.compile(r"Quadra \/ Lote(.*?)Bairro", re.DOTALL)
42
+ bairros = [b.strip() for b in pattern_bairro.findall(text)]
43
+ if bairros:
44
+ dados["bairro_imovel"] = list(set(bairros))[-1]
45
+
46
+ # ENDEREÇO
47
+ pattern_endereco = re.compile(
48
+ r"([^\n]*? - Porto Alegre/RS - \d{5}-\d{3})",
49
+ re.IGNORECASE
50
+ )
51
+ matches_end = pattern_endereco.findall(text)
52
+ if matches_end:
53
+ endereco_completo = matches_end[-1]
54
+ if dados["bairro_imovel"]:
55
+ dados["endereco_imovel"] = endereco_completo.split(f" - {dados['bairro_imovel']}")[0].strip()
56
+ else:
57
+ dados["endereco_imovel"] = re.split(r"\s-\s(?:PORTO ALEGRE|Porto Alegre)/RS", endereco_completo)[0].strip()
58
+
59
+ # SETOR
60
+ pattern_setor = re.compile(r"Ratei(.*?)Setor", re.DOTALL)
61
+ try:
62
+ setores = [re.findall(r"(?<!\d)\d{5}(?![,\d])", m)[-1] for m in pattern_setor.findall(text)]
63
+ if setores:
64
+ dados["setor_imovel"] = list(set(setores))[-1]
65
+ except (IndexError, Exception):
66
+ pass
67
+
68
+ # QUARTEIRÃO
69
+ pattern_quadra = re.compile(r"Setor(.*?)Quadra", re.DOTALL)
70
+ try:
71
+ quadras = [re.findall(r"(?<!\d)\d{4}(?![,\d])", m)[-1] for m in pattern_quadra.findall(text)]
72
+ if quadras:
73
+ dados["quarteirao_imovel"] = list(set(quadras))[-1]
74
+ except (IndexError, Exception):
75
+ pass
76
+
77
+ # LOTE
78
+ pattern_lote = re.compile(r"Lote Fiscal(.*?)Quarteir", re.DOTALL)
79
+ try:
80
+ lotes = [re.findall(r"(\d{1}\.\d{8}\.\d{4})", m)[-1] for m in pattern_lote.findall(text)]
81
+ if lotes:
82
+ dados["lote_imovel"] = list(set(lotes))[-1]
83
+ except (IndexError, Exception):
84
+ pass
85
+
86
+ # FINALIDADE
87
+ pattern_finalidade = re.compile(r"Uso(.*?)Finalidad", re.DOTALL)
88
+ fins = [f.strip() for f in pattern_finalidade.findall(text)]
89
+ if fins:
90
+ dados["finalidade_imovel"] = list(set(fins))[-1]
91
+
92
+ # VALOR VENAL
93
+ pattern_valor = re.compile(r'Tributação(.*?)Planta', re.DOTALL)
94
+ matches_valor = pattern_valor.findall(text)
95
+ valores_ano = []
96
+ for m in matches_valor:
97
+ try:
98
+ ano = re.search(r'(\d{4})', m).group(1)
99
+ valores = re.findall(r'(\d{1,3}(?:\.\d{3})*,\d{2})', m)
100
+ valor_venal = valores[2]
101
+ valores_ano.append(f"{ano}: R$ {valor_venal}")
102
+ except:
103
+ continue
104
+ if valores_ano:
105
+ dados["valores_venais"] = " | ".join(valores_ano)
106
+
107
+ # ÁREA TERRITORIAL
108
+ match_area_real = re.search(r"Área real total\s+(\d{1,5},\d{2})\s*m²", text, re.IGNORECASE)
109
+ area_real = match_area_real.group(1) if match_area_real else "0,00"
110
+
111
+ matches_corr = re.findall(r'territorial corrigida(.*?)Última', text, re.DOTALL | re.IGNORECASE)
112
+ corrigidas = []
113
+ for m in matches_corr:
114
+ valores = re.findall(r"(\d{1,5},\d{2})", m)
115
+ if valores:
116
+ corrigidas.extend(valores)
117
+ corrigidas_unicas = list(set(corrigidas))
118
+ area_corrigida = corrigidas_unicas[-1] if corrigidas_unicas else "0,00"
119
+
120
+ dados["area_territorial"] = f"{area_real} m² / {area_corrigida} m²"
121
+
122
+ # ÁREA CONSTRUÍDA
123
+ pattern_area_construida = re.compile(
124
+ r"Área Construída(.*?)Documento de Origem",
125
+ re.DOTALL | re.IGNORECASE
126
+ )
127
+ match_area = pattern_area_construida.search(text)
128
+ construcoes = []
129
+ if match_area:
130
+ bloco = match_area.group(1)
131
+ pattern_const = re.compile(
132
+ r"^\s*\d+\s+(?P<area>\d{1,4},\d{2})\s+(?P<ano>\d{4})\s+\d{4}\s+(?P<tipo>.*?)\s+\d{1,2}-\d{1,2}%",
133
+ re.MULTILINE
134
+ )
135
+ for m in pattern_const.finditer(bloco):
136
+ area = m.group("area")
137
+ ano = m.group("ano")
138
+ tipo = m.group("tipo").strip()
139
+ construcoes.append(f"{area} m² / {ano} / {tipo}")
140
+
141
+ if construcoes:
142
+ dados["construcoes_imovel"] = "\n".join(construcoes)
143
+
144
+ return dados
145
+
146
+ except Exception as e:
147
+ print(f"Erro ao extrair dados do PDF: {e}")
148
+ return None
models/__init__.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sistema de Modelos de Avaliação.
3
+
4
+ Uso:
5
+ from models import list_available_models, get_model
6
+
7
+ # Listar modelos disponíveis
8
+ modelos = list_available_models()
9
+ print(modelos) # ['ABC', 'MOD_V_TCOND_Z4_008C', ...]
10
+
11
+ # Carregar um modelo
12
+ modelo = get_model('ABC')
13
+ print(modelo.r2)
14
+ print(modelo.variaveis)
15
+ """
16
+ from .model_data import ModelData
17
+ from .registry import (
18
+ ModelRegistry,
19
+ get_registry,
20
+ list_available_models,
21
+ get_model,
22
+ )
23
+ from .model_loader import load_model, load_model_by_name
24
+
25
+ __all__ = [
26
+ 'ModelData',
27
+ 'ModelRegistry',
28
+ 'get_registry',
29
+ 'list_available_models',
30
+ 'get_model',
31
+ 'load_model',
32
+ 'load_model_by_name',
33
+ ]
models/files/ABC/metodologia.docx ADDED
Binary file (26.8 kB). View file
 
models/files/ABC/modelo.dai ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b72078033c7ea67662dde85f737c3cd4033b749bfb50b57ff3b090971a966b1c
3
+ size 691301
models/model_data.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ graf_model: str = ""
36
+
37
+ @property
38
+ def r2(self) -> Optional[float]:
39
+ """Retorna o coeficiente de determinação R²."""
40
+ return self.modelos_resumos.get('r2')
41
+
42
+ @property
43
+ def r2_ajustado(self) -> Optional[float]:
44
+ """Retorna o R² ajustado."""
45
+ return self.modelos_resumos.get('r2_ajustado')
46
+
47
+ @property
48
+ def equacao(self) -> Optional[str]:
49
+ """Retorna a equação do modelo."""
50
+ return self.modelos_resumos.get('equacao')
51
+
52
+ @property
53
+ def variaveis(self) -> List[str]:
54
+ """Retorna lista de variáveis do modelo."""
55
+ if self.tabelas_coef.empty:
56
+ return []
57
+ return [v for v in self.tabelas_coef.index.tolist() if v != 'const']
58
+
59
+ @property
60
+ def n_amostras(self) -> int:
61
+ """Retorna número de amostras no banco de dados."""
62
+ return self.modelos_resumos.get('n', 0)
63
+
64
+ @property
65
+ def n_variaveis(self) -> int:
66
+ """Retorna número de variáveis do modelo."""
67
+ return self.modelos_resumos.get('k', 0)
68
+
69
+ def tem_template(self) -> bool:
70
+ """Verifica se existe template de metodologia."""
71
+ template_path = self.path / "metodologia.docx"
72
+ return template_path.exists()
73
+
74
+ def get_template_path(self) -> Optional[Path]:
75
+ """Retorna caminho do template de metodologia."""
76
+ template_path = self.path / "metodologia.docx"
77
+ return template_path if template_path.exists() else None
models/model_loader.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Carregador de modelos de avaliação.
3
+ Carrega arquivos .dai via joblib.
4
+ """
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ import joblib
8
+
9
+ from .model_data import ModelData
10
+
11
+
12
+ def load_model(model_path: Path) -> Optional[ModelData]:
13
+ """
14
+ Carrega um modelo de avaliação a partir de uma pasta.
15
+
16
+ Args:
17
+ model_path: Caminho para a pasta do modelo (contendo modelo.dai)
18
+
19
+ Returns:
20
+ ModelData com os dados carregados, ou None se falhar
21
+ """
22
+ dai_path = model_path / "modelo.dai"
23
+
24
+ if not dai_path.exists():
25
+ return None
26
+
27
+ try:
28
+ data = joblib.load(dai_path)
29
+ except Exception as e:
30
+ print(f"Erro ao carregar {dai_path}: {e}")
31
+ return None
32
+
33
+ model = ModelData(
34
+ nome=model_path.name,
35
+ path=model_path,
36
+ xy_preview=data.get('Xy_preview_out_coords'),
37
+ top_x_esc=data.get('top_X_esc'),
38
+ top_y_esc=data.get('top_y_esc'),
39
+ estatisticas=data.get('estatisticas'),
40
+ tabelas_coef=data.get('tabelas_coef'),
41
+ tabelas_obs_calc=data.get('tabelas_obs_calc'),
42
+ modelos_resumos=data.get('modelos_resumos', {}),
43
+ modelos_sm=data.get('modelos_sm'),
44
+ formatted_top_transformation_info=data.get('formatted_top_transformation_info', []),
45
+ graf_model=data.get('graf_model', ''),
46
+ )
47
+
48
+ return model
49
+
50
+
51
+ def load_model_by_name(name: str, models_dir: Path) -> Optional[ModelData]:
52
+ """
53
+ Carrega um modelo pelo nome.
54
+
55
+ Args:
56
+ name: Nome do modelo (nome da pasta)
57
+ models_dir: Diretório raiz dos modelos
58
+
59
+ Returns:
60
+ ModelData com os dados carregados, ou None se não encontrar
61
+ """
62
+ model_path = models_dir / name
63
+ return load_model(model_path)
models/registry.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Registro de modelos de avaliação.
3
+ Descobre automaticamente modelos disponíveis na pasta models/files/.
4
+ """
5
+ from pathlib import Path
6
+ from typing import List, Optional, Dict
7
+
8
+ from .model_data import ModelData
9
+ from .model_loader import load_model
10
+
11
+
12
+ class ModelRegistry:
13
+ """Registro centralizado de modelos de avaliação."""
14
+
15
+ def __init__(self, models_dir: Path):
16
+ """
17
+ Inicializa o registro.
18
+
19
+ Args:
20
+ models_dir: Diretório contendo as pastas dos modelos
21
+ """
22
+ self.models_dir = models_dir
23
+ self._cache: Dict[str, ModelData] = {}
24
+
25
+ def discover_models(self) -> List[str]:
26
+ """
27
+ Descobre todos os modelos disponíveis.
28
+
29
+ Returns:
30
+ Lista de nomes de modelos (nomes das pastas que contêm modelo.dai)
31
+ """
32
+ models = []
33
+
34
+ if not self.models_dir.exists():
35
+ return models
36
+
37
+ for path in sorted(self.models_dir.iterdir()):
38
+ if path.is_dir() and not path.name.startswith('_'):
39
+ dai_path = path / "modelo.dai"
40
+ if dai_path.exists():
41
+ models.append(path.name)
42
+
43
+ return models
44
+
45
+ def get_model(self, name: str) -> Optional[ModelData]:
46
+ """
47
+ Obtém um modelo pelo nome.
48
+
49
+ Args:
50
+ name: Nome do modelo
51
+
52
+ Returns:
53
+ ModelData ou None se não encontrar
54
+ """
55
+ if name in self._cache:
56
+ return self._cache[name]
57
+
58
+ model_path = self.models_dir / name
59
+ model = load_model(model_path)
60
+
61
+ if model:
62
+ self._cache[name] = model
63
+
64
+ return model
65
+
66
+ def list_models(self) -> List[str]:
67
+ """
68
+ Lista todos os modelos disponíveis.
69
+ Alias para discover_models() para compatibilidade.
70
+ """
71
+ return self.discover_models()
72
+
73
+ def clear_cache(self) -> None:
74
+ """Limpa o cache de modelos carregados."""
75
+ self._cache.clear()
76
+
77
+
78
+ # Instância global do registro
79
+ _registry: Optional[ModelRegistry] = None
80
+
81
+
82
+ def get_registry(models_dir: Optional[Path] = None) -> ModelRegistry:
83
+ """
84
+ Obtém a instância global do registro.
85
+
86
+ Args:
87
+ models_dir: Diretório dos modelos (usa padrão se não fornecido)
88
+
89
+ Returns:
90
+ Instância do ModelRegistry
91
+ """
92
+ global _registry
93
+
94
+ if _registry is None:
95
+ if models_dir is None:
96
+ # Caminho padrão relativo ao projeto
97
+ models_dir = Path(__file__).parent / "files"
98
+ _registry = ModelRegistry(models_dir)
99
+
100
+ return _registry
101
+
102
+
103
+ def list_available_models() -> List[str]:
104
+ """
105
+ Lista todos os modelos disponíveis.
106
+ Função de conveniência para uso direto.
107
+ """
108
+ return get_registry().list_models()
109
+
110
+
111
+ def get_model(name: str) -> Optional[ModelData]:
112
+ """
113
+ Obtém um modelo pelo nome.
114
+ Função de conveniência para uso direto.
115
+ """
116
+ return get_registry().get_model(name)
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio
2
+ python-docx
3
+ Pillow
4
+ docxcompose
5
+ pypdf
6
+ joblib
7
+ pandas
8
+ statsmodels
9
+ matplotlib
10
+ scipy
ui/__init__.py ADDED
File without changes
ui/app_builder.py ADDED
@@ -0,0 +1,613 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Construtor da interface Gradio com campos dinâmicos.
3
+ """
4
+ import gradio as gr
5
+ from datetime import datetime
6
+ from typing import List, Dict, Any
7
+
8
+ from config.constants import (
9
+ UNIDADES_DEMANDANTES,
10
+ TIPOS_REQUERIMENTO,
11
+ UNIDADES_RESPONSAVEIS,
12
+ TECNICOS_RESPONSAVEIS,
13
+ METODOS_AVALIACAO,
14
+ MAX_MOTIVOS_DESVALORIZANTES,
15
+ MAX_VALORES_MERCADO,
16
+ MAX_DOCUMENTACAO_PADRAO,
17
+ MAX_DOCUMENTACAO_ESPECIFICA,
18
+ )
19
+ from models import list_available_models
20
+ from document import LaudoGenerator
21
+ from utils.formatters import (
22
+ formatar_valor_monetario,
23
+ aplicar_mascara_monetaria,
24
+ formatar_lista_para_documento,
25
+ formatar_motivos_desvalorizantes,
26
+ formatar_valores_mercado_para_documento,
27
+ )
28
+ from extractors.pdf_extractor import extrair_dados_pdf
29
+
30
+
31
+ # ============================================================================
32
+ # CALLBACKS
33
+ # ============================================================================
34
+
35
+ def toggle_custom_field(dropdown_value):
36
+ """Callback para mostrar/esconder campo customizado."""
37
+ return gr.update(visible=(dropdown_value == "Outros"))
38
+
39
+
40
+ def adicionar_campo(count, max_count):
41
+ """Adiciona um campo dinâmico."""
42
+ new = min(count + 1, max_count)
43
+ return [new] + [gr.update(visible=(i < new)) for i in range(max_count)]
44
+
45
+
46
+ def remover_campo(count, max_count):
47
+ """Remove um campo dinâmico."""
48
+ new = max(count - 1, 1)
49
+ return [new] + [gr.update(visible=(i < new)) for i in range(max_count)]
50
+
51
+
52
+ def processar_upload_pdf(pdf_file):
53
+ """Processa upload de PDF e extrai dados."""
54
+ dados = extrair_dados_pdf(pdf_file)
55
+ if dados:
56
+ setor = dados.get('setor_imovel', '')
57
+ quarteirao = dados.get('quarteirao_imovel', '')
58
+ setor_quarteirao = f"{setor} / {quarteirao}" if setor and quarteirao else setor or quarteirao
59
+
60
+ return [
61
+ dados.get('inscricao_imovel', ''),
62
+ dados.get('endereco_imovel', ''),
63
+ dados.get('bairro_imovel', ''),
64
+ setor_quarteirao,
65
+ dados.get('lote_imovel', ''),
66
+ dados.get('finalidade_imovel', ''),
67
+ dados.get('area_territorial', ''),
68
+ dados.get('construcoes_imovel', ''),
69
+ dados.get('valores_venais', ''),
70
+ ]
71
+ return [''] * 9
72
+
73
+
74
+ def gerar_campos_valores_mercado(ano_ini, ano_fim):
75
+ """Gera campos de valores de mercado para intervalo de anos."""
76
+ try:
77
+ ano_ini, ano_fim = int(ano_ini), int(ano_fim)
78
+ except:
79
+ return [gr.update(visible=False), gr.update(value=""), gr.update(value="")] * MAX_VALORES_MERCADO + [gr.update(value="")]
80
+
81
+ if ano_fim < ano_ini:
82
+ ano_ini, ano_fim = ano_fim, ano_ini
83
+ anos = list(range(ano_ini, ano_fim + 1))
84
+
85
+ updates = []
86
+ for i in range(MAX_VALORES_MERCADO):
87
+ if i < min(len(anos), MAX_VALORES_MERCADO):
88
+ updates.extend([gr.update(visible=True), gr.update(value=str(anos[i])), gr.update(value="")])
89
+ else:
90
+ updates.extend([gr.update(visible=False), gr.update(value=""), gr.update(value="")])
91
+
92
+ referencias_texto = ", ".join([f"Dezembro/{ano-1}" for ano in anos])
93
+ updates.append(gr.update(value=referencias_texto))
94
+
95
+ return updates
96
+
97
+
98
+ def limpar_campos_valores_mercado():
99
+ """Limpa todos os campos de valores de mercado."""
100
+ return [gr.update(visible=False), gr.update(value=""), gr.update(value="")] * MAX_VALORES_MERCADO + [gr.update(value="")]
101
+
102
+
103
+ def criar_lista_valores_mercado(anos: List[str], valores: List[str]) -> List[dict]:
104
+ """Cria lista estruturada de valores de mercado."""
105
+ lista = []
106
+ for ano, valor in zip(anos, valores):
107
+ ano_str = str(ano).strip() if ano else ''
108
+ valor_str = str(valor).strip() if valor else ''
109
+
110
+ if ano_str and valor_str:
111
+ try:
112
+ valor_formatado, valor_extenso = formatar_valor_monetario(valor_str)
113
+ lista.append({
114
+ 'ano': ano_str,
115
+ 'valor': valor_formatado,
116
+ 'valor_extenso': valor_extenso or ''
117
+ })
118
+ except Exception:
119
+ lista.append({
120
+ 'ano': ano_str,
121
+ 'valor': valor_str,
122
+ 'valor_extenso': ''
123
+ })
124
+ return lista
125
+
126
+
127
+ def _get_data_atual_formatada():
128
+ """Retorna a data atual formatada em português."""
129
+ meses = {
130
+ "January": "janeiro", "February": "fevereiro", "March": "março",
131
+ "April": "abril", "May": "maio", "June": "junho",
132
+ "July": "julho", "August": "agosto", "September": "setembro",
133
+ "October": "outubro", "November": "novembro", "December": "dezembro"
134
+ }
135
+ data = datetime.now().strftime("%d de %B de %Y")
136
+ for en, pt in meses.items():
137
+ data = data.replace(en, pt)
138
+ return data
139
+
140
+
141
+ # ============================================================================
142
+ # GERAÇÃO DO DOCUMENTO
143
+ # ============================================================================
144
+
145
+ def gerar_documento_callback(*args):
146
+ """Callback principal para gerar o documento."""
147
+ idx = 0
148
+
149
+ # Dados gerais
150
+ numero_laudo = args[idx]; idx += 1
151
+ numero_processo = args[idx]; idx += 1
152
+ data_laudo = args[idx]; idx += 1
153
+
154
+ unidade_demandante = args[idx]; idx += 1
155
+ unidade_demandante_custom = args[idx]; idx += 1
156
+ dados_requerimento = args[idx]; idx += 1
157
+ dados_requerimento_custom = args[idx]; idx += 1
158
+ representante_legal = args[idx]; idx += 1
159
+ valor_proposto = args[idx]; idx += 1
160
+ ano_proposto = args[idx]; idx += 1
161
+
162
+ # Documentação Padrão (dinâmico)
163
+ documentacao_padrao = []
164
+ for _ in range(MAX_DOCUMENTACAO_PADRAO):
165
+ doc = args[idx]; idx += 1
166
+ if doc and doc.strip():
167
+ documentacao_padrao.append(doc.strip())
168
+
169
+ # Documentação Específica (dinâmico)
170
+ documentacao_especifica = []
171
+ for _ in range(MAX_DOCUMENTACAO_ESPECIFICA):
172
+ doc = args[idx]; idx += 1
173
+ if doc and doc.strip():
174
+ documentacao_especifica.append(doc.strip())
175
+
176
+ # Motivos desvalorizantes (dinâmico)
177
+ motivos = []
178
+ for _ in range(MAX_MOTIVOS_DESVALORIZANTES):
179
+ desc = args[idx]; idx += 1
180
+ aleg = args[idx]; idx += 1
181
+ conf = args[idx]; idx += 1
182
+ if desc and desc.strip():
183
+ motivos.append({
184
+ 'descricao': desc.strip(),
185
+ 'alegado': aleg,
186
+ 'confirmado': conf
187
+ })
188
+
189
+ # Imóvel
190
+ inscricao_imovel = args[idx]; idx += 1
191
+ endereco_imovel = args[idx]; idx += 1
192
+ bairro_imovel = args[idx]; idx += 1
193
+ setor_quarteirao = args[idx]; idx += 1
194
+ lote_imovel = args[idx]; idx += 1
195
+ finalidade_imovel = args[idx]; idx += 1
196
+ area_territorial = args[idx]; idx += 1
197
+ construcoes_imovel = args[idx]; idx += 1
198
+ valores_venais = args[idx]; idx += 1
199
+
200
+ # Avaliação
201
+ unidade_responsavel = args[idx]; idx += 1
202
+ unidade_responsavel_custom = args[idx]; idx += 1
203
+ tecnico_responsavel = args[idx]; idx += 1
204
+ tecnico_responsavel_custom = args[idx]; idx += 1
205
+ metodo_avaliacao = args[idx]; idx += 1
206
+ metodo_avaliacao_custom = args[idx]; idx += 1
207
+ modelo_avaliacao = args[idx]; idx += 1
208
+
209
+ # Valores de mercado (dinâmico)
210
+ valores_anos = []
211
+ valores_valores = []
212
+ for _ in range(MAX_VALORES_MERCADO):
213
+ ano = args[idx]; idx += 1
214
+ valor = args[idx]; idx += 1
215
+ if ano and valor:
216
+ valores_anos.append(str(ano).strip())
217
+ valores_valores.append(str(valor).strip())
218
+
219
+ datas_referencia = args[idx]; idx += 1
220
+
221
+ # Registro fotográfico (upload)
222
+ registro_fotografico_path = args[idx]; idx += 1
223
+
224
+ # Preparar dados
225
+ def get_valor(dropdown, custom):
226
+ return custom if dropdown == "Outros" and custom else dropdown
227
+
228
+ valor_formatado, valor_extenso = formatar_valor_monetario(valor_proposto)
229
+ valor_proposto_completo = f"{valor_formatado} ({valor_extenso})" if valor_extenso else valor_formatado
230
+
231
+ setor_parts = setor_quarteirao.split(" / ") if " / " in setor_quarteirao else [setor_quarteirao, ""]
232
+ setor, quarteirao = (setor_parts[0], setor_parts[1]) if len(setor_parts) > 1 else (setor_parts[0], "")
233
+ bairro_completo = f"{bairro_imovel} ({setor} / {quarteirao})" if setor or quarteirao else bairro_imovel
234
+
235
+ valores_mercado_lista = criar_lista_valores_mercado(valores_anos, valores_valores)
236
+ motivos_formatados = formatar_motivos_desvalorizantes(motivos)
237
+
238
+ dados = {
239
+ 'numero_laudo': numero_laudo,
240
+ 'numero_processo': numero_processo,
241
+ 'data_laudo': data_laudo,
242
+ 'unidade_demandante': get_valor(unidade_demandante, unidade_demandante_custom),
243
+ 'dados_requerimento': get_valor(dados_requerimento, dados_requerimento_custom),
244
+ 'representante_legal': representante_legal,
245
+ 'valor_proposto': f"{valor_proposto_completo} (referente ao IPTU {ano_proposto})" if valor_proposto_completo else "",
246
+ 'documentacao_padrao': formatar_lista_para_documento(documentacao_padrao),
247
+ 'documentacao_especifica': formatar_lista_para_documento(documentacao_especifica),
248
+ 'endereco_imovel': endereco_imovel,
249
+ 'bairro_imovel': bairro_completo,
250
+ 'lote_imovel': lote_imovel,
251
+ 'inscricao_imovel': inscricao_imovel,
252
+ 'finalidade_imovel': finalidade_imovel,
253
+ 'area_territorial': area_territorial,
254
+ 'construcoes_imovel': construcoes_imovel,
255
+ 'valores_venais': valores_venais,
256
+ 'unidade_responsavel': get_valor(unidade_responsavel, unidade_responsavel_custom),
257
+ 'tecnico_responsavel': get_valor(tecnico_responsavel, tecnico_responsavel_custom),
258
+ 'metodo_avaliacao': get_valor(metodo_avaliacao, metodo_avaliacao_custom),
259
+ 'modelo_avaliacao': modelo_avaliacao,
260
+ 'valores_mercado': formatar_valores_mercado_para_documento(valores_anos, valores_valores),
261
+ 'valores_mercado_lista': valores_mercado_lista,
262
+ 'datas_referencia': datas_referencia,
263
+ 'registro_fotografico_path': registro_fotografico_path,
264
+ }
265
+
266
+ # Gerar documento
267
+ generator = LaudoGenerator()
268
+ output_path = generator.gerar(dados, motivos_formatados)
269
+
270
+ if output_path:
271
+ return f"✅ Documento gerado: {output_path.name}\nLocal: {output_path}", str(output_path)
272
+ else:
273
+ return "❌ Erro ao gerar documento", None
274
+
275
+
276
+ # ============================================================================
277
+ # VALORES DUMMY PARA TESTES
278
+ # ============================================================================
279
+
280
+ def get_dummy_values() -> Dict[str, Any]:
281
+ """
282
+ Retorna valores dummy para preencher os campos da interface.
283
+ Facilita testes durante o desenvolvimento.
284
+ Para remover esta funcionalidade, delete esta função e as referências a ela.
285
+ """
286
+ return {
287
+ # Dados gerais
288
+ "numero_laudo": "LA_001_2025",
289
+ "numero_processo": "123.456789.25.0",
290
+ "representante_legal": "João da Silva",
291
+ "valor_proposto": "500.000,00",
292
+ "ano_proposto": "2025",
293
+
294
+ # Documentação padrão
295
+ "docs_padrao": [
296
+ "Requerimento assinado",
297
+ "Cópia do RG e CPF",
298
+ "Comprovante de residência",
299
+ ],
300
+
301
+ # Documentação específica
302
+ "docs_especifica": [
303
+ "Laudo técnico de engenharia",
304
+ "Fotos do imóvel",
305
+ ],
306
+
307
+ # Motivos desvalorizantes
308
+ "motivos": [
309
+ {"descricao": "Imóvel em área de risco", "alegado": True, "confirmado": False},
310
+ {"descricao": "Falta de infraestrutura", "alegado": True, "confirmado": True},
311
+ ],
312
+
313
+ # Dados do imóvel
314
+ "inscricao": "001.002345.0001-0",
315
+ "endereco": "Rua das Flores, 123",
316
+ "bairro": "Centro",
317
+ "setor_quarteirao": "001 / 002",
318
+ "lote": "0001",
319
+ "finalidade": "Residencial",
320
+ "area_territorial": "360,00 m²",
321
+ "construcoes": "Casa térrea com 120,00 m² de área construída",
322
+ "valores_venais": "VT: R$ 200.000,00 | VC: R$ 150.000,00 | VTOTAL: R$ 350.000,00",
323
+
324
+ # Valores de mercado
325
+ "anos_mercado": ["2023", "2024", "2025"],
326
+ "valores_mercado": ["450.000,00", "480.000,00", "500.000,00"],
327
+ "ref_datas": "Dezembro/2022, Dezembro/2023, Dezembro/2024",
328
+ }
329
+
330
+
331
+ # ============================================================================
332
+ # INTERFACE
333
+ # ============================================================================
334
+
335
+ def build_interface(fill_dummy: bool = False) -> gr.Blocks:
336
+ """
337
+ Constrói a interface Gradio com campos dinâmicos e abas.
338
+
339
+ Args:
340
+ fill_dummy: Se True, preenche campos com valores de teste.
341
+ """
342
+ # Obter valores dummy se solicitado
343
+ dummy = get_dummy_values() if fill_dummy else {}
344
+
345
+ # Obter lista de modelos disponíveis
346
+ modelos_disponiveis = list_available_models()
347
+ if not modelos_disponiveis:
348
+ modelos_disponiveis = ["Nenhum modelo encontrado"]
349
+
350
+ with gr.Blocks(title="Sistema de Geração de Laudos") as app:
351
+ gr.Markdown("# Sistema de Geração de Laudos de Avaliação")
352
+
353
+ with gr.Tabs():
354
+ # ================================================================
355
+ # ABA 1: SOLICITAÇÃO
356
+ # ================================================================
357
+ with gr.TabItem("1. Solicitação"):
358
+ gr.Markdown("### Dados Gerais")
359
+ with gr.Row():
360
+ numero_laudo = gr.Textbox(label="Número do Laudo", placeholder="LA_XXX_2025", value=dummy.get("numero_laudo", ""))
361
+ numero_processo = gr.Textbox(label="Processo Administrativo", placeholder="000.000000.00.0", value=dummy.get("numero_processo", ""))
362
+ data_laudo = gr.Textbox(label="Data do Laudo", value=_get_data_atual_formatada())
363
+
364
+ with gr.Row():
365
+ u_demand = gr.Dropdown(UNIDADES_DEMANDANTES, label="Unidade Demandante", value=UNIDADES_DEMANDANTES[0])
366
+ u_demand_custom = gr.Textbox(label="Especifique", visible=False)
367
+ u_demand.change(toggle_custom_field, u_demand, u_demand_custom)
368
+
369
+ with gr.Row():
370
+ req_tipo = gr.Dropdown(TIPOS_REQUERIMENTO, label="Requerimento", value=TIPOS_REQUERIMENTO[0])
371
+ req_custom = gr.Textbox(label="Especifique", visible=False)
372
+ req_tipo.change(toggle_custom_field, req_tipo, req_custom)
373
+
374
+ rep_legal = gr.Textbox(label="Requerente/Representante Legal", value=dummy.get("representante_legal", ""))
375
+
376
+ gr.Markdown("### Valor Proposto pelo Contribuinte")
377
+ with gr.Row():
378
+ v_proposto = gr.Textbox(label="Valor Proposto (R$)", placeholder="Digite e saia do campo", value=dummy.get("valor_proposto", ""))
379
+ a_proposto = gr.Textbox(label="Ano Ref.", value=dummy.get("ano_proposto", "2025"))
380
+ v_proposto.blur(aplicar_mascara_monetaria, v_proposto, v_proposto)
381
+
382
+ # ================================================================
383
+ # ABA 2: DOCUMENTAÇÃO
384
+ # ================================================================
385
+ with gr.TabItem("2. Documentação"):
386
+ gr.Markdown("### Documentação Padrão")
387
+ docs_padrao_inputs = []
388
+ docs_padrao_rows = []
389
+ docs_padrao_dummy = dummy.get("docs_padrao", [])
390
+ n_docs_padrao = len(docs_padrao_dummy) if docs_padrao_dummy else 1
391
+ for i in range(MAX_DOCUMENTACAO_PADRAO):
392
+ with gr.Row(visible=(i < n_docs_padrao)) as row:
393
+ val = docs_padrao_dummy[i] if i < len(docs_padrao_dummy) else ""
394
+ docs_padrao_inputs.append(gr.Textbox(label=f"Documento {i+1}", show_label=(i == 0), value=val))
395
+ docs_padrao_rows.append(row)
396
+ docs_padrao_count = gr.State(n_docs_padrao)
397
+ with gr.Row():
398
+ btn_add_doc_padrao = gr.Button("➕ Adicionar", size="sm")
399
+ btn_rem_doc_padrao = gr.Button("➖ Remover", size="sm")
400
+ btn_add_doc_padrao.click(
401
+ lambda c: adicionar_campo(c, MAX_DOCUMENTACAO_PADRAO),
402
+ docs_padrao_count,
403
+ [docs_padrao_count] + docs_padrao_rows
404
+ )
405
+ btn_rem_doc_padrao.click(
406
+ lambda c: remover_campo(c, MAX_DOCUMENTACAO_PADRAO),
407
+ docs_padrao_count,
408
+ [docs_padrao_count] + docs_padrao_rows
409
+ )
410
+
411
+ gr.Markdown("---")
412
+ gr.Markdown("### Documentação Específica")
413
+ docs_esp_inputs = []
414
+ docs_esp_rows = []
415
+ docs_esp_dummy = dummy.get("docs_especifica", [])
416
+ n_docs_esp = len(docs_esp_dummy) if docs_esp_dummy else 1
417
+ for i in range(MAX_DOCUMENTACAO_ESPECIFICA):
418
+ with gr.Row(visible=(i < n_docs_esp)) as row:
419
+ val = docs_esp_dummy[i] if i < len(docs_esp_dummy) else ""
420
+ docs_esp_inputs.append(gr.Textbox(label=f"Doc Específico {i+1}", show_label=(i == 0), value=val))
421
+ docs_esp_rows.append(row)
422
+ docs_esp_count = gr.State(n_docs_esp)
423
+ with gr.Row():
424
+ btn_add_doc_esp = gr.Button("➕ Adicionar", size="sm")
425
+ btn_rem_doc_esp = gr.Button("➖ Remover", size="sm")
426
+ btn_add_doc_esp.click(
427
+ lambda c: adicionar_campo(c, MAX_DOCUMENTACAO_ESPECIFICA),
428
+ docs_esp_count,
429
+ [docs_esp_count] + docs_esp_rows
430
+ )
431
+ btn_rem_doc_esp.click(
432
+ lambda c: remover_campo(c, MAX_DOCUMENTACAO_ESPECIFICA),
433
+ docs_esp_count,
434
+ [docs_esp_count] + docs_esp_rows
435
+ )
436
+
437
+ # ================================================================
438
+ # ABA 3: MOTIVOS DESVALORIZANTES
439
+ # ================================================================
440
+ with gr.TabItem("3. Motivos Desvalorizantes"):
441
+ gr.Markdown("### Motivos Desvalorizantes Alegados")
442
+ motivos_rows = []
443
+ motivos_desc = []
444
+ motivos_aleg = []
445
+ motivos_conf = []
446
+ motivos_dummy = dummy.get("motivos", [])
447
+ n_motivos = len(motivos_dummy) if motivos_dummy else 1
448
+ for i in range(MAX_MOTIVOS_DESVALORIZANTES):
449
+ mot = motivos_dummy[i] if i < len(motivos_dummy) else {}
450
+ with gr.Row(visible=(i < n_motivos)) as row:
451
+ d = gr.Textbox(show_label=False, scale=3, placeholder=f"Motivo Desvalorizante {i+1}", value=mot.get("descricao", ""))
452
+ a = gr.Checkbox(label="Alegado", value=mot.get("alegado", True))
453
+ c = gr.Checkbox(label="Confirmado", value=mot.get("confirmado", False))
454
+ motivos_desc.append(d)
455
+ motivos_aleg.append(a)
456
+ motivos_conf.append(c)
457
+ motivos_rows.append(row)
458
+ motivos_count = gr.State(n_motivos)
459
+ with gr.Row():
460
+ btn_add_motivo = gr.Button("➕ Adicionar Motivo", size="sm")
461
+ btn_rem_motivo = gr.Button("➖ Remover Motivo", size="sm")
462
+ btn_add_motivo.click(
463
+ lambda c: adicionar_campo(c, MAX_MOTIVOS_DESVALORIZANTES),
464
+ motivos_count,
465
+ [motivos_count] + motivos_rows
466
+ )
467
+ btn_rem_motivo.click(
468
+ lambda c: remover_campo(c, MAX_MOTIVOS_DESVALORIZANTES),
469
+ motivos_count,
470
+ [motivos_count] + motivos_rows
471
+ )
472
+
473
+ # ================================================================
474
+ # ABA 4: IMÓVEL OBJETO
475
+ # ================================================================
476
+ with gr.TabItem("4. Imóvel Objeto"):
477
+ gr.Markdown("### Dados do Imóvel")
478
+ with gr.Row():
479
+ pdf_up = gr.File(label="PDF SIAT", type="filepath")
480
+ btn_pdf = gr.Button("🔍 Processar PDF", size="sm")
481
+
482
+ inscricao = gr.Textbox(label="Inscrição", placeholder="Aguardando processamento do PDF ou preenchimento manual", value=dummy.get("inscricao", ""))
483
+ with gr.Row():
484
+ endereco = gr.Textbox(label="Endereço", value=dummy.get("endereco", ""))
485
+ bairro = gr.Textbox(label="Bairro", value=dummy.get("bairro", ""))
486
+ with gr.Row():
487
+ setor_q = gr.Textbox(label="Setor / Quarteirão", value=dummy.get("setor_quarteirao", ""))
488
+ lote = gr.Textbox(label="Lote Fiscal", value=dummy.get("lote", ""))
489
+ with gr.Row():
490
+ finalidade = gr.Textbox(label="Finalidade", value=dummy.get("finalidade", ""))
491
+ area_ter = gr.Textbox(label="Área Territorial", value=dummy.get("area_territorial", ""))
492
+ construcoes = gr.Textbox(label="Construções", lines=2, value=dummy.get("construcoes", ""))
493
+ val_venais = gr.Textbox(label="Valores Venais", lines=2, value=dummy.get("valores_venais", ""))
494
+
495
+ imovel_outputs = [inscricao, endereco, bairro, setor_q, lote, finalidade, area_ter, construcoes, val_venais]
496
+ btn_pdf.click(processar_upload_pdf, pdf_up, imovel_outputs)
497
+
498
+ # ================================================================
499
+ # ABA 5: AVALIAÇÃO
500
+ # ================================================================
501
+ with gr.TabItem("5. Avaliação"):
502
+ gr.Markdown("### Dados do Avaliador")
503
+ with gr.Row():
504
+ u_resp = gr.Dropdown(UNIDADES_RESPONSAVEIS, label="Unidade Responsável", value=UNIDADES_RESPONSAVEIS[0])
505
+ u_resp_custom = gr.Textbox(label="Especifique", visible=False)
506
+ u_resp.change(toggle_custom_field, u_resp, u_resp_custom)
507
+
508
+ with gr.Row():
509
+ tec_resp = gr.Dropdown(TECNICOS_RESPONSAVEIS, label="Técnico Responsável", value=TECNICOS_RESPONSAVEIS[0])
510
+ tec_resp_custom = gr.Textbox(label="Especifique", visible=False)
511
+ tec_resp.change(toggle_custom_field, tec_resp, tec_resp_custom)
512
+
513
+ gr.Markdown("### Metodologia")
514
+ with gr.Row():
515
+ metodo = gr.Dropdown(METODOS_AVALIACAO, label="Método de Avaliação", value=METODOS_AVALIACAO[0])
516
+ metodo_custom = gr.Textbox(label="Especifique", visible=False)
517
+ metodo.change(toggle_custom_field, metodo, metodo_custom)
518
+
519
+ modelo = gr.Dropdown(modelos_disponiveis, label="Modelo de Avaliação (.dai)", value=modelos_disponiveis[0] if modelos_disponiveis else None)
520
+
521
+ # ================================================================
522
+ # ABA 6: VALORES DE MERCADO
523
+ # ================================================================
524
+ with gr.TabItem("6. Valores de Mercado"):
525
+ gr.Markdown("### Definir Intervalo de Anos")
526
+ with gr.Row():
527
+ ano_ini = gr.Number(value=2020, label="Ano Inicial")
528
+ ano_fim = gr.Number(value=2025, label="Ano Final")
529
+ btn_gerar_anos = gr.Button("📅 Gerar Campos", size="sm")
530
+ btn_limpar_anos = gr.Button("🗑️ Limpar", size="sm")
531
+
532
+ gr.Markdown("### Valores por Ano")
533
+ vm_rows = []
534
+ vm_anos = []
535
+ vm_vals = []
536
+ vm_outputs_flat = []
537
+ anos_dummy = dummy.get("anos_mercado", [])
538
+ valores_dummy = dummy.get("valores_mercado", [])
539
+ n_vm = len(anos_dummy) if anos_dummy else 0
540
+
541
+ for i in range(MAX_VALORES_MERCADO):
542
+ ano_val = anos_dummy[i] if i < len(anos_dummy) else ""
543
+ valor_val = valores_dummy[i] if i < len(valores_dummy) else ""
544
+ with gr.Row(visible=(i < n_vm)) as row:
545
+ a = gr.Textbox(label="Ano", interactive=False, scale=1, value=ano_val)
546
+ v = gr.Textbox(label="Valor (R$)", scale=2, placeholder="R$ 0,00", value=valor_val)
547
+ v.blur(aplicar_mascara_monetaria, v, v)
548
+ vm_anos.append(a)
549
+ vm_vals.append(v)
550
+ vm_rows.append(row)
551
+ vm_outputs_flat.extend([row, a, v])
552
+
553
+ ref_datas = gr.Textbox(label="Datas de Referência (datas-base)", value=dummy.get("ref_datas", ""))
554
+
555
+ btn_gerar_anos.click(gerar_campos_valores_mercado, [ano_ini, ano_fim], vm_outputs_flat + [ref_datas])
556
+ btn_limpar_anos.click(limpar_campos_valores_mercado, [], vm_outputs_flat + [ref_datas])
557
+
558
+ # ================================================================
559
+ # ABA 7: REGISTRO FOTOGRÁFICO
560
+ # ================================================================
561
+ with gr.TabItem("7. Registro Fotográfico"):
562
+ gr.Markdown("### Upload do Registro Fotográfico")
563
+ gr.Markdown(
564
+ "Faça upload do documento DOCX com o registro fotográfico do imóvel. "
565
+ "O documento será anexado ao final do laudo gerado."
566
+ )
567
+
568
+ registro_foto_upload = gr.File(
569
+ label="Documento de Registro Fotográfico (.docx)",
570
+ type="filepath",
571
+ file_types=[".docx"]
572
+ )
573
+
574
+ gr.Markdown("---")
575
+ gr.Markdown(
576
+ "**Dica:** Você pode gerar o registro fotográfico utilizando a ferramenta: "
577
+ "[Gerador de Fotos](https://huggingface.co/spaces/gui-sparim/gerador_fotos)"
578
+ )
579
+
580
+ # ================================================================
581
+ # ABA 8: GERAR DOCUMENTO
582
+ # ================================================================
583
+ with gr.TabItem("8. Gerar Documento"):
584
+ gr.Markdown("### Gerar Laudo de Avaliação")
585
+ gr.Markdown("Verifique se todos os dados foram preenchidos nas abas anteriores.")
586
+
587
+ btn_gerar = gr.Button("📄 Gerar Documento", variant="primary", size="lg")
588
+
589
+ gr.Markdown("---")
590
+ res_md = gr.Markdown()
591
+ res_file = gr.File(label="Download do Documento")
592
+
593
+ # Coleta de todos os inputs
594
+ all_inputs = [
595
+ numero_laudo, numero_processo, data_laudo,
596
+ u_demand, u_demand_custom,
597
+ req_tipo, req_custom,
598
+ rep_legal, v_proposto, a_proposto
599
+ ]
600
+ all_inputs.extend(docs_padrao_inputs)
601
+ all_inputs.extend(docs_esp_inputs)
602
+ for d, a, c in zip(motivos_desc, motivos_aleg, motivos_conf):
603
+ all_inputs.extend([d, a, c])
604
+ all_inputs.extend(imovel_outputs)
605
+ all_inputs.extend([u_resp, u_resp_custom, tec_resp, tec_resp_custom, metodo, metodo_custom, modelo])
606
+ for a, v in zip(vm_anos, vm_vals):
607
+ all_inputs.extend([a, v])
608
+ all_inputs.append(ref_datas)
609
+ all_inputs.append(registro_foto_upload)
610
+
611
+ btn_gerar.click(gerar_documento_callback, all_inputs, [res_md, res_file])
612
+
613
+ return app
ui/components/__init__.py ADDED
File without changes
utils/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Utilitários do sistema.
3
+ """
4
+ from .docx_loader import ler_texto_docx, mesclar_documento_fotos
5
+ from .formatters import formatar_valor_monetario, formatar_motivos_desvalorizantes
6
+
7
+ __all__ = [
8
+ 'ler_texto_docx',
9
+ 'mesclar_documento_fotos',
10
+ 'formatar_valor_monetario',
11
+ 'formatar_motivos_desvalorizantes',
12
+ ]
utils/docx_loader.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Utilitários para leitura e manipulação de arquivos DOCX.
3
+ """
4
+ from docx import Document
5
+ from pathlib import Path
6
+ from typing import Optional, Union
7
+
8
+
9
+ def ler_texto_docx(caminho_arquivo: Union[str, Path]) -> str:
10
+ """
11
+ Lê conteúdo de texto de um arquivo DOCX.
12
+
13
+ Args:
14
+ caminho_arquivo: Caminho para o arquivo DOCX
15
+
16
+ Returns:
17
+ Texto do documento com parágrafos separados por quebras duplas de linha
18
+ """
19
+ caminho = Path(caminho_arquivo)
20
+ if not caminho.exists():
21
+ return ""
22
+
23
+ try:
24
+ doc = Document(str(caminho))
25
+ return "\n\n".join(p.text for p in doc.paragraphs if p.text.strip())
26
+ except Exception as e:
27
+ print(f"Erro ao ler arquivo DOCX {caminho}: {e}")
28
+ return ""
29
+
30
+
31
+ def mesclar_documento_fotos(
32
+ doc_principal: Document,
33
+ caminho_fotos: Optional[Union[str, Path]]
34
+ ) -> Document:
35
+ """
36
+ Mescla documento de registro fotográfico ao documento principal.
37
+
38
+ Args:
39
+ doc_principal: Objeto Document principal do laudo
40
+ caminho_fotos: Caminho para o arquivo DOCX de registro fotográfico
41
+
42
+ Returns:
43
+ Documento mesclado (ou original se não houver documento de fotos)
44
+ """
45
+ if not caminho_fotos:
46
+ return doc_principal
47
+
48
+ caminho = Path(caminho_fotos)
49
+ if not caminho.exists():
50
+ return doc_principal
51
+
52
+ try:
53
+ from docxcompose.composer import Composer
54
+
55
+ doc_fotos = Document(str(caminho))
56
+ composer = Composer(doc_principal)
57
+ composer.append(doc_fotos)
58
+ return composer.doc
59
+ except ImportError:
60
+ print("docxcompose não instalado. Execute: pip install docxcompose")
61
+ return doc_principal
62
+ except Exception as e:
63
+ print(f"Erro ao mesclar documento de fotos: {e}")
64
+ return doc_principal
utils/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)
utils/formatters.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Formatadores de texto e valores monetários.
3
+ """
4
+ import re
5
+ from typing import Tuple, List, Dict
6
+
7
+
8
+ def numero_para_extenso(valor: float) -> str:
9
+ """Converte um valor numérico para sua representação por extenso em português."""
10
+ if valor == 0:
11
+ return "zero reais"
12
+
13
+ unidades = ['', 'um', 'dois', 'três', 'quatro', 'cinco', 'seis', 'sete', 'oito', 'nove']
14
+ especiais = ['dez', 'onze', 'doze', 'treze', 'quatorze', 'quinze', 'dezesseis', 'dezessete', 'dezoito', 'dezenove']
15
+ dezenas = ['', '', 'vinte', 'trinta', 'quarenta', 'cinquenta', 'sessenta', 'setenta', 'oitenta', 'noventa']
16
+ centenas = ['', 'cento', 'duzentos', 'trezentos', 'quatrocentos', 'quinhentos', 'seiscentos', 'setecentos', 'oitocentos', 'novecentos']
17
+
18
+ def converter_grupo(n):
19
+ if n == 0:
20
+ return ''
21
+ if n == 100:
22
+ return 'cem'
23
+
24
+ resultado = ''
25
+
26
+ if n >= 100:
27
+ resultado += centenas[n // 100]
28
+ n %= 100
29
+ if n > 0:
30
+ resultado += ' e '
31
+
32
+ if n >= 20:
33
+ resultado += dezenas[n // 10]
34
+ n %= 10
35
+ if n > 0:
36
+ resultado += ' e ' + unidades[n]
37
+ elif n >= 10:
38
+ resultado += especiais[n - 10]
39
+ elif n > 0:
40
+ resultado += unidades[n]
41
+
42
+ return resultado
43
+
44
+ parte_inteira = int(valor)
45
+ centavos = round((valor - parte_inteira) * 100)
46
+
47
+ resultado = ''
48
+
49
+ if parte_inteira >= 1000000:
50
+ milhoes = parte_inteira // 1000000
51
+ if milhoes == 1:
52
+ resultado += 'um milhão'
53
+ else:
54
+ resultado += converter_grupo(milhoes) + ' milhões'
55
+ parte_inteira %= 1000000
56
+ if parte_inteira > 0:
57
+ resultado += ' e '
58
+
59
+ if parte_inteira >= 1000:
60
+ milhares = parte_inteira // 1000
61
+ if milhares == 1:
62
+ resultado += 'mil'
63
+ else:
64
+ resultado += converter_grupo(milhares) + ' mil'
65
+ parte_inteira %= 1000
66
+ if parte_inteira > 0:
67
+ resultado += ' e '
68
+
69
+ if parte_inteira > 0:
70
+ resultado += converter_grupo(parte_inteira)
71
+
72
+ if resultado:
73
+ if int(valor) == 1:
74
+ resultado += ' real'
75
+ else:
76
+ resultado += ' reais'
77
+
78
+ if centavos > 0:
79
+ if resultado:
80
+ resultado += ' e '
81
+ resultado += converter_grupo(centavos)
82
+ if centavos == 1:
83
+ resultado += ' centavo'
84
+ else:
85
+ resultado += ' centavos'
86
+
87
+ return resultado
88
+
89
+
90
+ def parse_valor_monetario(valor_str: str) -> float:
91
+ """Converte string de valor monetário para float."""
92
+ if not valor_str:
93
+ return 0.0
94
+ valor_limpo = re.sub(r'[^\d,.]', '', valor_str)
95
+ valor_limpo = valor_limpo.replace('.', '').replace(',', '.')
96
+ try:
97
+ return float(valor_limpo)
98
+ except ValueError:
99
+ return 0.0
100
+
101
+
102
+ def formatar_valor_monetario(valor_str: str) -> Tuple[str, str]:
103
+ """Formata valor monetário e retorna tupla (valor_formatado, extenso)."""
104
+ valor_float = parse_valor_monetario(valor_str)
105
+
106
+ if valor_float == 0:
107
+ return valor_str, ""
108
+
109
+ valor_formatado = f"R$ {valor_float:,.2f}".replace(',', 'X').replace('.', ',').replace('X', '.')
110
+ extenso = numero_para_extenso(valor_float)
111
+
112
+ return valor_formatado, extenso
113
+
114
+
115
+ def aplicar_mascara_monetaria(valor: str) -> str:
116
+ """Aplica máscara monetária brasileira a um valor."""
117
+ if not valor:
118
+ return ""
119
+
120
+ valor = valor.strip()
121
+
122
+ if re.match(r'^R\$\s*[\d\.]+,\d{2}$', valor):
123
+ return valor
124
+
125
+ if re.search(r'[a-zA-ZáéíóúãõâêîôûàèìòùçÁÉÍÓÚÃÕÂÊÎÔÛÀÈÌÒÙÇ]', valor):
126
+ return valor
127
+
128
+ valor_limpo = re.sub(r'R\$\s*', '', valor)
129
+
130
+ if ',' in valor_limpo and '.' in valor_limpo:
131
+ valor_limpo = valor_limpo.replace('.', '').replace(',', '.')
132
+ elif ',' in valor_limpo:
133
+ valor_limpo = valor_limpo.replace(',', '.')
134
+
135
+ try:
136
+ valor_float = float(valor_limpo)
137
+ except ValueError:
138
+ return valor
139
+
140
+ return f"R$ {valor_float:,.2f}".replace(',', 'X').replace('.', ',').replace('X', '.')
141
+
142
+
143
+ def formatar_lista_para_documento(items: List[str]) -> str:
144
+ """Formata lista de itens para inclusão no documento."""
145
+ items_limpos = [i.strip() for i in items if i and i.strip()]
146
+ if not items_limpos:
147
+ return ""
148
+ return "\n".join([f"- {i};" for i in items_limpos])
149
+
150
+
151
+ def formatar_valores_mercado_para_documento(anos: List[str], valores: List[str]) -> str:
152
+ """Formata lista de valores de mercado por ano para inclusão no documento."""
153
+ linhas = []
154
+ for ano, valor in zip(anos, valores):
155
+ if ano and valor:
156
+ try:
157
+ ano_str = str(int(float(ano))) if ano else ""
158
+ except (ValueError, TypeError):
159
+ ano_str = str(ano) if ano else ""
160
+
161
+ valor_float = parse_valor_monetario(valor)
162
+ if valor_float > 0:
163
+ extenso = numero_para_extenso(valor_float)
164
+ linhas.append(f"{ano_str} - {valor} ({extenso})")
165
+ else:
166
+ linhas.append(f"{ano_str} - {valor}")
167
+
168
+ if not linhas:
169
+ return ""
170
+ return "\n".join(linhas)
171
+
172
+
173
+ def formatar_motivos_desvalorizantes(motivos: List[Dict]) -> Dict:
174
+ """Formata a lista unificada de motivos desvalorizantes."""
175
+ if not motivos:
176
+ return {
177
+ 'alegados': [],
178
+ 'confirmados': [],
179
+ 'todos': [],
180
+ 'alegados_texto': '',
181
+ 'confirmados_texto': '',
182
+ 'secoes': []
183
+ }
184
+
185
+ alegados = []
186
+ confirmados = []
187
+ todos = []
188
+ secoes = []
189
+
190
+ for i, motivo in enumerate(motivos, 1):
191
+ descricao = motivo.get('descricao', '').strip()
192
+ foi_alegado = motivo.get('alegado', False)
193
+ foi_confirmado = motivo.get('confirmado', False)
194
+
195
+ if not descricao:
196
+ continue
197
+
198
+ item = {
199
+ 'numero': i,
200
+ 'descricao': descricao,
201
+ 'alegado': foi_alegado,
202
+ 'confirmado': foi_confirmado
203
+ }
204
+
205
+ todos.append(item)
206
+
207
+ if foi_alegado:
208
+ alegados.append(descricao)
209
+
210
+ if foi_confirmado:
211
+ confirmados.append(descricao)
212
+
213
+ status = []
214
+ if foi_alegado:
215
+ status.append("alegado pelo contribuinte")
216
+ else:
217
+ status.append("não alegado pelo contribuinte")
218
+ if foi_confirmado:
219
+ status.append("e confirmado na análise")
220
+ else:
221
+ status.append("e não confirmado na análise")
222
+
223
+ secoes.append({
224
+ 'numero': i,
225
+ 'titulo': descricao,
226
+ 'status': ", ".join(status) if status else "identificado na vistoria",
227
+ 'confirmado': foi_confirmado
228
+ })
229
+
230
+ alegados_texto = formatar_lista_para_documento(alegados)
231
+ confirmados_texto = formatar_lista_para_documento(confirmados)
232
+
233
+ return {
234
+ 'alegados': alegados,
235
+ 'confirmados': confirmados,
236
+ 'todos': todos,
237
+ 'alegados_texto': alegados_texto,
238
+ 'confirmados_texto': confirmados_texto,
239
+ 'secoes': secoes
240
+ }