Spaces:
Sleeping
Sleeping
Upload 44 files
Browse files- .gitattributes +1 -0
- CLAUDE.md +302 -0
- app.py +50 -0
- config/__init__.py +0 -0
- config/constants.py +61 -0
- config/settings.py +23 -0
- core/__init__.py +0 -0
- core/entities/__init__.py +14 -0
- core/entities/imovel.py +67 -0
- core/entities/laudo.py +72 -0
- core/entities/solicitacao.py +35 -0
- core/services/__init__.py +0 -0
- document/__init__.py +41 -0
- document/anexos/__init__.py +18 -0
- document/anexos/banco_dados.py +88 -0
- document/anexos/calculo_valor.py +92 -0
- document/anexos/estatisticas.py +230 -0
- document/anexos/graficos.py +315 -0
- document/anexos/metodologia.py +103 -0
- document/anexos/planilha_calculo.py +196 -0
- document/formatters/__init__.py +61 -0
- document/formatters/heading.py +57 -0
- document/formatters/image.py +63 -0
- document/formatters/paragraph.py +102 -0
- document/formatters/section.py +59 -0
- document/formatters/table.py +238 -0
- document/generator.py +407 -0
- document/numbering.py +68 -0
- document/sections/__init__.py +0 -0
- extractors/__init__.py +0 -0
- extractors/pdf_extractor.py +148 -0
- models/__init__.py +33 -0
- models/files/ABC/metodologia.docx +0 -0
- models/files/ABC/modelo.dai +3 -0
- models/model_data.py +77 -0
- models/model_loader.py +63 -0
- models/registry.py +116 -0
- requirements.txt +10 -0
- ui/__init__.py +0 -0
- ui/app_builder.py +613 -0
- ui/components/__init__.py +0 -0
- utils/__init__.py +12 -0
- utils/docx_loader.py +64 -0
- utils/estatisticas_utils.py +114 -0
- utils/formatters.py +240 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
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 |
+
}
|