gui-sparim commited on
Commit
55d9355
·
verified ·
1 Parent(s): 3bda4f3

Publica gerador evolutivo e carrega modelos do repositório MESA

Browse files

Atualiza importação e narrativa das avaliações, tabelas e notas. No Space, consulta o dataset da MESA com cache por revisão e acesso autenticado somente no backend. Validado com 99 testes Python, 6 testes do frontend e build Vite.

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +3 -0
  2. Dockerfile +3 -0
  3. README.md +80 -0
  4. backend/app/avaliacoes_import.py +247 -0
  5. backend/app/evo_documentation.py +123 -0
  6. backend/app/import_defaults.py +90 -0
  7. backend/app/main.py +22 -1
  8. backend/app/schemas.py +26 -0
  9. backend/app/services.py +40 -2
  10. config/constants.py +3 -0
  11. config/mesa_documentacao.json +818 -0
  12. config/mesa_redutores.json +192 -0
  13. docs/plano-metodo-evolutivo.md +221 -0
  14. document/anexos/metodologia.py +82 -74
  15. document/avaliacoes_importadas.py +104 -0
  16. document/evo_adjustments.py +69 -0
  17. document/evolutivo.py +545 -0
  18. document/footnotes.py +49 -0
  19. document/formatters/__init__.py +2 -0
  20. document/formatters/heading.py +20 -5
  21. document/formatters/table.py +9 -0
  22. document/fundamentacao.py +136 -0
  23. document/generator.py +123 -41
  24. document/mesa_content.py +16 -0
  25. document/numbering.py +10 -2
  26. document/territorial_narrative.py +253 -0
  27. frontend/package.json +1 -0
  28. frontend/src/App.jsx +79 -313
  29. frontend/src/EvolutivoReview.jsx +63 -0
  30. frontend/src/ImportacaoAvaliacoes.jsx +89 -0
  31. frontend/src/RevisaoAvaliacao.jsx +33 -0
  32. frontend/src/importDefaults.js +55 -0
  33. frontend/src/styles.css +12 -0
  34. frontend/tests/importDefaults.test.mjs +55 -0
  35. models/anos.py +95 -0
  36. models/construction_identity.py +13 -0
  37. models/hf_repository.py +101 -0
  38. models/imported_context.py +95 -0
  39. models/model_data.py +1 -0
  40. models/model_loader.py +11 -0
  41. models/registry.py +11 -7
  42. requirements.txt +1 -0
  43. run_dev.sh +67 -4
  44. scripts/sync_mesa_documentacao.mjs +85 -0
  45. scripts/sync_mesa_redutores.py +25 -0
  46. tests/test_anos.py +138 -0
  47. tests/test_avaliacoes_import.py +268 -0
  48. tests/test_evo_adjustments.py +105 -0
  49. tests/test_evolutivo_documento.py +330 -0
  50. tests/test_hf_model_repository.py +137 -0
.dockerignore CHANGED
@@ -8,3 +8,6 @@ node_modules
8
  frontend/node_modules
9
  output/laudos_gerados
10
  output/tmp_uploads
 
 
 
 
8
  frontend/node_modules
9
  output/laudos_gerados
10
  output/tmp_uploads
11
+ models/files
12
+ models/backups
13
+ tmp
Dockerfile CHANGED
@@ -11,6 +11,9 @@ FROM python:3.11-slim AS runtime
11
  ENV PYTHONDONTWRITEBYTECODE=1
12
  ENV PYTHONUNBUFFERED=1
13
  ENV PORT=7860
 
 
 
14
  WORKDIR /app
15
 
16
  COPY requirements.txt ./
 
11
  ENV PYTHONDONTWRITEBYTECODE=1
12
  ENV PYTHONUNBUFFERED=1
13
  ENV PORT=7860
14
+ ENV MODELOS_REPOSITORIO_PROVIDER=hf_dataset
15
+ ENV MODELOS_REPOSITORIO_HF_REPO_ID=gui-sparim/repositorio_mesa
16
+ ENV MODELOS_REPOSITORIO_HF_SUBDIR=modelos_dai
17
  WORKDIR /app
18
 
19
  COPY requirements.txt ./
README.md CHANGED
@@ -19,6 +19,19 @@ Aplicação para geração de laudos de avaliação imobiliária com backend em
19
 
20
  ## Rodando em desenvolvimento local
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  ### 1) Backend
23
 
24
  ```bash
@@ -69,9 +82,76 @@ Este repositório inclui `Dockerfile` para Spaces com Docker SDK.
69
 
70
  - Builda o frontend em estágio Node
71
  - Sobe API FastAPI na porta `7860` (ou `PORT` informado pelo ambiente)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
  Comando de execução no container:
74
 
75
  ```bash
76
  uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-7860}
77
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  ## Rodando em desenvolvimento local
21
 
22
+ Para iniciar backend e frontend juntos:
23
+
24
+ ```bash
25
+ ./run_dev.sh
26
+ ```
27
+
28
+ O script procura as próximas portas livres a partir de `8000` para o backend e
29
+ `5173` para o frontend, configura a conexão com a API e as origens CORS, e exibe
30
+ os endereços escolhidos. Para mudar as portas iniciais, use, por exemplo,
31
+ `PORT=8100 FRONTEND_PORT=5200 ./run_dev.sh`. `Ctrl+C` encerra os dois servidores.
32
+
33
+ Também é possível iniciar cada serviço separadamente:
34
+
35
  ### 1) Backend
36
 
37
  ```bash
 
82
 
83
  - Builda o frontend em estágio Node
84
  - Sobe API FastAPI na porta `7860` (ou `PORT` informado pelo ambiente)
85
+ - Consulta os modelos no dataset da MESA, `gui-sparim/repositorio_mesa`, pasta `modelos_dai`.
86
+ Os arquivos `.dai` locais e seus backups não entram na imagem Docker.
87
+
88
+ Cadastre nos **Settings → Secrets** do Space o `HF_TOKEN` com acesso de leitura
89
+ ao dataset da MESA, caso ele seja privado. O token permanece no backend.
90
+ Em Spaces, a fonte é sempre o dataset; falhas de acesso são informadas e não
91
+ substituídas pelos modelos locais.
92
+
93
+ Configuração opcional em **Settings → Variables** (mesmos nomes da MESA):
94
+
95
+ - `MODELOS_REPOSITORIO_HF_REPO_ID`: padrão `gui-sparim/repositorio_mesa`
96
+ - `MODELOS_REPOSITORIO_HF_REVISION`: padrão `main`
97
+ - `MODELOS_REPOSITORIO_HF_SUBDIR`: padrão `modelos_dai`
98
+ - `MODELOS_REPOSITORIO_HF_CACHE_TTL_SECONDS`: padrão `300` segundos
99
+
100
+ O catálogo é consultado novamente após esse intervalo. Cada modelo é baixado
101
+ somente quando utilizado e a versão fica vinculada ao commit do dataset.
102
+ Uma nova revisão invalida os modelos carregados em memória, incluindo modelos
103
+ alterados ou excluídos. O cache de downloads do Hub evita transferências repetidas.
104
+
105
+ No desenvolvimento fora do Docker/Spaces, o padrão continua sendo `models/files`.
106
+ Para testar a mesma fonte de produção localmente, defina
107
+ `MODELOS_REPOSITORIO_PROVIDER=hf_dataset` e configure `HF_TOKEN` no ambiente.
108
 
109
  Comando de execução no container:
110
 
111
  ```bash
112
  uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-7860}
113
  ```
114
+
115
+ ## Importação de avaliações do MESA
116
+
117
+ A aplicação abre na etapa **Início · Importar avaliação**. A importação sugere os
118
+ motivos desvalorizantes e preenche método, modelos, referências e valores na
119
+ seção **5. Avaliação**. Os dados continuam editáveis; revisar anos ou trocar o
120
+ arquivo atualiza os campos automáticos e preserva as alterações manuais. Os
121
+ valores adotados são reiniciados ao trocar de arquivo para não reutilizar valores
122
+ de outra avaliação.
123
+
124
+ As condições registradas na MESA são sugeridas como confirmadas, sem presumir
125
+ que tenham sido alegadas pelo contribuinte. Revise essas marcações na seção de
126
+ motivos. O catálogo de condições tem origem e versão da MESA; para sincronizá-lo,
127
+ execute `python scripts/sync_mesa_redutores.py /caminho/mesa-frame`.
128
+
129
+ Um valor de mercado editado é usado no cabeçalho e na conclusão; a memória do
130
+ laudo conserva também o valor registrado na avaliação de origem. Método, nome do
131
+ modelo e referências revisados são textos documentais; a identificação original
132
+ do modelo permanece disponível para a metodologia e os anexos.
133
+
134
+ A importação preenche modelo e valores de mercado a partir de um arquivo JSON
135
+ exportado pelo MESA:
136
+
137
+ - **`.ame` (versão 1):** uma ou várias avaliações por modelo; mantém entradas, valores adotados, intervalos, KNN, ajustes e fundamentação, sem incorporar a base do modelo.
138
+ - **`.evo` (versões 1 e 2):** composições evolutivas salvas com resultado válido. Importa separadamente terreno, construção e total adotado. Redutores e fator de comercialização podem fazer o total diferir da soma dos componentes.
139
+
140
+ A prévia identifica o ano pelos parâmetros anuais 0/1, títulos e referências temporais. Cenários com todos os anos em zero usam o ano-base indicado no título exportado; se o título for apenas “Ano Base”, o ano deve ser informado pelo usuário. Referências ausentes ou conflitantes também devem ser confirmadas. O usuário escolhe se os anos representam a data-base da avaliação ou o exercício do IPTU; o documento registra ambos. Anos duplicados são apresentados como avaliações separadas, sem somar valores. Para avaliações por modelo, o objeto avaliado pode ser identificado na prévia.
141
+
142
+ O arquivo é lido novamente no backend ao gerar o documento; os valores da prévia não substituem os resultados originais. Valores unitários sem total não são tratados como totais. Arquivos inválidos, versões desconhecidas e composições sem resultado são recusados. Limite de upload: 50 MB.
143
+
144
+ O laudo evolutivo apresenta no corpo as seções **3.3 Terreno**, **3.4 Construções** e **3.5 Composição do imóvel**, com parâmetros por exercício, variáveis e equação, áreas de modelo e aplicação, CUB histórico simples ou composto, adequação, BDI, Ross-Heidecke, residual, Kd, redutores e Fc. A seção 4 discrimina fundamentação dos componentes e global, com a precisão restrita ao terreno. A conclusão mostra o valor adotado e seu extenso; a composição conserva o resultado calculado.
145
+
146
+ Em **5. Avaliação**, a revisão recupera as justificativas públicas salvas e permite complementar VU, área, BDI por sequência, Fc, ajustes não aplicados e critério de adoção. É possível reutilizar deliberadamente as justificativas nos demais exercícios. Critérios de fundamentação não preenchidos continuam pendentes; observações internas não são publicadas. Conferências com os dados salvos apontam divergências sem corrigir os resultados.
147
+
148
+ Os anexos estatísticos EVO usam o modelo do catálogo (dataset da MESA no Hugging Face ou pasta local no desenvolvimento) somente quando coeficientes, covariância, transformações e amostra correspondem ao contexto preservado. Caso contrário, o documento registra a falta de correspondência e utiliza o contexto importado na narrativa. Resultados não são reestimados. As avaliações AME e o preenchimento manual mantêm seus fluxos próprios.
149
+
150
+ As explicações técnicas são extraídas das fontes da MESA por `node scripts/sync_mesa_documentacao.mjs`. O catálogo versionado é gravado em `config/mesa_documentacao.json` e em `mesa-frame/frontend/src/evolutivo/documentacaoTecnica.json`; as novas exportações EVO incorporam o mesmo pacote e conservam os pacotes já existentes. Arquivos antigos continuam importáveis, com indicação explícita do uso do catálogo sincronizado quando falta um pacote histórico. Para atualizar o catálogo de redutores, use `python scripts/sync_mesa_redutores.py`.
151
+
152
+ Endpoints adicionais:
153
+
154
+ - `POST /api/avaliacoes/importar`: multipart com `arquivo`; retorna a prévia.
155
+ - `POST /api/laudos/generate`: inclui `arquivo_avaliacoes` e as opções `importacao_avaliacoes` no JSON de `payload` (`anos`, `referencia_anos`, `objeto_modelo`, `valores_adotados`, `complementos`, `campos_editados`).
156
+
157
+ Validação: `python -m unittest discover -s tests -v` e `npm run build --prefix frontend`.
backend/app/avaliacoes_import.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Read MESA JSON exports without loading executable models or recalculating results."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from copy import deepcopy
7
+ from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
8
+ from pathlib import Path
9
+
10
+ from .import_defaults import motivos_da_avaliacao, sugestoes_documentais
11
+ from .evo_documentation import saved_complements, apply_complements, review_for
12
+ from document.mesa_content import current_content
13
+
14
+ MAX_FILE_BYTES = 50 * 1024 * 1024
15
+ YEAR = re.compile(r"(?<!\d)(?:19|20|21)\d{2}(?!\d)")
16
+
17
+
18
+ def record(value):
19
+ return value if isinstance(value, dict) else {}
20
+
21
+
22
+ def money(value):
23
+ if value is None or value == "" or isinstance(value, bool):
24
+ return None
25
+ try:
26
+ number = Decimal(str(value))
27
+ if not number.is_finite() or number < 0:
28
+ return None
29
+ return format(number.quantize(Decimal('.01'), rounding=ROUND_HALF_UP), '.2f')
30
+ except (InvalidOperation, ValueError):
31
+ return None
32
+
33
+
34
+ def first_money(*values):
35
+ return next((parsed for value in values if (parsed := money(value)) is not None), None)
36
+
37
+
38
+ def year_in(text):
39
+ years = set(YEAR.findall(str(text or '')))
40
+ return next(iter(years)) if len(years) == 1 else None
41
+
42
+
43
+ def parameter_year(values):
44
+ years = {str(key).strip(): str(value) for key, value in record(values).items()
45
+ if re.fullmatch(r'(?:19|20|21)\d{2}', str(key).strip())}
46
+ if not years:
47
+ return None, False, []
48
+ try:
49
+ nums = {key: Decimal(value) for key, value in years.items()}
50
+ except InvalidOperation:
51
+ return None, False, ['Há parâmetros anuais inválidos; informe o ano de referência.']
52
+ active = [key for key, value in nums.items() if value == 1]
53
+ if all(value == 0 for value in nums.values()):
54
+ return None, True, []
55
+ if len(active) == 1 and all(value in (0, 1) for value in nums.values()):
56
+ return active[0], False, []
57
+ return None, False, ['Os parâmetros anuais não identificam um único ano; confira a referência.']
58
+
59
+
60
+ def compact_evaluation(value):
61
+ source = record(value)
62
+ if source.get('valores_x') is not None and not isinstance(source['valores_x'], dict):
63
+ raise ValueError('Os parâmetros da avaliação devem ser um objeto JSON.')
64
+ names = ('valores_x', 'tipo_y', 'coluna_area', 'valor_area', 'estimado',
65
+ 'estimado_unitario', 'estimado_total', 'ca_inf', 'ca_sup', 'ic_inf',
66
+ 'ic_sup', 'perc_inf', 'perc_sup', 'amplitude', 'precisao', 'fundamentacao',
67
+ 'knn_estimado', 'knn_estimado_unitario', 'knn_estimado_total', 'extrapolacoes')
68
+ return {name: source[name] for name in names if name in source}
69
+
70
+
71
+ def ame_rows(data):
72
+ cards = data.get('avaliacoes')
73
+ # Accept the initial single-card AME layout as well as the current list.
74
+ if cards is None and isinstance(data.get('card'), dict):
75
+ cards = [{**data['card'], 'titulo': data.get('titulo'), 'nomeModelo': data.get('nomeModelo')}]
76
+ if not isinstance(cards, list) or not cards:
77
+ raise ValueError('O arquivo AME não contém avaliações.')
78
+ for index, item in enumerate(cards):
79
+ if not isinstance(item, dict):
80
+ raise ValueError('Avaliação inválida no arquivo AME.')
81
+ evaluation = record(item.get('avaliacao'))
82
+ title = str(item.get('titulo') or f'Avaliação {index + 1}')
83
+ adjusted = record(record(evaluation.get('ajuste_territorial')).get('resultado'))
84
+ total = first_money(adjusted.get('valor_total_imovel'), adjusted.get('valor_total_territorial'),
85
+ evaluation.get('estimado_total'),
86
+ evaluation.get('estimado') if evaluation.get('tipo_y') == 'total' else None)
87
+ if total is None:
88
+ raise ValueError(f'{title}: falta o valor total. Configure o tipo de valor e a área no MESA e exporte novamente; valor unitário não será tratado como total.')
89
+ year, base, warnings = parameter_year(evaluation.get('valores_x'))
90
+ label_year = year_in(title)
91
+ if year and label_year and year != label_year:
92
+ year = None
93
+ warnings.append('O título e o parâmetro anual divergem; informe o ano correto.')
94
+ elif not year and not warnings and (not base or YEAR.fullmatch(title.strip())):
95
+ year = label_year
96
+ yield {
97
+ 'id': f'ame-{index + 1}', 'titulo': title,
98
+ 'modelo': str(item.get('nomeModelo') or item.get('modelo') or evaluation.get('modelo_nome') or ''),
99
+ 'ano_sugerido': year, 'cenario_base': base, 'avisos': warnings,
100
+ 'valor_total': total,
101
+ 'valor_unitario': first_money(adjusted.get('valor_unitario_adotado'), evaluation.get('estimado_unitario')),
102
+ 'valor_terreno': None, 'valor_construcao': None,
103
+ 'area': evaluation.get('valor_area'),
104
+ 'avaliacao': compact_evaluation(evaluation),
105
+ 'fundamentacao': record(item.get('fundamentacaoCalculada')),
106
+ 'ajustes': record(evaluation.get('ajuste_territorial')),
107
+ 'motivos_desvalorizantes': motivos_da_avaliacao(item, evaluation),
108
+ }
109
+
110
+
111
+ def evo_rows(data):
112
+ years = data.get('anos')
113
+ if not isinstance(years, list) or not years:
114
+ raise ValueError('O arquivo EVO não contém avaliações.')
115
+ for index, item in enumerate(years):
116
+ if not isinstance(item, dict):
117
+ raise ValueError('Composição inválida no arquivo EVO.')
118
+ terrain, construction = record(item.get('terreno')), record(item.get('construcao'))
119
+ for container, keys in [(item,('catalogoAjustes','fundamentacaoGlobal','documentacaoTecnica','complementosDocumentais')),
120
+ (terrain,('contextoModelo','parametrosAplicados','parametrosOriginais','fundamentacaoModelo','avaliacao')),
121
+ (construction,('avaliacao','fundamentacaoMqc'))]:
122
+ if any(container.get(key) is not None and not isinstance(container[key],dict) for key in keys):
123
+ raise ValueError('Os dados documentais da avaliação evolutiva devem ser objetos JSON.')
124
+ result = record(item.get('resultadoComposicao'))
125
+ title = str(item.get('identificacao') or f'Avaliação evolutiva {index + 1}')
126
+ if not terrain or not construction or not result or result.get('erros') or not isinstance(construction.get('construcoes', []), list):
127
+ raise ValueError(f'{title}: composição incompleta ou sem resultado válido. Recalcule no MESA antes de exportar.')
128
+ total = first_money(result.get('valorTotal'))
129
+ terrain_total = first_money(result.get('valorTerrenoPgv'), terrain.get('valorTotal'))
130
+ construction_total = first_money(result.get('valorConstrucao'), construction.get('valorTotal'))
131
+ if any(value is None for value in (total, terrain_total, construction_total)):
132
+ raise ValueError(f'{title}: faltam valores de terreno, construção ou total adotado.')
133
+ evaluation = record(terrain.get('avaliacao'))
134
+ year, base, warnings = parameter_year(terrain.get('parametrosAplicados') or evaluation.get('valores_x'))
135
+ reference = year_in(item.get('referencia') or item.get('ano'))
136
+ construction_year = year_in(construction.get('anoReferencia'))
137
+ inferred = {value for value in (year, reference, construction_year) if value}
138
+ if len(inferred) > 1:
139
+ year = None
140
+ warnings.append('As referências do terreno, construção ou composição divergem; confirme o ano adotado.')
141
+ else:
142
+ year = next(iter(inferred), None)
143
+ if base and not reference:
144
+ year = None
145
+ warnings.append('O terreno está no cenário com todos os anos em zero. Informe o ano-base dessa composição; o ano do CUB não define sozinho o ano-base do terreno.')
146
+ if not item.get('concluida'):
147
+
148
+ warnings.append('A composição foi salva sem a marca de conclusão no MESA.')
149
+ yield {
150
+ 'id': f'evo-{index + 1}', 'titulo': title, 'modelo': str(terrain.get('modelo') or ''),
151
+ 'ano_sugerido': year, 'cenario_base': base and not reference,
152
+ 'avisos': warnings, 'valor_total': total, 'valor_terreno': terrain_total,
153
+ 'valor_construcao': construction_total, 'valor_unitario': first_money(result.get('valorUnitarioTerreno'), terrain.get('valorUnitario')),
154
+ 'id_origem': item.get('id'),
155
+ 'evolutivo': deepcopy(item),
156
+ 'complementos': saved_complements(item),
157
+ 'revisao_documental': review_for(item, saved_complements(item)),
158
+ 'area': terrain.get('areaAplicacao') if terrain.get('areaAplicacao') is not None else terrain.get('area'),
159
+ 'area_construida': construction.get('area'), 'avaliacao': compact_evaluation(evaluation),
160
+ 'construcoes': deepcopy([row for row in construction.get('construcoes', []) if isinstance(row, dict)]),
161
+ 'fator_comercializacao': result.get('fatorFcAplicado', item.get('fatorComercializacao')),
162
+ 'valor_antes_redutores_imovel': result.get('valorAposConstrucao'),
163
+ 'valor_apos_redutores_imovel': result.get('valorAposRedutoresImovel'),
164
+ 'observacoes': str(item.get('observacoes') or ''),
165
+ 'motivos_desvalorizantes': motivos_da_avaliacao(item, evaluation, item.get('catalogoAjustes')),
166
+ }
167
+
168
+
169
+ def parse_export(content: bytes, filename: str):
170
+ extension = Path(filename).suffix.lower()
171
+ if extension not in ('.ame', '.evo'):
172
+ raise ValueError('Envie um arquivo .ame ou .evo.')
173
+ if len(content) > MAX_FILE_BYTES:
174
+ raise ValueError('O arquivo excede o limite de 50 MB.')
175
+ try:
176
+ data = json.loads(content.decode('utf-8-sig'), parse_constant=lambda value: (_ for _ in ()).throw(ValueError(value)))
177
+ except (UnicodeError, ValueError, RecursionError) as exc:
178
+ raise ValueError('O arquivo não contém JSON válido.') from exc
179
+ if not isinstance(data, dict):
180
+ raise ValueError('Estrutura de arquivo inválida.')
181
+ if extension == '.ame':
182
+ if data.get('formato') != 'ame' or data.get('versao') != 1:
183
+ raise ValueError('Formato ou versão AME não suportado.')
184
+ rows, kind = list(ame_rows(data)), 'modelo'
185
+ else:
186
+ if data.get('format') != 'mesa-evolutivo' or data.get('version') not in (1, 2):
187
+ raise ValueError('Formato ou versão EVO não suportado.')
188
+ rows, kind = list(evo_rows(data)), 'evolutivo'
189
+ return {'arquivo': Path(filename).name, 'tipo': kind, 'avaliacoes': rows,
190
+ 'conteudo_tecnico': {'identificador': current_content()['identificador'], 'versao': current_content()['versao']},
191
+ 'sugestoes': sugestoes_documentais(rows, kind)}
192
+
193
+
194
+ def resolve_import(parsed, choices):
195
+ if choices.referencia_anos not in ('ano_base', 'exercicio_iptu'):
196
+ raise ValueError('Selecione a interpretação dos anos.')
197
+ known_ids = {source['id'] for source in parsed['avaliacoes']}
198
+ if set(choices.valores_adotados) - known_ids:
199
+ raise ValueError('Há valores adotados para avaliações que não pertencem ao arquivo.')
200
+ if set(choices.complementos) - known_ids:
201
+ raise ValueError('Há complementos para avaliações que não pertencem ao arquivo.')
202
+ rows = []
203
+ for source in parsed['avaliacoes']:
204
+ selected = choices.anos[source['id']] if source['id'] in choices.anos else source['ano_sugerido']
205
+ year = str(selected or '').strip()
206
+ if not re.fullmatch(r'(?:19|20|21)\d{2}', year):
207
+ raise ValueError(f"Informe o ano de referência para {source['titulo']}.")
208
+ base = int(year) if choices.referencia_anos == 'ano_base' else int(year) - 1
209
+ warnings = [warning for warning in source['avisos']
210
+ if not any(word in warning.lower() for word in ('informe', 'confira', 'confirme'))]
211
+ if not source['ano_sugerido']:
212
+ warnings.append('Referência temporal definida pelo usuário durante a importação.')
213
+ original_title = source['titulo']
214
+ title = f'Exercício {base + 1}'
215
+ if source['cenario_base']:
216
+ title += ' — Cenário base'
217
+ elif not re.fullmatch(r'(?:19|20|21)\d{2}', original_title.strip()):
218
+ title += f' — {original_title}'
219
+ rows.append({**source, 'titulo_origem': original_title, 'titulo': title,
220
+ 'avisos': warnings, 'ano_base': str(base), 'ano': str(base + 1),
221
+ 'objeto': 'Terreno e construção' if parsed['tipo'] == 'evolutivo' else choices.objeto_modelo.strip() or 'Objeto avaliado por modelo'})
222
+ if parsed['tipo'] == 'evolutivo':
223
+ complements = deepcopy(source['complementos'])
224
+ if source['id'] in choices.complementos:
225
+ override = choices.complementos[source['id']]
226
+ changes = override.model_dump(exclude_unset=True)
227
+ for key, value in changes.items():
228
+ if key in ('bdi', 'requisitos_grau_iii'):
229
+ complements[key] = {**complements.get(key, {}), **value}
230
+ else:
231
+ complements[key] = value
232
+ rows[-1]['complementos'] = complements
233
+ rows[-1]['evolutivo'] = apply_complements(source['evolutivo'], complements)
234
+ rows[-1]['revisao_documental'] = review_for(rows[-1]['evolutivo'], complements)
235
+ if source['id'] in choices.valores_adotados:
236
+ adopted = money(choices.valores_adotados[source['id']])
237
+ if adopted is None:
238
+ raise ValueError(f"Informe um valor adotado válido para {source['titulo']}.")
239
+ if adopted != source['valor_total']:
240
+ rows[-1]['valor_total_origem'] = source['valor_total']
241
+ rows[-1]['valor_total'] = adopted
242
+ rows.sort(key=lambda item: (int(item['ano_base']), item['titulo']))
243
+ from collections import Counter
244
+ occurrences = Counter(item['ano_base'] for item in rows)
245
+ for row in rows:
246
+ row['referencia_duplicada'] = occurrences[row['ano_base']] > 1
247
+ return {**parsed, 'avaliacoes': rows}
backend/app/evo_documentation.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Saved case-specific reasons and missing documentary evidence for EVO review."""
2
+ from copy import deepcopy
3
+ from document.mesa_content import content_for
4
+ from document.fundamentacao import global_foundation, POINTS, number
5
+ from decimal import Decimal, ROUND_HALF_UP
6
+ from models.construction_identity import construction_identity
7
+
8
+
9
+ def construction_key(row, index):
10
+ return str(row.get('id') or row.get('seq') or index+1)
11
+
12
+
13
+ def public_reason(obj, *keys):
14
+ for key in keys:
15
+ if obj.get(key + 'Interna') or obj.get(key + '_interna'):
16
+ continue
17
+ value = obj.get(key)
18
+ if isinstance(value, str) and value.strip():
19
+ return value.strip()
20
+ return ''
21
+
22
+
23
+ def saved_complements(source):
24
+ terrain = source.get('terreno') or {}
25
+ evaluation = terrain.get('avaliacao') or {}
26
+ ca = (evaluation.get('ajuste_territorial') or {}).get('configuracao', {}).get('campo_arbitrio') or {}
27
+ manual = terrain.get('fundamentacaoModelo') or {}
28
+ construction = source.get('construcao') or {}
29
+ global_state = source.get('fundamentacaoGlobal') or {}
30
+ result = {
31
+ 'justificativa_vu': public_reason(ca, 'justificativa') or public_reason(terrain, 'justificativaVu', 'justificativaValorUnitario'),
32
+ 'justificativa_area': public_reason(terrain, 'justificativaArea', 'areaAplicacaoJustificativa'),
33
+ 'justificativa_fc': public_reason(global_state, 'justificativa', 'justificativaFc') or public_reason(source, 'justificativaFc', 'fatorComercializacaoJustificativa'),
34
+ 'ajustes_nao_aplicados': public_reason(source, 'ajustesNaoAplicados'),
35
+ 'criterio_adocao': public_reason(source, 'criterioAdocao'),
36
+ 'fc_criterio': global_state.get('fatorComercializacao') or '',
37
+ 'bdi': {construction_key(row, i): public_reason(row, 'bdiJustificativa', 'justificativaBdi') for i, row in enumerate(construction.get('construcoes') or [])},
38
+ 'caracterizacao_grau': (manual.get('caracterizacao') or {}).get('grau',''),
39
+ 'caracterizacao_justificativa': public_reason(manual.get('caracterizacao') or {}, 'justificativa'),
40
+ 'identificacao_grau': (manual.get('identificacaoDados') or {}).get('grau',''),
41
+ 'identificacao_justificativa': public_reason(manual.get('identificacaoDados') or {}, 'justificativa'),
42
+ 'requisitos_grau_iii': manual.get('requisitosGrauIII') or {},
43
+ }
44
+ # Future EVO files may preserve these exact documentary fields as a package.
45
+ saved = source.get('complementosDocumentais') or {}
46
+ for key in result:
47
+ if saved.get(key): result[key] = saved[key]
48
+ return result
49
+
50
+
51
+ def apply_complements(source, values):
52
+ source = deepcopy(source)
53
+ terrain = source.setdefault('terreno', {})
54
+ state = terrain.setdefault('fundamentacaoModelo', {})
55
+ for prefix, key in [('caracterizacao','caracterizacao'),('identificacao','identificacaoDados')]:
56
+ selected = values.get(prefix+'_grau', '')
57
+ if selected and selected not in POINTS:
58
+ raise ValueError('Grau de fundamentação inválido.')
59
+ state[key] = {'grau':selected,'justificativa':values.get(prefix+'_justificativa','')}
60
+ allowed = {item['value'] for item in content_for(source)['criterios_fc']}
61
+ if values.get('fc_criterio') and values['fc_criterio'] not in allowed:
62
+ raise ValueError('Critério do fator de comercialização inválido.')
63
+ reqs = values.get('requisitos_grau_iii') or {}
64
+ allowed_reqs = {item['key'] for item in content_for(source)['requisitos_modelo']}
65
+ if set(reqs) - allowed_reqs or any(v not in ('','sim','nao',True,False) for v in reqs.values()):
66
+ raise ValueError('Requisitos do Grau III inválidos.')
67
+ state['requisitosGrauIII'] = reqs
68
+ source.setdefault('fundamentacaoGlobal', {})['fatorComercializacao'] = values.get('fc_criterio','')
69
+ source['complementosDocumentais'] = deepcopy(values)
70
+ return source
71
+
72
+
73
+ def review_for(source, complements):
74
+ terrain = source.get('terreno') or {}
75
+ area_model = terrain.get('areaModelo', (terrain.get('avaliacao') or {}).get('valor_area'))
76
+ area_apply = terrain.get('areaAplicacao', terrain.get('area'))
77
+ pending = []
78
+ for key, label, required in [
79
+ ('justificativa_vu','Escolha do valor unitário / campo de arbítrio',True),
80
+ ('justificativa_area','Diferença entre área do modelo e área de aplicação',number(area_model) != number(area_apply)),
81
+ ('justificativa_fc','Escolha do fator de comercialização',True),
82
+ ]:
83
+ if required and not complements.get(key): pending.append(label)
84
+ constructions = []
85
+ construction = source.get('construcao') or {}
86
+ for index, row in enumerate(construction.get('construcoes') or []):
87
+ key = construction_key(row,index)
88
+ name = row.get('nome') or f"Construção {row.get('seq') or index+1} — {row.get('tipoConstrucao') or 'tipo não informado'}"
89
+ constructions.append({'id':key,'nome':name,'bdi':row.get('bdi'),'grupo':construction_identity(source,row,index)})
90
+ if not complements.get('bdi',{}).get(key): pending.append(f'BDI: {name}')
91
+ foundation = global_foundation(source)
92
+ for label, item in [('Terreno',foundation['terrain']),('Construções',foundation['construction']),('Global',foundation)]:
93
+ if item['grade'] == 'Pendente': pending.append(f'Fundamentação {label.lower()}: há critérios não informados')
94
+ return {'pendencias':pending,'construcoes':constructions,'area_modelo':area_model,'area_aplicacao':area_apply,
95
+ 'conferencias': check_saved_results(source),
96
+ 'fundamentacao': {k:foundation[k]['grade'] for k in ['terrain','construction']} | {'global':foundation['grade']},
97
+ 'criterios_fc':content_for(source)['criterios_fc'],'requisitos_modelo':content_for(source)['requisitos_modelo']}
98
+
99
+
100
+ def check_saved_results(source):
101
+ """Report inconsistencies using saved precision; never repair source values."""
102
+ warnings = []
103
+ result = source.get('resultadoComposicao') or {}
104
+ def check(label, expected, actual):
105
+ actual = number(actual)
106
+ if expected is not None and actual is not None:
107
+ expected = expected.quantize(Decimal('.01'),rounding=ROUND_HALF_UP)
108
+ if abs(expected-actual) > Decimal('.02'):
109
+ warnings.append(f'{label}: os dados salvos não reproduzem o resultado registrado (conferência {expected}; registro {actual}). O resultado de origem foi preservado.')
110
+ terrain = source.get('terreno') or {}
111
+ vu = number(result.get('valorUnitarioTerreno',result.get('valorCa',terrain.get('valorUnitario'))))
112
+ area = number(terrain.get('areaAplicacao',terrain.get('area')))
113
+ check('Valor territorial',vu*area if vu is not None and area is not None else None,result.get('valorTerrenoPgv'))
114
+ construction_values = []
115
+ for i, item in enumerate((source.get('construcao') or {}).get('construcoes') or []):
116
+ a,c,bdi,kd = [number(item.get(key)) for key in ['areaCalculada','custoUnitario','bdi','kd']]
117
+ check(f"Construção {item.get('seq') or i+1}",a*c*(1+bdi/100)*kd if all(v is not None for v in (a,c,bdi,kd)) else None,item.get('vcalcConstSeq'))
118
+ construction_values.append(number(item.get('vcalcConstSeq')))
119
+ if construction_values and all(v is not None for v in construction_values):
120
+ check('Total das construções',sum(construction_values),result.get('valorConstrucao'))
121
+ base,fc = number(result.get('valorAposRedutoresImovel')),number(result.get('fatorFcAplicado'))
122
+ check('Aplicação final do Fc',base*fc if base is not None and fc is not None else None,result.get('valorTotalCalculado'))
123
+ return warnings
backend/app/import_defaults.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sugestões documentais a partir das condições registradas nas avaliações."""
2
+ from functools import lru_cache
3
+ import json
4
+ from pathlib import Path
5
+ from config.constants import METODO_COMPARATIVO, METODO_EVOLUTIVO
6
+
7
+
8
+ def record(value):
9
+ return value if isinstance(value, dict) else {}
10
+
11
+
12
+ @lru_cache(maxsize=1)
13
+ def catalogo_mesa():
14
+ path = Path(__file__).resolve().parents[2] / "config/mesa_redutores.json"
15
+ return json.loads(path.read_text(encoding="utf-8"))["catalogo"]
16
+
17
+
18
+ def positivo(value):
19
+ try:
20
+ return float(value) > 0
21
+ except (TypeError, ValueError):
22
+ return False
23
+
24
+
25
+ def motivos_da_avaliacao(item, evaluation, catalog=None):
26
+ catalog = record(catalog) or catalogo_mesa()
27
+ ajuste = record(record(evaluation).get("ajuste_territorial"))
28
+ config = record(ajuste.get("configuracao")) or ajuste
29
+ redutores = record(ajuste.get("redutores"))
30
+ reasons = {}
31
+
32
+ def add(description, alleged=False, confirmed=True):
33
+ description = str(description or "").strip()
34
+ if not description:
35
+ return
36
+ key = description.casefold()
37
+ previous = reasons.get(key, {})
38
+ reasons[key] = {"descricao": description,
39
+ "alegado": bool(alleged or previous.get("alegado")),
40
+ "confirmado": bool(confirmed or previous.get("confirmado"))}
41
+
42
+ for reason in item.get("motivos_desvalorizantes", []) if isinstance(item.get("motivos_desvalorizantes"), list) else []:
43
+ if isinstance(reason, dict):
44
+ add(reason.get("descricao"), reason.get("alegado", False), reason.get("confirmado", False))
45
+
46
+ def selected(group, codes, subkey="condicoes"):
47
+ if not isinstance(codes, (list, tuple, set)):
48
+ return
49
+ for condition in record(catalog.get(group)).get(subkey, []):
50
+ if condition.get("codigo") in codes:
51
+ add(condition.get("rotulo"))
52
+
53
+ conditions = record(record(config.get("art_88_i")).get("condicoes"))
54
+ details = record(record(redutores.get("art_88")).get("condicoes_inciso_i"))
55
+ for condition in record(catalog.get("art_88_i")).get("condicoes", []):
56
+ code = condition["codigo"]
57
+ values = record(conditions.get(code))
58
+ detail = record(details.get(code))
59
+ if any(positivo(value) for value in [values.get("percentual_area"),
60
+ values.get("percentual_area_considerada"), values.get("percentual_area_bruta"),
61
+ detail.get("percentual_area_informado")]):
62
+ add(condition.get("rotulo"))
63
+ selected("art_88_ii", record(config.get("art_88_ii")).get("selecionados",
64
+ record(redutores.get("art_88")).get("selecionados_inciso_ii_informados", [])))
65
+ selected("art_60_61", record(config.get("art_60_61")).get("selecionados",
66
+ record(redutores.get("art_60_61")).get("selecionados", [])))
67
+ topography = record(config.get("topografia")) or record(redutores.get("topografia"))
68
+ for key in ("relevo", "nivel_rua"):
69
+ selected("topografia", [topography.get(key)], key)
70
+ water = record(config.get("agua")) or record(redutores.get("agua"))
71
+ selected("agua", [water.get("situacao")])
72
+ apan = record(config.get("apan")) or record(redutores.get("apan"))
73
+ if positivo(apan.get("percentual_area")):
74
+ add("APAN")
75
+ return list(reasons.values())
76
+
77
+
78
+ def sugestoes_documentais(rows, kind):
79
+ reasons = {}
80
+ for row in rows:
81
+ for reason in row.get("motivos_desvalorizantes", []):
82
+ key = reason["descricao"].casefold()
83
+ previous = reasons.get(key, {})
84
+ reasons[key] = {**reason, "alegado": reason["alegado"] or previous.get("alegado", False),
85
+ "confirmado": reason["confirmado"] or previous.get("confirmado", False)}
86
+ return {
87
+ "motivos_desvalorizantes": list(reasons.values()),
88
+ "metodo_avaliacao": METODO_EVOLUTIVO if kind == "evolutivo" else METODO_COMPARATIVO,
89
+ "modelo_avaliacao": ", ".join(dict.fromkeys(row["modelo"] for row in rows if row["modelo"])),
90
+ }
backend/app/main.py CHANGED
@@ -10,6 +10,9 @@ from fastapi.staticfiles import StaticFiles
10
  from pydantic import ValidationError
11
 
12
  from config.settings import OUTPUT_DIR
 
 
 
13
 
14
  from .runtime import CORS_ORIGINS, FRONTEND_DIST_DIR, HOST, PORT
15
  from .schemas import (
@@ -44,6 +47,11 @@ if CORS_ORIGINS:
44
  )
45
 
46
 
 
 
 
 
 
47
  @app.get("/api/health")
48
  def health() -> dict:
49
  return {"status": "ok"}
@@ -64,6 +72,8 @@ def models() -> dict:
64
  def model_columns(modelo_nome: str) -> dict:
65
  try:
66
  return get_model_columns_payload(modelo_nome)
 
 
67
  except ValueError as exc:
68
  raise HTTPException(status_code=400, detail=str(exc)) from exc
69
  except Exception as exc:
@@ -93,10 +103,19 @@ def market_values_generate(payload: MarketValuesRangeRequest) -> dict:
93
  raise HTTPException(status_code=400, detail=f"Intervalo de anos inválido: {exc}") from exc
94
 
95
 
 
 
 
 
 
 
 
 
96
  @app.post("/api/laudos/generate", response_model=LaudoGenerateResponse)
97
  async def laudos_generate(
98
  payload: str = Form(...),
99
  registro_fotografico: UploadFile | None = File(default=None),
 
100
  ) -> dict:
101
  try:
102
  payload_obj = LaudoFormInput.model_validate_json(payload)
@@ -111,7 +130,9 @@ async def laudos_generate(
111
  )
112
 
113
  try:
114
- output_path = generate_laudo_document(payload_obj, registro_fotografico)
 
 
115
  except ValueError as exc:
116
  raise HTTPException(status_code=400, detail=str(exc)) from exc
117
  except Exception as exc:
 
10
  from pydantic import ValidationError
11
 
12
  from config.settings import OUTPUT_DIR
13
+ from models.hf_repository import ModelRepositoryError
14
+
15
+ from .avaliacoes_import import MAX_FILE_BYTES, parse_export
16
 
17
  from .runtime import CORS_ORIGINS, FRONTEND_DIST_DIR, HOST, PORT
18
  from .schemas import (
 
47
  )
48
 
49
 
50
+ @app.exception_handler(ModelRepositoryError)
51
+ async def model_repository_error(request, exc):
52
+ return JSONResponse(status_code=503, content={'detail': str(exc)})
53
+
54
+
55
  @app.get("/api/health")
56
  def health() -> dict:
57
  return {"status": "ok"}
 
72
  def model_columns(modelo_nome: str) -> dict:
73
  try:
74
  return get_model_columns_payload(modelo_nome)
75
+ except ModelRepositoryError:
76
+ raise
77
  except ValueError as exc:
78
  raise HTTPException(status_code=400, detail=str(exc)) from exc
79
  except Exception as exc:
 
103
  raise HTTPException(status_code=400, detail=f"Intervalo de anos inválido: {exc}") from exc
104
 
105
 
106
+ @app.post("/api/avaliacoes/importar")
107
+ async def avaliacoes_importar(arquivo: UploadFile = File(...)) -> dict:
108
+ try:
109
+ return parse_export(await arquivo.read(MAX_FILE_BYTES + 1), arquivo.filename or "")
110
+ except ValueError as exc:
111
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
112
+
113
+
114
  @app.post("/api/laudos/generate", response_model=LaudoGenerateResponse)
115
  async def laudos_generate(
116
  payload: str = Form(...),
117
  registro_fotografico: UploadFile | None = File(default=None),
118
+ arquivo_avaliacoes: UploadFile | None = File(default=None),
119
  ) -> dict:
120
  try:
121
  payload_obj = LaudoFormInput.model_validate_json(payload)
 
130
  )
131
 
132
  try:
133
+ output_path = generate_laudo_document(payload_obj, registro_fotografico, arquivo_avaliacoes)
134
+ except ModelRepositoryError:
135
+ raise
136
  except ValueError as exc:
137
  raise HTTPException(status_code=400, detail=str(exc)) from exc
138
  except Exception as exc:
backend/app/schemas.py CHANGED
@@ -17,7 +17,33 @@ class ValorMercadoInput(BaseModel):
17
  valor: str = ""
18
 
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  class LaudoFormInput(BaseModel):
 
 
21
  numero_laudo: str = ""
22
  numero_processo: str = ""
23
  data_laudo: str = ""
 
17
  valor: str = ""
18
 
19
 
20
+ class ComplementosAvaliacaoInput(BaseModel):
21
+ justificativa_vu: str = ""
22
+ justificativa_area: str = ""
23
+ justificativa_fc: str = ""
24
+ ajustes_nao_aplicados: str = ""
25
+ criterio_adocao: str = ""
26
+ bdi: dict[str, str] = Field(default_factory=dict)
27
+ caracterizacao_grau: str = ""
28
+ caracterizacao_justificativa: str = ""
29
+ identificacao_grau: str = ""
30
+ identificacao_justificativa: str = ""
31
+ requisitos_grau_iii: dict[str, str | bool] = Field(default_factory=dict)
32
+ fc_criterio: str = ""
33
+
34
+
35
+ class ImportacaoAvaliacoesInput(BaseModel):
36
+ referencia_anos: str = "ano_base"
37
+ anos: dict[str, str] = Field(default_factory=dict)
38
+ objeto_modelo: str = "Objeto avaliado por modelo"
39
+ valores_adotados: dict[str, str] = Field(default_factory=dict)
40
+ campos_editados: List[str] = Field(default_factory=list)
41
+ complementos: dict[str, ComplementosAvaliacaoInput] = Field(default_factory=dict)
42
+
43
+
44
  class LaudoFormInput(BaseModel):
45
+ importacao_avaliacoes: Optional[ImportacaoAvaliacoesInput] = None
46
+
47
  numero_laudo: str = ""
48
  numero_processo: str = ""
49
  data_laudo: str = ""
backend/app/services.py CHANGED
@@ -1,6 +1,7 @@
1
  """Serviços de negócio do backend."""
2
  from __future__ import annotations
3
 
 
4
  from datetime import datetime
5
  from pathlib import Path
6
  from tempfile import NamedTemporaryFile
@@ -14,6 +15,8 @@ from config.constants import (
14
  MAX_MOTIVOS_DESVALORIZANTES,
15
  MAX_VALORES_MERCADO,
16
  METODOS_AVALIACAO,
 
 
17
  TECNICOS_RESPONSAVEIS,
18
  TIPOS_REQUERIMENTO,
19
  UNIDADES_DEMANDANTES,
@@ -29,6 +32,7 @@ from utils.formatters import (
29
  formatar_valores_mercado_para_documento,
30
  )
31
 
 
32
  from .runtime import DEFAULT_FILL_DUMMY, ENABLE_DUMMY_DATA
33
  from .schemas import LaudoFormInput
34
 
@@ -236,6 +240,7 @@ def _normalizar_lista_strings(values: List[str]) -> List[str]:
236
  def _build_generator_payload(
237
  payload: LaudoFormInput,
238
  registro_fotografico_path: str | None,
 
239
  ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
240
  valor_formatado, valor_extenso = formatar_valor_monetario(payload.valor_proposto)
241
  valor_proposto_completo = (
@@ -271,7 +276,7 @@ def _build_generator_payload(
271
 
272
  colunas_banco_dados = (
273
  _validar_colunas_banco_dados(payload.modelo_avaliacao, payload.banco_dados_colunas)
274
- if payload.banco_dados_colunas is not None
275
  else []
276
  )
277
 
@@ -322,12 +327,36 @@ def _build_generator_payload(
322
  "registro_fotografico_path": registro_fotografico_path,
323
  }
324
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  return dados, motivos_formatados
326
 
327
 
328
  def generate_laudo_document(
329
  payload: LaudoFormInput,
330
  registro_fotografico: UploadFile | None,
 
331
  ) -> Path:
332
  temp_registro_path: str | None = None
333
  try:
@@ -338,7 +367,16 @@ def generate_laudo_document(
338
  temp_file.write(registro_fotografico.file.read())
339
  temp_registro_path = temp_file.name
340
 
341
- dados, motivos_formatados = _build_generator_payload(payload, temp_registro_path)
 
 
 
 
 
 
 
 
 
342
  output_path = LaudoGenerator().gerar(dados, motivos_formatados)
343
 
344
  if output_path is None:
 
1
  """Serviços de negócio do backend."""
2
  from __future__ import annotations
3
 
4
+ from collections import Counter
5
  from datetime import datetime
6
  from pathlib import Path
7
  from tempfile import NamedTemporaryFile
 
15
  MAX_MOTIVOS_DESVALORIZANTES,
16
  MAX_VALORES_MERCADO,
17
  METODOS_AVALIACAO,
18
+ METODO_COMPARATIVO,
19
+ METODO_EVOLUTIVO,
20
  TECNICOS_RESPONSAVEIS,
21
  TIPOS_REQUERIMENTO,
22
  UNIDADES_DEMANDANTES,
 
32
  formatar_valores_mercado_para_documento,
33
  )
34
 
35
+ from .avaliacoes_import import MAX_FILE_BYTES, parse_export, resolve_import
36
  from .runtime import DEFAULT_FILL_DUMMY, ENABLE_DUMMY_DATA
37
  from .schemas import LaudoFormInput
38
 
 
240
  def _build_generator_payload(
241
  payload: LaudoFormInput,
242
  registro_fotografico_path: str | None,
243
+ importacao: dict | None = None,
244
  ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
245
  valor_formatado, valor_extenso = formatar_valor_monetario(payload.valor_proposto)
246
  valor_proposto_completo = (
 
276
 
277
  colunas_banco_dados = (
278
  _validar_colunas_banco_dados(payload.modelo_avaliacao, payload.banco_dados_colunas)
279
+ if payload.banco_dados_colunas is not None and importacao is None
280
  else []
281
  )
282
 
 
327
  "registro_fotografico_path": registro_fotografico_path,
328
  }
329
 
330
+ if importacao:
331
+ rows = importacao['avaliacoes']
332
+ models = list(dict.fromkeys(row['modelo'] for row in rows if row['modelo']))
333
+ dados['avaliacoes_importadas'] = importacao
334
+ dados['modelo_avaliacao'] = ', '.join(models)
335
+ dados['metodo_avaliacao'] = METODO_EVOLUTIVO if importacao['tipo'] == 'evolutivo' else METODO_COMPARATIVO
336
+ dados['datas_referencia'] = ', '.join(dict.fromkeys(
337
+ f"Dezembro/{row['ano_base']}" for row in rows))
338
+ dados['valores_mercado_lista'] = []
339
+ exercicios = Counter(row['ano'] for row in rows)
340
+ anos = [row['ano'] if exercicios[row['ano']] == 1
341
+ else f"{row['ano']} ({row['titulo_origem']})" for row in rows]
342
+ valores = [formatar_valor_monetario(row['valor_total'].replace('.', ','))[0]
343
+ for row in rows]
344
+ dados['valores_mercado'] = formatar_valores_mercado_para_documento(anos, valores)
345
+ # Apenas as alterações explícitas da revisão substituem os metadados
346
+ # documentais. O modelo e os resultados de origem continuam no arquivo.
347
+ for campo in ('metodo_avaliacao', 'modelo_avaliacao', 'datas_referencia'):
348
+ if payload.importacao_avaliacoes and campo in payload.importacao_avaliacoes.campos_editados:
349
+ revisado = _resolver_dropdown(payload.metodo_avaliacao, payload.metodo_avaliacao_custom) if campo == 'metodo_avaliacao' else getattr(payload, campo)
350
+ if campo != 'metodo_avaliacao' or revisado not in ('Método evolutivo', 'Método comparativo direto de dados de mercado por modelo estatístico'):
351
+ dados[campo] = revisado
352
+
353
  return dados, motivos_formatados
354
 
355
 
356
  def generate_laudo_document(
357
  payload: LaudoFormInput,
358
  registro_fotografico: UploadFile | None,
359
+ arquivo_avaliacoes: UploadFile | None = None,
360
  ) -> Path:
361
  temp_registro_path: str | None = None
362
  try:
 
367
  temp_file.write(registro_fotografico.file.read())
368
  temp_registro_path = temp_file.name
369
 
370
+ importacao = None
371
+ if arquivo_avaliacoes and arquivo_avaliacoes.filename:
372
+ if payload.importacao_avaliacoes is None:
373
+ raise ValueError('Informe a correspondência dos anos da avaliação importada.')
374
+ arquivo_avaliacoes.file.seek(0)
375
+ parsed = parse_export(arquivo_avaliacoes.file.read(MAX_FILE_BYTES + 1), arquivo_avaliacoes.filename)
376
+ importacao = resolve_import(parsed, payload.importacao_avaliacoes)
377
+ elif payload.importacao_avaliacoes is not None:
378
+ raise ValueError('Envie o arquivo .ame ou .evo da avaliação.')
379
+ dados, motivos_formatados = _build_generator_payload(payload, temp_registro_path, importacao)
380
  output_path = LaudoGenerator().gerar(dados, motivos_formatados)
381
 
382
  if output_path is None:
config/constants.py CHANGED
@@ -50,6 +50,9 @@ METODOS_AVALIACAO = [
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;
 
50
 
51
  # --- Textos padrão ---
52
 
53
+ METODO_COMPARATIVO = "Método Comparativo Dados de Mercado - ABNT NBR 14.653-2"
54
+ METODO_EVOLUTIVO = METODO_COMPARATIVO + " e Método de Quantificação de Custo"
55
+
56
  TEXTO_OBSERVACOES_COMPLEMENTARES = """- Pressupõe-se que todas as informações fornecidas por terceiros merecem credibilidade;
57
 
58
  - Considerou-se que os documentos e/ou títulos de propriedade do avaliando espelham a realidade;
config/mesa_documentacao.json ADDED
@@ -0,0 +1,818 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "versao": 1,
3
+ "fontes": {
4
+ "frontend/src/mqc/data/referenceData.js": "b57ca605c1505d28d895142704b81893d369ee67fca702d7a042e9aa74310616",
5
+ "frontend/src/mqc/wordExportOptions.js": "66210e6d0d8f99b776551050890f4e7159f37eb2fa916341f6d21bff93f09e71",
6
+ "frontend/src/components/NormativeReference.jsx": "8fe15de448046093efa83ea3c1f8fc7a0f4147c8d3f95eaef52af0882338b4f5",
7
+ "frontend/src/components/RedutoresAvaliacaoModal.jsx": "40f2a918ccdf5282f13e7e25e4141e6153e2d60eb670dda8733b1572d790454c",
8
+ "frontend/src/mqc/calculations.js": "ab4cabf7243e4cf52ace67e2280545b399b465d9ed7104ed989d31ad08839e4e",
9
+ "frontend/src/modelFoundation.js": "69e8893a8506c6c7c33277a5183c8f22369318cd10e512799ee3ac2bef1c187a",
10
+ "frontend/src/globalFoundation.js": "24b630bce2a805e8506b714d627d7cdddd7aa4c3b0d34978496e3b3d52fb6972",
11
+ "frontend/src/mqc/exporters.js": "467251f8372625f54980f8424184eb250ec65c724936cad9f9567f925658cfbf"
12
+ },
13
+ "notas": {
14
+ "sequence": "Const.: número da sequência construtiva.",
15
+ "year": "Ano (base): ano de construção.",
16
+ "cubGroup": "CUB (R$ / m²): grupo dos dados relacionados ao custo unitário básico por metro quadrado.",
17
+ "cubType": "CUB: tipo ou composição de CUB adotada.",
18
+ "cubValue": "CUB (R$): valor do CUB por metro quadrado na DataCUB; quando houver ponderação, uma anotação específica apresenta sua composição.",
19
+ "cubPercent": "% do CUB: percentual aplicado ao valor do CUB.",
20
+ "unitCost": "CUNIT: custo unitário após a aplicação do percentual do CUB.",
21
+ "bdi": "BDI (%): percentual de benefícios e despesas indiretas.",
22
+ "conservation": "Est. Cons.: estado de conservação adotado, identificado pelo respectivo código.",
23
+ "depreciation": "Depr. (K): coeficiente de depreciação calculado pelo método de Ross-Heidecke.",
24
+ "residual": "R (%): valor residual percentual.",
25
+ "kd": "kd: fator de depreciação com consideração do valor residual.",
26
+ "constructionValue": "Valor Construção (R$): valor final depreciado da sequência construtiva."
27
+ },
28
+ "notas_area": [
29
+ "Área eq. (m²): área utilizada no cálculo após ponderação das áreas reais pelos respectivos coeficientes de equivalência. Os coeficientes representam quanto cada tipo de área contribui, em custo, em relação à área padrão da construção.",
30
+ "Área (m²): área da construção considerada diretamente no cálculo da sequência construtiva."
31
+ ],
32
+ "referencias": {
33
+ "bdiAdicionais": [
34
+ {
35
+ "item": "a)",
36
+ "descricao": "projetos detalhados e especificacoes (5% a 10%)",
37
+ "perc1": 0.05,
38
+ "perc2": 0.05,
39
+ "perc3": 0.05,
40
+ "escolha": 0.05
41
+ },
42
+ {
43
+ "item": "b)",
44
+ "descricao": "copias, licenca da obra, agua, luz e forca (1,5% a 3%)",
45
+ "perc1": 0.03,
46
+ "perc2": 0.015,
47
+ "perc3": 0.03,
48
+ "escolha": 0.03
49
+ },
50
+ {
51
+ "item": "c)",
52
+ "descricao": "adm. local - mestre, apontador geral e engenheiro de obras (3% a 7%)",
53
+ "perc1": 0.05,
54
+ "perc2": 0.03,
55
+ "perc3": 0.05,
56
+ "escolha": 0.05
57
+ },
58
+ {
59
+ "item": "d)",
60
+ "descricao": "escritorio - pessoal, material, despesas de locacao ou amortizacao (4% a 8%)",
61
+ "perc1": 0.04,
62
+ "perc2": 0.04,
63
+ "perc3": 0.04,
64
+ "escolha": 0.04
65
+ },
66
+ {
67
+ "item": "e)",
68
+ "descricao": "eventuais, para atender omissoes de orcamento e elevacoes de precos nao previstos (minimo)",
69
+ "perc1": 0.05,
70
+ "perc2": 0.05,
71
+ "perc3": 0.05,
72
+ "escolha": 0.05
73
+ },
74
+ {
75
+ "item": "f)",
76
+ "descricao": "administracao central, lucro da firma construtora e custos financeiros, oscilando entre 10% e 25% conforme condicoes de servico da mesma e de mercado",
77
+ "perc1": null,
78
+ "perc2": 0.1,
79
+ "perc3": 0.1,
80
+ "escolha": 0.1
81
+ }
82
+ ],
83
+ "bdiTiposConstrucao": [
84
+ {
85
+ "tipo": "TELHEIRO SIMPLES (SEM BDI)",
86
+ "bdi": "0%"
87
+ },
88
+ {
89
+ "tipo": "TELHEIRO MEDIO (5%)",
90
+ "bdi": "5%"
91
+ },
92
+ {
93
+ "tipo": "TELHEIRO ALUMINIO (12%)",
94
+ "bdi": "12%"
95
+ },
96
+ {
97
+ "tipo": "MADEIRA SIMPLES (SEM BDI)",
98
+ "bdi": "0%"
99
+ },
100
+ {
101
+ "tipo": "MADEIRA MEDIA (5%)",
102
+ "bdi": "5%"
103
+ },
104
+ {
105
+ "tipo": "MADEIRA SUPERIOR (10%)",
106
+ "bdi": "10%"
107
+ },
108
+ {
109
+ "tipo": "MISTA SIMPLES (SEM BDI)",
110
+ "bdi": "0%"
111
+ },
112
+ {
113
+ "tipo": "MISTA MEDIA (5%)",
114
+ "bdi": "5%"
115
+ },
116
+ {
117
+ "tipo": "MISTA SUPERIOR (10%)",
118
+ "bdi": "10%"
119
+ },
120
+ {
121
+ "tipo": "ALVENARIA PRECARIA (SEM BDI)",
122
+ "bdi": "0%"
123
+ },
124
+ {
125
+ "tipo": "ALVENARIA POPULAR (8%)",
126
+ "bdi": "8 - 10%"
127
+ },
128
+ {
129
+ "tipo": "ALVENARIA POPULAR (10%)",
130
+ "bdi": "8 - 10%"
131
+ },
132
+ {
133
+ "tipo": "ALVENARIA MEDIA",
134
+ "bdi": "12 - 25%"
135
+ },
136
+ {
137
+ "tipo": "ALVENARIA FINA",
138
+ "bdi": "25 - 32%"
139
+ },
140
+ {
141
+ "tipo": "ALVENARIA LUXO",
142
+ "bdi": "32 - 35%"
143
+ },
144
+ {
145
+ "tipo": "PAVILHAO ALVENARIA POPULAR",
146
+ "bdi": "5%"
147
+ },
148
+ {
149
+ "tipo": "PAVILHAO ALVENARIA MEDIA",
150
+ "bdi": "10 - 12%"
151
+ }
152
+ ],
153
+ "estadosConservacao": [
154
+ {
155
+ "estado": "A - novo",
156
+ "coeficiente": 0,
157
+ "descricao": "Com ate seis meses de uso e sem danos. Nao sofreu nem necessita de reparos. Edificacao nova ou com reforma geral e substancial, com menos de 02 anos, que apresente apenas sinais de desgaste natural da pintura externa."
158
+ },
159
+ {
160
+ "estado": "B - entre novo e regular",
161
+ "coeficiente": 0.0032,
162
+ "descricao": "Apesar de ja submetido ao uso, apresenta-se nas condicoes de novo ou bem proximo disso. Nao recebeu e nem necessita de reparos. Edificacao nova ou com reforma geral e substancial, com menos de 02 anos, que apresente necessidade apenas de uma demao leve de pintura para recompor a sua aparencia."
163
+ },
164
+ {
165
+ "estado": "C - regular",
166
+ "coeficiente": 0.0252,
167
+ "descricao": "Requer ou recebeu reparos pequenos. Quando o objeto do servico de recuperacao ou de restauracao recente deixou o imovel em condicoes proximas ao de novo. Quando da existencia de atividade de manutencao permanente e eficiente que mantem a aparencia e/ou uso em condicoes de novo; requer apenas limpeza sem utilizacao de mao de obra especializada para manter em boas condicoes de uso/aparencia. Edificacao semi-nova ou com reforma geral e substancial entre 02 e 05 anos, cujo estado geral possa ser recuperado apenas com reparos de eventuais fissuras superficiais localizadas e/ou pintura externa e interna."
168
+ },
169
+ {
170
+ "estado": "D - entre regular e reparos simples",
171
+ "coeficiente": 0.0809,
172
+ "descricao": "Atividade de manutencao eventual ou periodica que mantem uma boa aparencia e condicoes normais de uso, mas sem o aspecto de novo ou recuperacao recente. Requer intervencoes superficiais em pontos localizados para recuperacao de desgastes naturais. Pode requerer mao de obra especializada com uso de instrumentos especiais. Edificacao semi-nova ou com reforma geral e substancial entre 02 e 05 anos, cujo estado geral possa ser recuperado com reparo de fissuras localizadas e superficiais e pintura externa e interna."
173
+ },
174
+ {
175
+ "estado": "E - reparos simples",
176
+ "coeficiente": 0.181,
177
+ "descricao": "Requer reparacoes simples como intervencoes em pontos localizados ou em partes/componentes definidos para restauracao de aspectos e/ou funcionalidades originais. Necessitam de servicos generalizados de manutencao e limpeza. Implicam a realizacao de servicos superficiais ou reparos de partes ou componentes definidos/localizados com mao de obra especializada. Nao comprometem a operacao e a funcionalidade. Edificacao cujo estado geral possa ser recuperado com pintura interna e externa, apos reparos de fissuras superficiais generalizadas, sem recuperacao do sistema estrutural. Eventualmente, revisao do sistema hidraulico e eletrico."
178
+ },
179
+ {
180
+ "estado": "F - entre reparos simples e importantes",
181
+ "coeficiente": 0.332,
182
+ "descricao": "Requer intervencoes generalizadas na maior parte, ou com profundidade, em pecas ou componentes especificos sob pena de comprometimento iminente de operacao e seguranca. Implica restauracao ou recuperacao com remocao/substituicao/adicao de elementos ou pecas com mao de obra especializada. Edificacao cujo estado geral possa ser recuperado com pintura interna e externa, apos reparos de fissuras, e com estabilizacao e/ou recuperacao localizada do sistema estrutural. As instalacoes hidraulicas e eletricas possam ser restauradas mediante a revisao e com substituicao eventual de algumas pecas desgastadas naturalmente. Eventualmente possa ser necessaria a substituicao dos revestimentos de pisos e paredes, de um, ou de outro compartimento. Revisao da impermeabilizacao ou substituicao de telhas da cobertura."
183
+ },
184
+ {
185
+ "estado": "G - reparos importantes",
186
+ "coeficiente": 0.526,
187
+ "descricao": "Requer intervencoes generalizadas e com profundidade em partes ou pecas criticas sob o aspecto de estetica, salubridade, seguranca e funcionalidade. Implica restauracao ou recuperacao com remocao/substituicao/adicao de elementos ou pecas com mao de obra especializada. Edificacao cujo estado geral possa ser recuperado com pintura interna e externa, com substituicao de parametros de regularizacao da alvenaria, reparos de fissuras, com estabilizacao e/ou recuperacao de grande parte do sistema estrutural. As instalacoes hidraulicas e eletricas possam ser restauradas mediante a substituicao das pecas aparentes. A substituicao dos revestimentos de pisos e paredes, da maioria dos compartimentos. Substituicao ou reparacoes importantes na impermeabilizacao ou no telhado."
188
+ },
189
+ {
190
+ "estado": "H - entre reparos importantes e sem valor",
191
+ "coeficiente": 0.752,
192
+ "descricao": "Restauracao total de elementos ou pecas importantes. Degradacao generalizada e com alto grau de exposicao. Alto nivel de comprometimento da funcionalidade, seguranca e operacao. Edificacao cujo estado geral possa ser recuperado com estabilizacao e/ou recuperacao do sistema estrutural, substituicao da regularizacao da alvenaria, reparos de fissuras. Substituicao das instalacoes hidraulicas e eletricas. Substituicao dos revestimentos de pisos e paredes. Substituicao da impermeabilizacao ou do telhado."
193
+ },
194
+ {
195
+ "estado": "I - sem valor",
196
+ "coeficiente": 1
197
+ }
198
+ ],
199
+ "vidaUtilTipos": [
200
+ {
201
+ "tipo": "APARTAMENTOS",
202
+ "vidaUtil": 60
203
+ },
204
+ {
205
+ "tipo": "BANCOS",
206
+ "vidaUtil": 70
207
+ },
208
+ {
209
+ "tipo": "CASAS DE ALVENARIA",
210
+ "vidaUtil": 65
211
+ },
212
+ {
213
+ "tipo": "CASAS DE MADEIRA",
214
+ "vidaUtil": 45
215
+ },
216
+ {
217
+ "tipo": "HOTEIS",
218
+ "vidaUtil": 50
219
+ },
220
+ {
221
+ "tipo": "LOJAS",
222
+ "vidaUtil": 70
223
+ },
224
+ {
225
+ "tipo": "TEATROS",
226
+ "vidaUtil": 50
227
+ },
228
+ {
229
+ "tipo": "ARMAZENS",
230
+ "vidaUtil": 75
231
+ },
232
+ {
233
+ "tipo": "FABRICAS",
234
+ "vidaUtil": 50
235
+ },
236
+ {
237
+ "tipo": "CONST. RURAIS",
238
+ "vidaUtil": 60
239
+ },
240
+ {
241
+ "tipo": "GARAGENS",
242
+ "vidaUtil": 60
243
+ },
244
+ {
245
+ "tipo": "EDIFICIOS DE ESCRITORIOS",
246
+ "vidaUtil": 70
247
+ },
248
+ {
249
+ "tipo": "GALPOES (DEPOSITOS)",
250
+ "vidaUtil": 70
251
+ },
252
+ {
253
+ "tipo": "SILOS",
254
+ "vidaUtil": 75
255
+ }
256
+ ],
257
+ "valorResidualReferencias": [
258
+ {
259
+ "classe": "Residencial",
260
+ "tipo": "Barraco",
261
+ "padrao": "Rústico",
262
+ "vidaReferencialIr": 5,
263
+ "valorResidual": 0
264
+ },
265
+ {
266
+ "classe": "Residencial",
267
+ "tipo": "Barraco",
268
+ "padrao": "Simples",
269
+ "vidaReferencialIr": 10,
270
+ "valorResidual": 0
271
+ },
272
+ {
273
+ "classe": "Residencial",
274
+ "tipo": "Casa",
275
+ "padrao": "Rústico",
276
+ "vidaReferencialIr": 60,
277
+ "valorResidual": 20
278
+ },
279
+ {
280
+ "classe": "Residencial",
281
+ "tipo": "Casa",
282
+ "padrao": "Proletário",
283
+ "vidaReferencialIr": 60,
284
+ "valorResidual": 20
285
+ },
286
+ {
287
+ "classe": "Residencial",
288
+ "tipo": "Casa",
289
+ "padrao": "Econômico",
290
+ "vidaReferencialIr": 70,
291
+ "valorResidual": 20
292
+ },
293
+ {
294
+ "classe": "Residencial",
295
+ "tipo": "Casa",
296
+ "padrao": "Simples",
297
+ "vidaReferencialIr": 70,
298
+ "valorResidual": 20
299
+ },
300
+ {
301
+ "classe": "Residencial",
302
+ "tipo": "Casa",
303
+ "padrao": "Médio",
304
+ "vidaReferencialIr": 70,
305
+ "valorResidual": 20
306
+ },
307
+ {
308
+ "classe": "Residencial",
309
+ "tipo": "Casa",
310
+ "padrao": "Superior",
311
+ "vidaReferencialIr": 70,
312
+ "valorResidual": 20
313
+ },
314
+ {
315
+ "classe": "Residencial",
316
+ "tipo": "Casa",
317
+ "padrao": "Fino",
318
+ "vidaReferencialIr": 60,
319
+ "valorResidual": 20
320
+ },
321
+ {
322
+ "classe": "Residencial",
323
+ "tipo": "Casa",
324
+ "padrao": "Luxo",
325
+ "vidaReferencialIr": 60,
326
+ "valorResidual": 20
327
+ },
328
+ {
329
+ "classe": "Residencial",
330
+ "tipo": "Apartamento",
331
+ "padrao": "Econômico",
332
+ "vidaReferencialIr": 60,
333
+ "valorResidual": 20
334
+ },
335
+ {
336
+ "classe": "Residencial",
337
+ "tipo": "Apartamento",
338
+ "padrao": "Simples",
339
+ "vidaReferencialIr": 60,
340
+ "valorResidual": 20
341
+ },
342
+ {
343
+ "classe": "Residencial",
344
+ "tipo": "Apartamento",
345
+ "padrao": "Médio",
346
+ "vidaReferencialIr": 60,
347
+ "valorResidual": 20
348
+ },
349
+ {
350
+ "classe": "Residencial",
351
+ "tipo": "Apartamento",
352
+ "padrao": "Superior",
353
+ "vidaReferencialIr": 60,
354
+ "valorResidual": 20
355
+ },
356
+ {
357
+ "classe": "Residencial",
358
+ "tipo": "Apartamento",
359
+ "padrao": "Fino",
360
+ "vidaReferencialIr": 50,
361
+ "valorResidual": 20
362
+ },
363
+ {
364
+ "classe": "Residencial",
365
+ "tipo": "Apartamento",
366
+ "padrao": "Luxo",
367
+ "vidaReferencialIr": 50,
368
+ "valorResidual": 20
369
+ },
370
+ {
371
+ "classe": "Comercial",
372
+ "tipo": "Escritório",
373
+ "padrao": "Econômico",
374
+ "vidaReferencialIr": 70,
375
+ "valorResidual": 20
376
+ },
377
+ {
378
+ "classe": "Comercial",
379
+ "tipo": "Escritório",
380
+ "padrao": "Simples",
381
+ "vidaReferencialIr": 70,
382
+ "valorResidual": 20
383
+ },
384
+ {
385
+ "classe": "Comercial",
386
+ "tipo": "Escritório",
387
+ "padrao": "Médio",
388
+ "vidaReferencialIr": 60,
389
+ "valorResidual": 20
390
+ },
391
+ {
392
+ "classe": "Comercial",
393
+ "tipo": "Escritório",
394
+ "padrao": "Superior",
395
+ "vidaReferencialIr": 60,
396
+ "valorResidual": 20
397
+ },
398
+ {
399
+ "classe": "Comercial",
400
+ "tipo": "Escritório",
401
+ "padrao": "Fino",
402
+ "vidaReferencialIr": 50,
403
+ "valorResidual": 20
404
+ },
405
+ {
406
+ "classe": "Comercial",
407
+ "tipo": "Escritório",
408
+ "padrao": "Luxo",
409
+ "vidaReferencialIr": 50,
410
+ "valorResidual": 20
411
+ },
412
+ {
413
+ "classe": "Comercial",
414
+ "tipo": "Galpões",
415
+ "padrao": "Rústico",
416
+ "vidaReferencialIr": 60,
417
+ "valorResidual": 20
418
+ },
419
+ {
420
+ "classe": "Comercial",
421
+ "tipo": "Galpões",
422
+ "padrao": "Simples",
423
+ "vidaReferencialIr": 60,
424
+ "valorResidual": 20
425
+ },
426
+ {
427
+ "classe": "Comercial",
428
+ "tipo": "Galpões",
429
+ "padrao": "Médio",
430
+ "vidaReferencialIr": 80,
431
+ "valorResidual": 20
432
+ },
433
+ {
434
+ "classe": "Comercial",
435
+ "tipo": "Galpões",
436
+ "padrao": "Superior",
437
+ "vidaReferencialIr": 80,
438
+ "valorResidual": 20
439
+ },
440
+ {
441
+ "classe": "Comercial",
442
+ "tipo": "Coberturas",
443
+ "padrao": "Rústico",
444
+ "vidaReferencialIr": 20,
445
+ "valorResidual": 10
446
+ },
447
+ {
448
+ "classe": "Comercial",
449
+ "tipo": "Coberturas",
450
+ "padrao": "Simples",
451
+ "vidaReferencialIr": 20,
452
+ "valorResidual": 10
453
+ },
454
+ {
455
+ "classe": "Comercial",
456
+ "tipo": "Coberturas",
457
+ "padrao": "Superior",
458
+ "vidaReferencialIr": 30,
459
+ "valorResidual": 10
460
+ }
461
+ ],
462
+ "areaEquivalenteCoeficientes": [
463
+ {
464
+ "item": "a",
465
+ "descricao": "garagem (subsolo)",
466
+ "coeficiente": "0,50 a 0,75"
467
+ },
468
+ {
469
+ "item": "b",
470
+ "descricao": "área privativa (unidade autônoma padrão)",
471
+ "coeficiente": "1,00"
472
+ },
473
+ {
474
+ "item": "c",
475
+ "descricao": "área privativa (salas com acabamento)",
476
+ "coeficiente": "1,00"
477
+ },
478
+ {
479
+ "item": "d",
480
+ "descricao": "área privativa (salas sem acabamento)",
481
+ "coeficiente": "0,75 a 0,90"
482
+ },
483
+ {
484
+ "item": "e",
485
+ "descricao": "área de loja sem acabamento",
486
+ "coeficiente": "0,40 a 0,60"
487
+ },
488
+ {
489
+ "item": "f",
490
+ "descricao": "varandas",
491
+ "coeficiente": "0,75 a 1,00"
492
+ },
493
+ {
494
+ "item": "g",
495
+ "descricao": "terraços ou áreas descobertas sobre lajes",
496
+ "coeficiente": "0,30 a 0,60"
497
+ },
498
+ {
499
+ "item": "h",
500
+ "descricao": "estacionamento sobre terreno",
501
+ "coeficiente": "0,05 a 0,10"
502
+ },
503
+ {
504
+ "item": "i",
505
+ "descricao": "área de projeção do terreno sem benfeitoria",
506
+ "coeficiente": "0,00"
507
+ },
508
+ {
509
+ "item": "j",
510
+ "descricao": "área de serviço - residência unifamiliar padrão baixo",
511
+ "coeficiente": "0,5"
512
+ },
513
+ {
514
+ "item": "k",
515
+ "descricao": "barrilete",
516
+ "coeficiente": "0,50 a 0,75"
517
+ },
518
+ {
519
+ "item": "l",
520
+ "descricao": "caixa d'água",
521
+ "coeficiente": "0,50 a 0,75"
522
+ },
523
+ {
524
+ "item": "m",
525
+ "descricao": "casa de máquinas",
526
+ "coeficiente": "0,50 a 0,75"
527
+ },
528
+ {
529
+ "item": "n",
530
+ "descricao": "piscinas",
531
+ "coeficiente": "0,50 a 0,75"
532
+ },
533
+ {
534
+ "item": "o",
535
+ "descricao": "quintais, calçadas, jardins, etc.",
536
+ "coeficiente": "0,10 a 0,30"
537
+ }
538
+ ],
539
+ "siglasCub": [
540
+ {
541
+ "sigla": "R1-B",
542
+ "descricao": "Residencia unifamiliar padrao baixo",
543
+ "dormitorios": 2,
544
+ "areaReal": 58.64,
545
+ "areaEquivalente": 51.94
546
+ },
547
+ {
548
+ "sigla": "R1-N",
549
+ "descricao": "Residencia unifamiliar padrao normal",
550
+ "dormitorios": 3,
551
+ "areaReal": 106.44,
552
+ "areaEquivalente": 99.47
553
+ },
554
+ {
555
+ "sigla": "R1-A",
556
+ "descricao": "Residencia unifamiliar padrao alto",
557
+ "dormitorios": 4,
558
+ "areaReal": 224.82,
559
+ "areaEquivalente": 210.44
560
+ },
561
+ {
562
+ "sigla": "RP1Q",
563
+ "descricao": "Residencia unifamiliar popular",
564
+ "dormitorios": 1,
565
+ "areaReal": 39.56,
566
+ "areaEquivalente": 39.56
567
+ },
568
+ {
569
+ "sigla": "PIS",
570
+ "descricao": "Residencia multifamiliar - projeto de interesse social",
571
+ "dormitorios": 2,
572
+ "areaReal": 991.45,
573
+ "areaEquivalente": 978.09
574
+ },
575
+ {
576
+ "sigla": "PP4-B",
577
+ "descricao": "Predio popular padrao baixo",
578
+ "dormitorios": 2,
579
+ "areaReal": 1415.07,
580
+ "areaEquivalente": 927.08
581
+ },
582
+ {
583
+ "sigla": "PP4-N",
584
+ "descricao": "Predio popular padrao normal",
585
+ "dormitorios": 3,
586
+ "areaReal": 2521.96,
587
+ "areaEquivalente": 1991.05
588
+ },
589
+ {
590
+ "sigla": "R8-N",
591
+ "descricao": "Residencia multifamiliar padrao normal",
592
+ "dormitorios": 3,
593
+ "areaReal": 5998.73,
594
+ "areaEquivalente": 5137.54
595
+ },
596
+ {
597
+ "sigla": "CAL8-N",
598
+ "descricao": "Comercial andares livres padrao normal",
599
+ "dormitorios": "-",
600
+ "areaReal": 5917.79,
601
+ "areaEquivalente": 5093.82
602
+ },
603
+ {
604
+ "sigla": "GI",
605
+ "descricao": "Galpao industrial",
606
+ "dormitorios": "-",
607
+ "areaReal": 1000,
608
+ "areaEquivalente": 1000
609
+ }
610
+ ]
611
+ },
612
+ "normas": {
613
+ "terreno": {
614
+ "title": "Fundamentação da avaliação do terreno",
615
+ "source": "ABNT NBR 14653-2:2011 - itens 9.2.1 e 9.2.1.6; Tabelas 1 e 2.",
616
+ "intro": "Aplicável ao modelo de regressão linear usado na estimativa do terreno. A seção 9 calcula os seis itens e usa a soma e as exigências mínimas da Tabela 2.",
617
+ "columns": [
618
+ "Critério",
619
+ "Grau III",
620
+ "Grau II",
621
+ "Grau I"
622
+ ],
623
+ "rows": [
624
+ [
625
+ "Caracterização",
626
+ "Completa para todas as variáveis analisadas",
627
+ "Completa para as variáveis do modelo",
628
+ "Situação paradigma"
629
+ ],
630
+ [
631
+ "Dados de mercado",
632
+ "Ao menos 6 × (k + 1)",
633
+ "Ao menos 4 × (k + 1)",
634
+ "Ao menos 3 × (k + 1)"
635
+ ],
636
+ [
637
+ "Identificação",
638
+ "Todos os dados e variáveis, com registros de campo",
639
+ "Todos os dados e variáveis analisados",
640
+ "Dados e variáveis efetivamente usados"
641
+ ],
642
+ [
643
+ "Extrapolação",
644
+ "Não admitida",
645
+ "Uma variável, dentro dos limites e impacto de até 15%",
646
+ "Dentro dos limites e impacto de até 20%"
647
+ ],
648
+ [
649
+ "Regressores",
650
+ "p-valor máximo de 10%",
651
+ "p-valor máximo de 20%",
652
+ "p-valor máximo de 30%"
653
+ ],
654
+ [
655
+ "Teste F",
656
+ "p-valor máximo de 1%",
657
+ "p-valor máximo de 2%",
658
+ "p-valor máximo de 5%"
659
+ ]
660
+ ],
661
+ "framing": "Enquadramento global do modelo: Grau III exige 16 pontos; Grau II, 10; Grau I, 6. Há também requisitos mínimos específicos por item. Para Grau III, a norma requer laudo completo, análise do modelo, endereços e fontes identificados e estimativa de tendência central."
662
+ },
663
+ "construcoes": {
664
+ "title": "Fundamentação da avaliação das construções",
665
+ "source": "ABNT NBR 14653-2:2011 - item 9.3; Tabelas 6 e 7.",
666
+ "intro": "Aplicável à estimativa dos custos de reedição pelo método da quantificação do custo. A seção 10 usa os três itens da Tabela 6.",
667
+ "columns": [
668
+ "Critério",
669
+ "Grau III",
670
+ "Grau II",
671
+ "Grau I"
672
+ ],
673
+ "rows": [
674
+ [
675
+ "Custo direto",
676
+ "Orçamento ao menos sintético",
677
+ "CUB de projeto semelhante ao padrão",
678
+ "CUB de projeto diferente, com ajustes"
679
+ ],
680
+ [
681
+ "BDI",
682
+ "Calculado",
683
+ "Justificado",
684
+ "Arbitrado"
685
+ ],
686
+ [
687
+ "Depreciação física",
688
+ "Levantamento do custo de recuperação ou bem novo/hipotético",
689
+ "Métodos técnicos, com idade, vida útil e conservação",
690
+ "Arbitrada"
691
+ ]
692
+ ],
693
+ "framing": "Enquadramento global do método: Grau III exige 7 pontos e custo direto no Grau III, com os demais no mínimo no Grau II; Grau II exige 5 pontos e os itens 1 e 2 no mínimo no Grau II; Grau I exige 3 pontos e todos no mínimo no Grau I. Grau III exige laudo na modalidade completa."
694
+ },
695
+ "global": {
696
+ "title": "Fundamentação global pelo método evolutivo",
697
+ "source": "ABNT NBR 14653-2:2011 - item 9.5; Tabelas 10 e 11.",
698
+ "intro": "A seção 11 reúne automaticamente os graus das avaliações de terreno e de construções e permite definir o critério do fator de comercialização.",
699
+ "columns": [
700
+ "Item",
701
+ "Grau III",
702
+ "Grau II",
703
+ "Grau I"
704
+ ],
705
+ "rows": [
706
+ [
707
+ "Valor do terreno",
708
+ "Grau III no método comparativo ou involutivo",
709
+ "Grau II no método comparativo ou involutivo",
710
+ "Grau I no método comparativo ou involutivo"
711
+ ],
712
+ [
713
+ "Custos de reedição",
714
+ "Grau III no método da quantificação do custo",
715
+ "Grau II no método da quantificação do custo",
716
+ "Grau I no método da quantificação do custo"
717
+ ],
718
+ [
719
+ "Fator de comercialização",
720
+ "Inferido em mercado semelhante",
721
+ "Justificado",
722
+ "Arbitrado"
723
+ ]
724
+ ],
725
+ "framing": "Enquadramento global: Grau III exige 8 pontos, itens 1 e 2 no Grau III e item 3 no mínimo no Grau II; Grau II exige 5 pontos e itens 1 e 2 no mínimo no Grau II; Grau I exige 3 pontos e todos no mínimo no Grau I. Quando o terreno ou as benfeitorias representam menos de 15% do valor total do imóvel, a norma admite dois pontos para o respectivo item. Grau III exige laudo na modalidade completa."
726
+ }
727
+ },
728
+ "redutores_termos": {
729
+ "VU central": "valor unitário estimado pelo modelo territorial antes de qualquer redutor ou campo de arbítrio",
730
+ "Área territorial": "área multiplicada pelo valor unitário para obter o valor territorial",
731
+ "Art. 88, inciso I — total": "soma das parcelas ponderadas válidas do inciso I; o fator é 1 menos essa redução",
732
+ "Art. 60 e 61 — aplicado": "maior redutor entre as condições selecionadas; o fator é 1 menos essa redução",
733
+ "Topografia — total": "soma dos redutores de relevo e desnível; o fator é 1 menos essa soma",
734
+ "Inundação — aplicado": "percentual da situação selecionada; o fator é 1 menos essa redução",
735
+ "Área atingida por APAN": "parcela territorial informada como APAN",
736
+ "APAN considerada no CA": "redução correspondente à faixa de APAN, que deve ser considerada pelo avaliador ao definir o fator final do campo de arbítrio",
737
+ "VU após redutores territoriais": "valor unitário que chega ao campo de arbítrio",
738
+ "Fator CA final escolhido": "fator definido pelo avaliador após considerar APAN, quando existente, e os demais fundamentos técnicos",
739
+ "VU após o CA": "valor unitário territorial adotado",
740
+ "Valor territorial": "resultado do VU após o CA multiplicado pela área",
741
+ "Valor das construções": "resultado trazido da avaliação MQC selecionada",
742
+ "Imóvel composto": "soma do terreno e das construções antes dos redutores de imóvel",
743
+ "Valor de referência do Art. 88": "valor do terreno com o CA somado às construções, antes dos incisos I e II",
744
+ "Redução do inciso I": "redutor territorial ponderado pelas áreas atingidas",
745
+ "Inciso II selecionado": "soma bruta das condições do inciso II antes da verificação do limite",
746
+ "Piso legal": "15% do valor de referência; o resultado combinado dos incisos não pode ficar abaixo deste valor",
747
+ "Resultado bruto dos incisos": "resultado que seria obtido antes de aplicar o limite de 85%",
748
+ "Redução total do imóvel": "percentual efetivamente aplicado do inciso II após a verificação do limite do Art. 88",
749
+ "Fator dos redutores do imóvel": "multiplicador equivalente a 1 menos a redução total do imóvel",
750
+ "Valor após redutores do imóvel": "base que seguirá para a aplicação final do FC",
751
+ "Base antes do FC": "valor do imóvel depois dos redutores de imóvel",
752
+ "FC": "fator de comercialização aplicado como última operação",
753
+ "Valor final": "resultado final da avaliação após todas as etapas"
754
+ },
755
+ "funcoes_mqc": {
756
+ "calcularDepreciacao": "function calcularDepreciacao(percentualVidaUtil, coefHeidecke) {\n const q = toNumber(percentualVidaUtil) / 100\n const curvaRoss = round(q / 2 + q ** 2 / 2, 6)\n return round(1 - (1 - curvaRoss) * (1 - toNumber(coefHeidecke)), 4)\n}",
757
+ "calcularKd": "function calcularKd(valorResidual, depreciacao) {\n const residual = toNumber(valorResidual)\n return round(residual + (1 - toNumber(depreciacao)) * (1 - residual), 3)\n}",
758
+ "calcularEnquadramento": "function calcularEnquadramento(definitions = {}) {\n const cust1 = definitions.formaCub === 'Elaboracao de orcamento, no minimo sintetico' ? 3 : definitions.formaCub === 'Utilizacao CUB para projeto semelhante ao projeto padrao' ? 2 : definitions.formaCub ? 1 : null\n const cust2 = definitions.tipoBdi === 'Calculado' ? 3 : definitions.tipoBdi === 'Justificado' ? 2 : definitions.tipoBdi ? 1 : null\n const cust3 = definitions.depreciacao === 'Por levantamento do custo de recuperacao do bem, para deixa-lo no estado de novo ou caso de bens novos ou projetos hipoteticos' ? 3 : definitions.depreciacao === 'Por metodos tecnicos consagrados, considerando-se idade, vida util e estado de conservacao' ? 2 : definitions.depreciacao ? 1 : null\n const complete = [cust1, cust2, cust3].every((value) => value !== null)\n const somaCusto = complete ? cust1 + cust2 + cust3 : 0\n let enquadramento = complete ? 'Fora dos critérios' : 'Pendente'\n if (complete && somaCusto >= 7 && cust1 === 3 && cust2 >= 2 && cust3 >= 2) enquadramento = 'Grau III'\n else if (complete && somaCusto >= 5 && cust1 >= 2 && cust2 >= 2) enquadramento = 'Grau II'\n else if (complete && somaCusto >= 3) enquadramento = 'Grau I'\n return { cust1, cust2, cust3, somaCusto, enquadramento }\n}"
759
+ },
760
+ "anotacoes_mqc": {
761
+ "wordCubTypeExplanation": "function wordCubTypeExplanation(value) {\n const text = String(value || '').trim()\n const match = text.match(/^(.+?)\\s*\\((.+)\\)$/)\n return match ? `CUB ${match[1].trim()}: ${match[2].trim()}.` : `CUB ${text || '-'}: tipo de CUB adotado.`\n}",
762
+ "wordMixExplanation": "function wordMixExplanation(item, composition) {\n const components = composition.map((row) => (\n `${wordNumber(row.peso, 2, 2)}% do CUB ${wordCubShortName(row.cubTipo)}, no valor de R$ ${wordNumber(row.valorCub, 2, 2)}/m²`\n ))\n return `CUB ponderado da Const. ${item.seq}: composição formada por ${wordNaturalList(components)}. Considerados esses pesos, a média ponderada adotada é de R$ ${wordNumber(item.valorCub, 2, 2)}/m².`\n}",
763
+ "wordLifeAdjustmentExplanation": "function wordLifeAdjustmentExplanation(item) {\n const justification = String(item.percentualVidaUtilJustificativa || '').replace(/\\s+/g, ' ').trim()\n const punctuation = /[.!?]$/.test(justification) ? '' : '.'\n return `Coeficiente de depreciação da construção nº ${item.seq}: calculado considerando ${wordNumber(item.percentualVidaUtil, 0, 2)}% da vida útil transcorrida, em vez dos ${wordNumber(item.percentualVidaUtilPadrao, 0, 2)}% transcorridos que seriam obtidos automaticamente pela relação entre a idade e a vida útil da construção. O ajuste foi adotado pela seguinte razão: ${justification}${punctuation}`\n}"
764
+ },
765
+ "criterios_fc": [
766
+ {
767
+ "value": "mercado_semelhante",
768
+ "label": "Grau III - Inferido em mercado semelhante",
769
+ "grade": "Grau III",
770
+ "points": 3
771
+ },
772
+ {
773
+ "value": "justificado",
774
+ "label": "Grau II - Justificado",
775
+ "grade": "Grau II",
776
+ "points": 2
777
+ },
778
+ {
779
+ "value": "arbitrado",
780
+ "label": "Grau I - Arbitrado",
781
+ "grade": "Grau I",
782
+ "points": 1
783
+ }
784
+ ],
785
+ "descricoes_modelo": {
786
+ "caracterizacao": {
787
+ "Grau III": "Caracterização completa quanto a todas as variáveis analisadas.",
788
+ "Grau II": "Caracterização completa quanto às variáveis utilizadas no modelo.",
789
+ "Grau I": "Adoção de situação paradigma.",
790
+ "Sem enquadramento": "O critério não foi atendido."
791
+ },
792
+ "identificacaoDados": {
793
+ "Grau III": "Informações de todos os dados e variáveis analisados, com fotos e características observadas no local pelo autor do laudo.",
794
+ "Grau II": "Informações de todos os dados e variáveis analisados na modelagem.",
795
+ "Grau I": "Informações dos dados e variáveis efetivamente utilizados no modelo.",
796
+ "Sem enquadramento": "O critério não foi atendido."
797
+ }
798
+ },
799
+ "requisitos_modelo": [
800
+ {
801
+ "key": "laudoCompleto",
802
+ "label": "Apresentação do laudo na modalidade completa"
803
+ },
804
+ {
805
+ "key": "analiseModelo",
806
+ "label": "Análise do modelo no laudo, com coerência do comportamento das variáveis e suas elasticidades no ponto de estimação"
807
+ },
808
+ {
809
+ "key": "enderecosCompletos",
810
+ "label": "Identificação completa dos endereços dos dados de mercado e das fontes de informação"
811
+ },
812
+ {
813
+ "key": "estimativaCentral",
814
+ "label": "Adoção da estimativa central"
815
+ }
816
+ ],
817
+ "identificador": "0d96271df5c3c68e"
818
+ }
config/mesa_redutores.json ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "origem": "mesa-frame/backend/app/core/redutores_territoriais.py",
3
+ "catalogo": {
4
+ "versao": "redutores_padrao_v5",
5
+ "campo_arbitrio": {
6
+ "minimo": 0.85,
7
+ "maximo": 1.15,
8
+ "padrao": 1.0
9
+ },
10
+ "limite_reducao_art_88": 0.85,
11
+ "art_88_i": {
12
+ "tipo": "terreno",
13
+ "limite_minimo_area": 0.1,
14
+ "condicoes": [
15
+ {
16
+ "codigo": "app",
17
+ "rotulo": "Área de Preservação Permanente",
18
+ "redutor": 0.7
19
+ },
20
+ {
21
+ "codigo": "diretriz_logradouro",
22
+ "rotulo": "Diretriz para implantação de logradouro público",
23
+ "redutor": 0.35
24
+ },
25
+ {
26
+ "codigo": "faixa_redes",
27
+ "rotulo": "Faixa não edificável para passagens de redes",
28
+ "redutor": 0.35
29
+ },
30
+ {
31
+ "codigo": "alta_tensao",
32
+ "rotulo": "Redes de alta tensão",
33
+ "redutor": 0.85
34
+ },
35
+ {
36
+ "codigo": "ocupacoes_irregulares",
37
+ "rotulo": "Ocupações irregulares",
38
+ "redutor": 0.6
39
+ }
40
+ ]
41
+ },
42
+ "art_88_ii": {
43
+ "tipo": "imovel",
44
+ "redutor_por_item": 0.15,
45
+ "limite_combinado_art_88": 0.85,
46
+ "condicoes": [
47
+ {
48
+ "codigo": "sem_agua",
49
+ "rotulo": "Sem disponibilidade do serviço público de água",
50
+ "redutor": 0.15
51
+ },
52
+ {
53
+ "codigo": "sem_luz",
54
+ "rotulo": "Sem disponibilidade do serviço público de luz",
55
+ "redutor": 0.15
56
+ },
57
+ {
58
+ "codigo": "sem_esgoto",
59
+ "rotulo": "Sem disponibilidade do serviço público de esgoto",
60
+ "redutor": 0.15
61
+ },
62
+ {
63
+ "codigo": "sem_pavimentacao",
64
+ "rotulo": "Logradouro público sem pavimentação",
65
+ "redutor": 0.15
66
+ }
67
+ ]
68
+ },
69
+ "art_60_61": {
70
+ "tipo": "terreno",
71
+ "condicoes": [
72
+ {
73
+ "codigo": "vila",
74
+ "rotulo": "Imóvel em vila",
75
+ "redutor": 0.3
76
+ },
77
+ {
78
+ "codigo": "encravado",
79
+ "rotulo": "Terreno encravado",
80
+ "redutor": 0.6
81
+ },
82
+ {
83
+ "codigo": "triangular_ate_1000",
84
+ "rotulo": "Terreno interno triangular de até 1.000 m²",
85
+ "redutor": 0.3
86
+ }
87
+ ]
88
+ },
89
+ "apan": {
90
+ "tipo": "terreno",
91
+ "faixas": [
92
+ {
93
+ "minimo": 0.0,
94
+ "maximo_exclusivo": 0.1,
95
+ "redutor": 0.0
96
+ },
97
+ {
98
+ "minimo": 0.1,
99
+ "maximo_exclusivo": 0.37,
100
+ "redutor": 0.05
101
+ },
102
+ {
103
+ "minimo": 0.37,
104
+ "maximo_exclusivo": 0.67,
105
+ "redutor": 0.1
106
+ },
107
+ {
108
+ "minimo": 0.67,
109
+ "maximo_exclusivo": null,
110
+ "redutor": 0.15
111
+ }
112
+ ]
113
+ },
114
+ "topografia": {
115
+ "tipo": "terreno",
116
+ "relevo": [
117
+ {
118
+ "codigo": "declive_ate_5",
119
+ "rotulo": "Declive até 5%",
120
+ "redutor": 0.05
121
+ },
122
+ {
123
+ "codigo": "declive_5_10",
124
+ "rotulo": "Declive de 5% até 10%",
125
+ "redutor": 0.1
126
+ },
127
+ {
128
+ "codigo": "declive_10_20",
129
+ "rotulo": "Declive de 10% até 20%",
130
+ "redutor": 0.2
131
+ },
132
+ {
133
+ "codigo": "declive_acima_20",
134
+ "rotulo": "Declive acima de 20%",
135
+ "redutor": 0.3
136
+ },
137
+ {
138
+ "codigo": "aclive_ate_10",
139
+ "rotulo": "Aclive até 10%",
140
+ "redutor": 0.05
141
+ },
142
+ {
143
+ "codigo": "aclive_ate_20",
144
+ "rotulo": "Aclive até 20%",
145
+ "redutor": 0.1
146
+ },
147
+ {
148
+ "codigo": "aclive_acima_20",
149
+ "rotulo": "Aclive acima de 20%",
150
+ "redutor": 0.15
151
+ }
152
+ ],
153
+ "nivel_rua": [
154
+ {
155
+ "codigo": "abaixo_1_2_5",
156
+ "rotulo": "Abaixo do nível da rua de 1,00 m até 2,50 m",
157
+ "redutor": 0.1
158
+ },
159
+ {
160
+ "codigo": "abaixo_2_5_4",
161
+ "rotulo": "Abaixo do nível da rua de 2,50 m até 4,00 m",
162
+ "redutor": 0.2
163
+ },
164
+ {
165
+ "codigo": "acima_2_4",
166
+ "rotulo": "Acima do nível da rua de 2,00 m até 4,00 m",
167
+ "redutor": 0.1
168
+ }
169
+ ]
170
+ },
171
+ "agua": {
172
+ "tipo": "terreno",
173
+ "condicoes": [
174
+ {
175
+ "codigo": "acesso_inundavel",
176
+ "rotulo": "Região inundável dificulta o acesso, sem atingir o terreno",
177
+ "redutor": 0.1
178
+ },
179
+ {
180
+ "codigo": "inundacao_periodica",
181
+ "rotulo": "Terreno atingido ou afetado periodicamente por inundação",
182
+ "redutor": 0.3
183
+ },
184
+ {
185
+ "codigo": "alagado_permanente",
186
+ "rotulo": "Terreno permanentemente alagado",
187
+ "redutor": 0.4
188
+ }
189
+ ]
190
+ }
191
+ }
192
+ }
docs/plano-metodo-evolutivo.md ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Plano da geração textual pelo método evolutivo
2
+
3
+ Data: 20 de outubro de 2026.
4
+
5
+ Estado: implementado em 20/10/2026. O corpo EVO, a preservação dos dados, o catálogo técnico compartilhado e incorporado nas novas exportações, a revisão de complementos, os enquadramentos, a conclusão e os anexos foram integrados. Validação inclui testes de equivalência com os algoritmos da MESA, importação e geração completa com arquivo real de quatro exercícios e três construções, navegação da revisão e inspeção do Word renderizado. Os itens abaixo registram o escopo e os critérios da implementação.
6
+
7
+ Referência examinada: [LA 107/2025 — Belém Velho, Casa 17](/Users/guilhermesilberfarbcosta/Downloads/LA_107_2025_BELEM_VELHO_4139_CASA_17.pdf), com 60 páginas. Foram examinados o cabeçalho, a demonstração da avaliação nas páginas 9–17 e o Anexo IV nas páginas 44–45.
8
+
9
+ ## Objetivo e princípio de organização
10
+
11
+ Organizar o corpo do laudo em metodologia geral, avaliação do terreno, avaliação das construções e composição do valor do imóvel. A demonstração técnica precisa aparecer no corpo do texto; os anexos conservam os dados, os resultados estatísticos e a memória detalhada que sustentam essa demonstração.
12
+
13
+ Os exercícios devem ser apresentados em tabelas comparativas dentro de cada etapa, como no exemplo, evitando repetir toda a metodologia para cada ano. Havendo modelos, imóveis ou construções diferentes, agrupar por identidade efetiva e identificar as diferenças. Avaliações do mesmo exercício permanecem distintas.
14
+
15
+ O gerador documentará os resultados salvos no MESA. Não recalculará o modelo estatístico, não substituirá o CUB histórico por uma série atual e não inventará justificativas técnicas.
16
+
17
+ ## Reaproveitamento obrigatório das explicações técnicas da MESA
18
+
19
+ Requisito confirmado pelo usuário: as explicações de redutores, BDI, CUB, áreas equivalentes, depreciação, campo de arbítrio, Fc e fundamentação deverão aproveitar os conteúdos técnicos da própria MESA. O PDF orienta a organização do documento; os textos, termos, fórmulas, tabelas de referência e explicações de cálculo da MESA orientam seu conteúdo técnico.
20
+
21
+ Esse reaproveitamento inclui a explicação do significado e da forma de aplicação de cada fator, além dos números finais. A adaptação para prosa de laudo poderá retirar instruções de interface e ajustar remissões, preservando o sentido técnico, as condições de aplicação, as unidades, as fontes e a sequência das operações.
22
+
23
+ Fontes já identificadas no projeto `mesa-frame`, em `/Users/guilhermesilberfarbcosta/Downloads/mesa-frame`:
24
+
25
+ | Tema | Fonte na MESA | Conteúdo a reaproveitar |
26
+ | --- | --- | --- |
27
+ | Redutores e campo de arbítrio | `frontend/src/components/RedutoresAvaliacaoModal.jsx` e `backend/app/core/redutores_territoriais.py` | Explicações das etapas, termos e fórmulas de `memoriaMatematica` e `memoriaCompleta`; catálogo e versão das regras; condições selecionadas, áreas atingidas, ponderações, limites, bases de incidência, ordem dos ajustes e justificativa do campo de arbítrio. |
28
+ | BDI | `frontend/src/mqc/data/referenceData.js`, `frontend/src/mqc/wordExportOptions.js` e `frontend/src/mqc/exporters.js` | Definição do BDI, componentes de adicionais, referências por tipo construtivo, significado de percentual e multiplicador e notas documentais existentes. Usar a composição efetivamente registrada quando disponível, sem presumir que uma tabela de referência foi a composição adotada. |
29
+ | CUB e CUB composto | `frontend/src/mqc/data/referenceData.js`, `frontend/src/mqc/wordExportOptions.js`, `frontend/src/mqc/exporters.js` e `frontend/src/mqc/calculations.js` | Descrição dos projetos padrão, DataCUB, percentual de adequação, CUNIT e explicações automáticas dos tipos e da média ponderada dos CUBs com seus pesos e valores. |
30
+ | Áreas equivalentes | `frontend/src/mqc/data/referenceData.js`, `frontend/src/mqc/wordExportOptions.js` e `frontend/src/components/MqcEvaluationEditor.jsx` | Significado da área equivalente, tipos e coeficientes de equivalência e memória da composição da área efetivamente calculada. |
31
+ | Depreciação e conservação | `frontend/src/mqc/data/referenceData.js`, `frontend/src/mqc/calculations.js`, `frontend/src/mqc/wordExportOptions.js` e `frontend/src/mqc/exporters.js` | Descrição dos estados de conservação, vida útil, Ross-Heidecke, K, residual e Kd; explicação dos ajustes manuais de percentual de vida e respectivas justificativas públicas. |
32
+ | Fundamentação e Fc | `frontend/src/components/NormativeReference.jsx`, `frontend/src/modelFoundation.js`, `frontend/src/globalFoundation.js` e `frontend/src/evolutivo/exportPdf.js` | Textos de critérios, fontes normativas, descrições dos enquadramentos e explicações dos resultados por componente e do método evolutivo. |
33
+
34
+ Na implementação, extrair os conteúdos hoje embutidos em componentes e exportadores para módulos ou catálogos serializáveis, compartilhados com a interface e com as exportações da MESA. O gerador consumirá esse conteúdo por um pacote documental versionado, preferencialmente preservado no `.evo`, ou por catálogo sincronizado com identificação de versão. Não manter cópias manuais independentes dos mesmos textos em JavaScript e Python.
35
+
36
+ Priorizar os fundamentos e referências preservados com a avaliação. Quando um arquivo antigo não trouxer os textos, usar o catálogo compatível com a versão identificável e sinalizar a ausência dessa identificação quando necessário. A atualização de um texto ou regra da MESA não deverá alterar silenciosamente a explicação de um resultado histórico.
37
+
38
+ Textos explicativos gerais não substituem a justificativa específica do avaliador para escolher um fator, um percentual de BDI ou um VU. Recuperar a justificativa já salva; solicitar complemento somente para a decisão particular que continuar sem fundamento. Respeitar as marcações de observação interna existentes na MESA, incluindo `percentualVidaUtilJustificativaInterna`: elas não autorizam publicar o texto no laudo.
39
+
40
+ ## O que o laudo de referência demonstra
41
+
42
+ | Parte | Páginas | Estrutura e conteúdo observados |
43
+ | --- | --- | --- |
44
+ | Metodologia geral | 9 | Apresenta o método evolutivo e explica os métodos empregados para terreno e construções, com consideração final do fator de comercialização. |
45
+ | Avaliação do terreno | 9–13 | Pesquisa e amostra, variáveis, equação e interpretação estatística; parâmetros do avaliando, campo de arbítrio, valor unitário adotado e justificativa; aplicação do valor unitário à área da unidade. |
46
+ | Avaliação das construções | 14–15 | Método da quantificação de custo, CUB e sua referência temporal; equação e significado dos termos; características construtivas, BDI e sua justificativa, depreciação e tabela de cálculo por exercício. |
47
+ | Composição do imóvel | 15–16 | Soma dos componentes, aplicação do Fc, tabela de resultados e justificativa do fator; explicação de uma redução não aplicada por já estar representada no modelo. |
48
+ | Especificação | 16 | Métodos, graus de fundamentação por componente, precisão do terreno e explicações para os enquadramentos. |
49
+ | Conclusão | 16–17 | Tratamento dos motivos desvalorizantes e valores finais adotados por exercício, com representação por extenso. |
50
+ | Anexo de cálculo | 44–45 | Parâmetros e limites amostrais, intervalos de confiança, campo de arbítrio e valores territoriais. |
51
+
52
+ Há duas distinções essenciais no exemplo:
53
+
54
+ - A área usada para estimar o valor unitário é a da gleba; a área de aplicação do valor unitário é a da unidade autônoma, de 4.126,73 m². O gerador precisa documentar ambas e a razão de serem diferentes.
55
+ - A tabela de composição apresenta valores calculados com centavos, enquanto a conclusão adota valores arredondados. Por exemplo, em 2022 aparecem R$ 741.299,90 na composição e R$ 740.000,00 na conclusão. São campos diferentes; o gerador não deve aplicar uma regra de arredondamento presumida.
56
+
57
+ ## Estrutura proposta para o corpo do laudo
58
+
59
+ ```text
60
+ 3 ANÁLISE DO VALOR DE MERCADO
61
+ 3.1 Diagnóstico de mercado
62
+ 3.2 Metodologia de avaliação
63
+ Definição do método evolutivo e dos métodos dos componentes
64
+ 3.3 Avaliação do terreno
65
+ 3.3.1 Aplicação do método comparativo
66
+ 3.3.1.1 Variáveis
67
+ 3.3.1.2 Equação e análise do modelo estatístico
68
+ 3.3.2 Cálculo do valor do terreno
69
+ 3.3.2.1 Parâmetros e valor estimado pelo modelo
70
+ 3.3.2.2 Ajustes aplicados e valor territorial adotado
71
+ Condições de incidência e justificativas do VU e da área
72
+ 3.4 Avaliação das construções
73
+ 3.4.1 Aplicação do método da quantificação de custo
74
+ 3.4.1.1 Características das construções e áreas
75
+ 3.4.1.2 CUB, referência temporal e adequações
76
+ 3.4.1.3 BDI e justificativa
77
+ 3.4.1.4 Depreciação
78
+ 3.4.1.5 Cálculo por construção e exercício
79
+ 3.5 Avaliação do imóvel objeto
80
+ Composição dos componentes, ajustes e Fc
81
+ Tabela por exercício e justificativas
82
+ 4 ESPECIFICAÇÃO DA AVALIAÇÃO
83
+ Terreno, construções e enquadramento global
84
+ 5 CONCLUSÃO TÉCNICA
85
+ Motivos desvalorizantes
86
+ Valores adotados por exercício e por extenso
87
+ Observações e referências temporais
88
+ ```
89
+
90
+ A separação do CUB em item próprio é uma proposta para tornar explícitos os dados existentes no MESA. A numeração será automática, inclusive no quarto nível, e as remissões usarão os números efetivamente gerados.
91
+
92
+ ## Conteúdo e tabelas de cada etapa
93
+
94
+ ### Terreno
95
+
96
+ Reaproveitar os textos e o cadastro de variáveis já existentes, incluindo o tratamento do ano de referência das dicotômicas. Identificar o modelo, a amostra, o período dos dados, as variáveis, a equação e os diagnósticos disponíveis. Afirmações sobre aderência estatística devem decorrer dos resultados, sem frases fixas que assegurem normalidade, ausência de autocorrelação ou atendimento de critérios independentemente dos testes.
97
+
98
+ Gerar um quadro comparativo com parâmetros, estimativas e intervalos por exercício e outro com os ajustes efetivamente aplicados, campo de arbítrio, VU adotado, áreas e valor territorial. As justificativas acompanham o quadro e são agrupadas quando iguais, mantendo a abrangência das diferenças entre referências.
99
+
100
+ Preservar a diferença entre a referência anual da avaliação e o ano-base omitido na codificação das dicotômicas. Também distinguir estimativa central do modelo, limites de confiança, campo de arbítrio e valor adotado: não são resultados intercambiáveis.
101
+
102
+ ### Construções
103
+
104
+ Explicar o custo de reedição e a equação, com unidades e convenção de percentuais coerentes com os dados salvos:
105
+
106
+ ```text
107
+ CUNIT = CUB × percentual de adequação em forma decimal
108
+ VC = área de cálculo × CUNIT × (1 + BDI em forma decimal) × Kd
109
+ Kd = R + (1 − K) × (1 − R)
110
+ ```
111
+
112
+ O BDI de 25% no cadastro corresponde ao multiplicador 1,25; a apresentação deve distinguir percentual e multiplicador. A notação de K, R e Kd deve permanecer consistente.
113
+
114
+ Documentar, por construção, área real e equivalente quando aplicável, ano da construção, idade ou percentual de vida, vida útil, tipo construtivo, conservação, CUB, adequações e depreciação. Preservar as ponderações quando houver CUB composto e os ajustes manuais de vida útil com suas justificativas.
115
+
116
+ Apresentar uma tabela de cálculo por construção com linhas por exercício e, havendo várias construções, um quadro de totais. Se a tabela ficar larga, separar características e memória de cálculo em tabelas legíveis. Não repetir automaticamente os 25% de BDI, o padrão R1-N ou o residual de 20% do exemplo.
117
+
118
+ Usar o mês do CUB efetivamente registrado em `cubData`. O texto sobre CUB de novembro publicado em dezembro é próprio do exemplo e só deve ser reproduzido quando essa correspondência estiver demonstrada nos dados do trabalho.
119
+
120
+ ### Composição do imóvel
121
+
122
+ Sem outros ajustes, apresentar a expressão do exemplo, `VI = (VT + VC) × Fc`. Quando houver ajustes, a expressão e as tabelas devem refletir a sequência salva em `resultadoComposicao.memoria`, incluindo ajustes unitários, territoriais, do imóvel e a aplicação final do Fc. Não resumir uma composição com redutores em uma fórmula que omita etapas.
123
+
124
+ O quadro deve permitir acompanhar os componentes, os ajustes aplicados, a base antes do Fc, o fator e o resultado calculado. A justificativa do Fc precisa acompanhar o fator, por exercício ou em texto comum quando for a mesma. Identificar também os ajustes expressamente não aplicados e sua justificativa, quando informados pelo avaliador, para evitar dupla consideração do mesmo aspecto.
125
+
126
+ ### Especificação e conclusão
127
+
128
+ Exibir os enquadramentos de terreno e construções, a precisão da avaliação territorial e o enquadramento global quando houver elementos para documentá-lo. Não atribuir a precisão da regressão ao imóvel inteiro. Preservar os critérios e justificativas, sem adotar os graus II/III do exemplo como padrão.
129
+
130
+ Mostrar na conclusão o valor final adotado, com eventual diferença em relação ao calculado claramente identificada. Cabeçalho e conclusão devem usar o mesmo registro de valor adotado e a mesma referência temporal. A composição conservará o valor calculado.
131
+
132
+ ## Lacunas verificadas no código atual
133
+
134
+ | Componente | Situação atual | Mudança planejada |
135
+ | --- | --- | --- |
136
+ | `backend/app/avaliacoes_import.py` | O importador EVO conserva totais e parte das informações, mas reduz o conjunto de dados de terreno, construções e composição. | Preservar os dados documentais necessários, de forma estruturada e validada, mantendo a origem de cada resultado. |
137
+ | `document/generator.py` | A seção 3.2 recebe a metodologia do modelo e um parágrafo geral sobre o evolutivo. A seção 4 ainda contém um campo para preenchimento. | Encaminhar o caso evolutivo a um gerador próprio para as seções 3.2–3.5 e para a especificação. |
138
+ | `document/anexos/metodologia.py` | A metodologia comparativa produz títulos no terceiro nível e pressupõe valor unitário no texto de abertura das variáveis. | Permitir reaproveitamento no bloco territorial, com quarto nível de títulos e descrição compatível com a natureza real do Y. |
139
+ | `document/avaliacoes_importadas.py` | A conclusão EVO apresenta uma tabela de componentes e o Anexo IV concentra a demonstração por avaliação. | Separar quadro de composição e conclusão, trazendo a demonstração essencial para o corpo do laudo. |
140
+ | `frontend/src/ImportacaoAvaliacoes.jsx` e schemas | A prévia permite corrigir anos, mas não possui campos próprios para os fundamentos documentais da avaliação evolutiva. | Acrescentar revisão dos dados e complementos técnicos vinculados ao ID de cada avaliação. |
141
+
142
+ O `.evo` do MESA já conserva dados úteis em `terreno`, `construcao`, `resultadoComposicao` e `fundamentacaoGlobal`. A implementação deve primeiro preservar esses registros antes de acrescentar campos ao exportador.
143
+
144
+ ## Dados a preservar na importação
145
+
146
+ | Bloco | Dados necessários |
147
+ | --- | --- |
148
+ | Identificação | ID estável, imóvel/unidade, exercício IPTU, ano e data de referência, modelo e identificação da versão disponível. |
149
+ | Terreno | `areaModelo`, `areaAplicacao`, `parametrosAplicados`, parâmetros originais quando necessários para explicar mudanças, `valorUnitario`, resultados e intervalos da avaliação, configuração e justificativa do campo de arbítrio, contexto e fundamentação do modelo. |
150
+ | Construções | `cubData`, `anoReferencia`, referências de cálculo, `fundamentacaoMqc`, sequências com área e suas equivalências, CUB simples/composto, `percentualCub`, `custoUnitario`, `bdi`, ano de construção, conservação, vida útil, percentuais de vida e ajustes manuais, `coefHeidecke`, `depreciacao`, `valorResidual`, `kd` e `vcalcConstSeq`. |
151
+ | Composição | Memória ordenada e resultados intermediários, redutores e seus componentes, Fc aplicado, resultado final salvo e fundamentação global. |
152
+ | Complementos | Justificativas de área, escolha do VU, BDI, Fc e ajustes não aplicados; eventual valor final adotado distinto do calculado e critério dessa adoção. |
153
+
154
+ Para tabelas de parâmetros aplicados, não usar automaticamente `avaliacao.valores_x` quando houver `terreno.parametrosAplicados` mais recente. A área usada na estimação não deve ser sobrescrita pela área de aplicação.
155
+
156
+ Os anexos estatísticos dependem do modelo correspondente. O nome de um arquivo local, sozinho, não comprova que ele é a mesma versão usada na avaliação salva; usar a identificação e o contexto disponíveis e tornar explícita eventual falta de correspondência. Resultados importados não serão substituídos por uma nova estimação do arquivo local.
157
+
158
+ ## Complementos no gerador
159
+
160
+ Primeiro, utilizar os fundamentos já presentes no arquivo e as explicações técnicas compartilhadas da MESA. Campos de complemento devem tratar apenas das decisões específicas ainda não justificadas, associados ao ID da avaliação e, para BDI ou características específicas, à construção. Não pedir ao usuário que redigite definições, referências ou explicações gerais já existentes na MESA. Permitir reutilização deliberada de uma justificativa em vários exercícios.
161
+
162
+ O valor adotado será inicialmente o resultado final salvo. Caso seja necessário registrar adoção diferente, como os arredondamentos do exemplo, deverá existir campo explícito para esse valor e seu critério. Preservar ambos os números; não arredondar silenciosamente e não alterar o resultado de origem.
163
+
164
+ Arquivos antigos continuarão importáveis dentro das validações já existentes. Uma informação documental ausente deverá aparecer na revisão como pendente, sem ser convertida em zero ou em justificativa padrão. Dados essenciais de resultado inválidos continuarão impedindo a geração de resultados sem suporte.
165
+
166
+ ## Sequência de implementação
167
+
168
+ 1. **Conteúdo técnico compartilhado:** inventariar os textos da MESA e separar conteúdos gerais, explicações parametrizadas e justificativas particulares. Preparar o catálogo/pacote documental versionado, reaproveitando os mesmos conteúdos da interface e das exportações existentes.
169
+ 2. **Contrato de dados:** ampliar e testar a normalização EVO, os campos de origem e a preservação do conteúdo técnico. Preparar um exemplo com quatro exercícios, áreas de modelo e aplicação distintas e memória completa.
170
+ 3. **Demonstração no corpo:** criar `document/evolutivo.py` com geradores de metodologia geral, terreno, construções e composição, consumindo as explicações compartilhadas da MESA; adaptar o reaproveitamento da metodologia comparativa e a numeração.
171
+ 4. **Revisão e fundamentos:** ampliar os schemas e a interface de importação para mostrar as pendências e permitir os complementos necessários, com valores calculados e adotados separados.
172
+ 5. **Especificação e conclusão:** gerar os quadros e explicações a partir dos dados preservados; integrar o valor adotado ao cabeçalho já recuperado.
173
+ 6. **Anexos e remissões:** manter os anexos estatísticos, organizar o Anexo IV como memória detalhada e gerar referências coerentes com os títulos e números efetivos.
174
+ 7. **Validação:** executar testes de dados, equivalência do conteúdo técnico e geração; produzir DOCX representativos, renderizar e conferir todas as páginas afetadas. Verificar também o fluxo AME e o preenchimento manual existentes.
175
+
176
+ ## Critérios de aceitação
177
+
178
+ - O corpo contém a demonstração territorial, construtiva e de composição por exercício, com os fundamentos técnicos correspondentes.
179
+ - As explicações de redutores, BDI, CUB, depreciação e demais fatores provêm das fontes compartilhadas da MESA, com origem e versão identificáveis. Testes conferem que a adaptação documental preserva fórmulas, termos, unidades, condições, referências e a memória efetivamente aplicada.
180
+ - Explicações técnicas já disponíveis são preenchidas automaticamente. Complementos do avaliador são restritos às decisões específicas ainda sem justificativa; observações marcadas como internas não são publicadas.
181
+ - Um caso com área da gleba e área da unidade distintas mostra ambas corretamente e conserva os parâmetros realmente aplicados.
182
+ - Não se repetem textos de metodologia para cada ano quando o método e o modelo são os mesmos.
183
+ - Há suporte a várias construções, áreas equivalentes, CUB composto, alterações por exercício e justificativas diferentes.
184
+ - BDI em percentual e multiplicador, K, R e Kd têm identificação inequívoca.
185
+ - Reduções e Fc aparecem na ordem e com os valores intermediários do resultado salvo; diferenças de conferência não são corrigidas silenciosamente.
186
+ - Campos monetários são tratados com precisão decimal e formatação consistente; a conferência não recompõe resultados usando coeficientes de exibição com precisão reduzida.
187
+ - Precisão territorial, fundamentação dos componentes e global não são confundidas nem presumidas.
188
+ - Cabeçalho, conclusão e referências temporais permanecem consistentes. Valores calculados e adotados podem diferir, mas a diferença está registrada.
189
+ - Avaliações distintas do mesmo exercício não são somadas ou fundidas automaticamente.
190
+ - A ausência de justificativa, modelo local ou dados históricos é visível e não produz texto técnico inventado.
191
+ - Tabelas largas e quebras de página são verificadas no documento renderizado; títulos e remissões são automáticos.
192
+
193
+ ## Inconsistências do exemplo a evitar
194
+
195
+ O PDF servirá como referência de estrutura, não como base numérica sem conferência. Foram observadas diferenças internas:
196
+
197
+ - No cabeçalho, o valor numérico de 2023 aparece como R$ 780.000.000,00, enquanto o extenso e a conclusão indicam R$ 780.000,00.
198
+ - A área do modelo aparece como 70.478 na tabela do corpo e 74.478 no Anexo IV.
199
+ - Há referências anuais e parâmetros diferentes entre a tabela da página 12 e o Anexo IV; para 2025, a tabela do corpo marca duas dicotômicas anuais, enquanto o anexo marca somente AP2024.
200
+ - As observações finais citam dezembro de 2022 a 2024, enquanto o cabeçalho inclui dezembro de 2021 a 2024.
201
+ - Existem remissões de itens e anexos que não correspondem à numeração apresentada.
202
+
203
+ Esses pontos motivam dados compartilhados entre corpo, cabeçalho e anexos, além de referências geradas automaticamente. Os valores e justificativas próprios deste imóvel não serão adotados como padrões para outros trabalhos.
204
+
205
+ ## Revisão crítica da apresentação
206
+
207
+ A comparação com o PDF evidenciou excesso de tabelas pequenas e textos genéricos sobre ajustes sem efeito. A revisão reúne parâmetros e estimativas do modelo; reúne ajustes efetivos, campo de arbítrio, áreas e valor territorial; consolida características e mantém um único quadro comparativo de cálculo por construção e grupo de até quatro avaliações. CUB, adequação, CUNIT, BDI, vida consumida, K, Kd e VC passam a aparecer juntos. Mudanças de características, composição do CUB e justificativas entre exercícios permanecem identificadas. Tabelas de áreas equivalentes com uma única área e coeficiente 1 são dispensadas quando repetem exatamente os valores já informados.
208
+
209
+ As explicações de redutores dependem do efeito salvo, com precedência do fator efetivamente executado sobre a seleção ou taxa de referência. Reduções zeradas não aparecem como aplicadas. Na demonstração das condições do inciso I, somente parcelas acima do limiar histórico participam; nos arts. 60 e 61, somente a condição correspondente ao maior percentual efetivo é apresentada. O limite do art. 88 é explicado com seus valores de verificação somente quando efetivamente acionado. APAN permanece vinculada à decisão no campo de arbítrio, sem desconto autônomo. Ajustes expressamente não aplicados continuam documentados quando o avaliador forneceu justificativa. Ausência de dados não é tratada como percentual zero.
210
+
211
+ O Anexo IV mantém a ordem salva das operações, omitindo etapas de redutores com efeito explicitamente neutro. Conversões, adição das construções e Fc continuam disponíveis para conferência. Os resultados e a origem importada não são alterados nem recalculados. Explicações comuns de CUB, BDI, vida útil e conservação são agrupadas por conteúdo e abrangência; composições efetivamente adotadas permanecem distintas de referências gerais.
212
+
213
+ ## Narrativa dos ajustes e remissões à caracterização
214
+
215
+ Revisão de 21/10/2026: o item 3.3.2.2 passa a conter títulos de quinto nível, um para cada condição de restrição efetivamente aplicada, seguido da escolha no campo de arbítrio, quando cabível, e da síntese territorial. A numeração dos filhos se reinicia em cada item de quarto nível. O quadro comparativo de valores permanece único.
216
+
217
+ As remissões à caracterização usam os números efetivamente gerados nos itens 2.2.X, e não a posição presumida do motivo na entrada. A ligação compara a descrição da condição e designações conhecidas, como APP e APAN, respeitando palavras completas. A ausência de correspondência é indicada, sem criar um número de item inexistente nem afirmar que uma condição foi confirmada quando o capítulo apenas a examina.
218
+
219
+ A narrativa encadeia a condição tratada no capítulo 2.2, seu fundamento legal, a regra de incidência e o percentual registrado para as referências às quais se aplica. Para as restrições ponderadas, distingue área atingida, taxa de referência, contribuição ponderada e redução efetiva do conjunto. Divergências entre contribuições e percentual salvo são explicitadas sem substituir resultados. APAN continua na decisão técnica do campo de arbítrio.
220
+
221
+ A identificação dos fundamentos gerais foi conferida no [texto municipal do Decreto nº 16.500/2009](https://legislacao.portoalegre.rs.gov.br/media/sapl/public/normajuridica/sirel/Norma_16500_20091110_1.html): art. 88 para desvalorização particular; arts. 60 e 61, com os incisos correspondentes à condição, para a configuração do terreno; art. 56, parágrafo único, para a equivalência da correção no preço do metro quadrado. Os percentuais apresentados continuam provenientes do catálogo e dos efeitos históricos salvos na avaliação. O fundamento geral não transforma essas taxas em percentuais prescritos pelo caput legal, nem cria enquadramento específico para memórias sem hipótese identificável.
document/anexos/metodologia.py CHANGED
@@ -3,7 +3,6 @@ Gerador da seção de Metodologia de Avaliação.
3
  Gera as sub-seções de Aplicação, Variáveis e Equação a partir dos dados do modelo (.dai).
4
  """
5
  import json
6
- import re
7
  from datetime import date, datetime
8
  from typing import Optional, List, Tuple, Dict
9
 
@@ -14,13 +13,16 @@ from docx.oxml import OxmlElement
14
  from docx.oxml.ns import qn
15
 
16
  from models.model_data import ModelData
 
17
  from document.numbering import NumeradorSecoes
18
  from document.formatters import (
19
  add_body_text,
20
  add_placeholder_text,
21
  add_subsubsection_title,
 
22
  add_bullet_text,
23
  criar_paragrafo_formatado,
 
24
  )
25
  from config.settings import BASE_DIR
26
 
@@ -76,10 +78,7 @@ _TEXTO_VARIAVEIS_INTRO = (
76
  "dos imóveis e valorização imobiliária da zona onde se localizam os mesmos."
77
  )
78
 
79
- _TEXTO_VARIAVEIS_LISTA = (
80
- "As variáveis independentes que se mostraram mais significativas, a nível "
81
- "estatístico, para a formação do valor do imóvel em análise foram:"
82
- )
83
 
84
 
85
  # ---------------------------------------------------------------------------
@@ -153,41 +152,27 @@ def _obter_periodo_amostra(modelo: ModelData) -> Tuple[str, str]:
153
  return inicio or "data inicial não informada", fim or "data final não informada"
154
 
155
 
156
- def _extrair_anos_variaveis(variaveis: List[str]) -> Tuple[List[int], Optional[int], List[str]]:
157
- """
158
- Separa variáveis de ano das demais.
159
-
160
- Returns:
161
- (anos, ap_ano, outras) onde:
162
- - anos: lista ordenada de anos (de variáveis aYYYY)
163
- - ap_ano: ano de variável APYYYY (ou None)
164
- - outras: variáveis que não são de ano
165
- """
166
  anos = []
167
- ap_ano = None
168
  outras = []
169
-
170
  for v in variaveis:
171
- if re.fullmatch(r"a(20\d{2}|2100)", v):
172
- anos.append(int(v[1:]))
173
- elif re.fullmatch(r"AP(20\d{2}|2100)", v):
174
- ap_ano = int(v[2:])
175
  else:
176
  outras.append(v)
 
177
 
178
- return sorted(anos), ap_ano, outras
179
 
180
-
181
- def _gerar_bloco_ano(doc: Document, anos: List[int], ap_ano: Optional[int]) -> None:
182
  """
183
  Gera o bloco especial da variável ANO com título bold e bullets por ano.
184
  """
185
  colchetes = [f"[{ano}]" for ano in anos]
186
- if ap_ano:
187
- colchetes.append(f"[AP{ap_ano}]")
188
 
189
  # Título + descrição no mesmo parágrafo (bold + non-bold)
190
- titulo = f"Ano {', '.join(colchetes[:-1])} e {colchetes[-1]}:"
191
  descricao = (
192
  " variáveis do tipo dicotômicas que identificam o ano em que cada "
193
  "elemento foi pesquisado, ou seja, corresponde à época em que foi "
@@ -196,18 +181,21 @@ def _gerar_bloco_ano(doc: Document, anos: List[int], ap_ano: Optional[int]) -> N
196
  _adicionar_paragrafo_variavel(doc, titulo, descricao)
197
 
198
  # Bullets individuais por ano
199
- for ano in anos:
200
- add_bullet_text(doc, f"[{ano}] = 1, se o dado é de {ano};")
201
-
202
- if ap_ano:
203
- add_bullet_text(doc, f"[AP{ap_ano}] = 1, se o dado é a partir de {ap_ano};")
 
 
 
 
204
 
205
  # Linha final com ano base
206
- ano_base = min(anos) - 1
207
- add_bullet_text(
208
- doc,
209
- f"{', '.join(colchetes[:-1])} e {colchetes[-1]} = 0, se o dado é de {ano_base}."
210
- )
211
 
212
 
213
  def _adicionar_paragrafo_variavel(doc: Document, titulo: str, descricao: str) -> None:
@@ -233,34 +221,44 @@ def _adicionar_paragrafo_variavel(doc: Document, titulo: str, descricao: str) ->
233
  run_desc.font.color.rgb = RGBColor(255, 0, 0)
234
 
235
 
236
- def _adicionar_equacao_destacada(doc: Document, equacao: str) -> None:
237
- """Apresenta a equação como um bloco centralizado e visualmente destacado."""
238
- p = doc.add_paragraph()
239
- p.alignment = WD_ALIGN_PARAGRAPH.CENTER
240
- p.paragraph_format.space_before = Pt(8)
241
- p.paragraph_format.space_after = Pt(10)
242
- p.paragraph_format.left_indent = Cm(0)
243
- p.paragraph_format.right_indent = Cm(0)
244
-
245
- p_pr = p._p.get_or_add_pPr()
246
- shading = OxmlElement("w:shd")
247
- shading.set(qn("w:fill"), "F2F5F7")
248
- p_pr.append(shading)
249
-
250
- bordas = OxmlElement("w:pBdr")
251
- for lado in ("top", "left", "bottom", "right"):
252
- borda = OxmlElement(f"w:{lado}")
253
- borda.set(qn("w:val"), "single")
254
- borda.set(qn("w:sz"), "6")
255
- borda.set(qn("w:space"), "6")
256
- borda.set(qn("w:color"), "7F8C8D")
257
- bordas.append(borda)
258
- p_pr.append(bordas)
259
-
260
- run = p.add_run(equacao)
261
- run.bold = True
262
- run.font.name = "Arial"
263
- run.font.size = Pt(11)
 
 
 
 
 
 
 
 
 
 
264
 
265
 
266
  # ---------------------------------------------------------------------------
@@ -272,6 +270,13 @@ def _gerar_aplicacao(doc: Document, modelo: ModelData, num: Optional[NumeradorSe
272
  numero = num.subsubsecao() if num else ""
273
  add_subsubsection_title(doc, numero, "Aplicação Método Comparativo")
274
 
 
 
 
 
 
 
 
275
  data_inicial, data_final = _obter_periodo_amostra(modelo)
276
  texto = _TEXTO_APLICACAO_P1.format(
277
  n_amostras=modelo.n_amostras,
@@ -288,24 +293,27 @@ def _gerar_aplicacao(doc: Document, modelo: ModelData, num: Optional[NumeradorSe
288
  recuo_primeira_linha=1.25,
289
  )
290
 
 
 
291
  add_body_text(doc, _TEXTO_APLICACAO_P2)
292
  add_body_text(doc, _TEXTO_APLICACAO_P3)
293
 
294
 
295
- def _gerar_variaveis(doc: Document, modelo: ModelData, num: Optional[NumeradorSecoes]) -> None:
296
  """Gera sub-seção 3.2.2 - Variáveis."""
297
- numero = num.subsubsecao() if num else ""
298
- add_subsubsection_title(doc, numero, "Variáveis")
299
 
300
- add_body_text(doc, _TEXTO_VARIAVEIS_INTRO)
 
301
  add_body_text(doc, _TEXTO_VARIAVEIS_LISTA)
302
 
303
  descricoes = _carregar_descricoes_variaveis()
304
- anos, ap_ano, outras_variaveis = _extrair_anos_variaveis(modelo.variaveis)
305
 
306
  # Bloco ANO (se houver variáveis de ano)
307
- if anos or ap_ano:
308
- _gerar_bloco_ano(doc, anos, ap_ano)
309
 
310
  # Demais variáveis
311
  for var in outras_variaveis:
@@ -322,9 +330,9 @@ def _gerar_equacao(doc: Document, modelo: ModelData, num: Optional[NumeradorSeco
322
  add_subsubsection_title(doc, numero, "Equação Modelo Estatístico")
323
 
324
  add_body_text(doc, "O modelo de regressão que melhor se ajustou aos dados da amostra é transcrito a seguir:")
 
325
 
326
- if modelo.equacao:
327
- _adicionar_equacao_destacada(doc, modelo.equacao)
328
 
329
  add_body_text(doc, "Os resultados estatísticos e a planilha de cálculo são exibidos no Anexo II.")
330
 
 
3
  Gera as sub-seções de Aplicação, Variáveis e Equação a partir dos dados do modelo (.dai).
4
  """
5
  import json
 
6
  from datetime import date, datetime
7
  from typing import Optional, List, Tuple, Dict
8
 
 
13
  from docx.oxml.ns import qn
14
 
15
  from models.model_data import ModelData
16
+ from models.anos import periodo_variavel_ano
17
  from document.numbering import NumeradorSecoes
18
  from document.formatters import (
19
  add_body_text,
20
  add_placeholder_text,
21
  add_subsubsection_title,
22
+ add_subsubsubsection_title,
23
  add_bullet_text,
24
  criar_paragrafo_formatado,
25
+ add_simple_table,
26
  )
27
  from config.settings import BASE_DIR
28
 
 
78
  "dos imóveis e valorização imobiliária da zona onde se localizam os mesmos."
79
  )
80
 
81
+ _TEXTO_VARIAVEIS_LISTA = "As variáveis independentes utilizadas no modelo são:"
 
 
 
82
 
83
 
84
  # ---------------------------------------------------------------------------
 
152
  return inicio or "data inicial não informada", fim or "data final não informada"
153
 
154
 
155
+ def _extrair_anos_variaveis(variaveis: List[str]) -> Tuple[List[str], List[str]]:
156
+ """Separa dicotômicas de ano, preservando os nomes usados no modelo."""
 
 
 
 
 
 
 
 
157
  anos = []
 
158
  outras = []
 
159
  for v in variaveis:
160
+ if periodo_variavel_ano(str(v)) is not None:
161
+ anos.append(str(v))
 
 
162
  else:
163
  outras.append(v)
164
+ return sorted(anos, key=lambda nome: periodo_variavel_ano(nome)[0]), outras
165
 
 
166
 
167
+ def _gerar_bloco_ano(doc: Document, anos: List[str], ano_base: Optional[int]) -> None:
 
168
  """
169
  Gera o bloco especial da variável ANO com título bold e bullets por ano.
170
  """
171
  colchetes = [f"[{ano}]" for ano in anos]
172
+ lista = " e ".join([", ".join(colchetes[:-1]), colchetes[-1]]) if len(colchetes) > 1 else colchetes[0]
 
173
 
174
  # Título + descrição no mesmo parágrafo (bold + non-bold)
175
+ titulo = f"Ano {lista}:"
176
  descricao = (
177
  " variáveis do tipo dicotômicas que identificam o ano em que cada "
178
  "elemento foi pesquisado, ou seja, corresponde à época em que foi "
 
181
  _adicionar_paragrafo_variavel(doc, titulo, descricao)
182
 
183
  # Bullets individuais por ano
184
+ for nome in anos:
185
+ periodo, a_partir = periodo_variavel_ano(nome)
186
+ if a_partir:
187
+ condicao = f"a partir de {periodo[0]}"
188
+ elif len(periodo) > 1:
189
+ condicao = "de " + " ou ".join(str(ano) for ano in periodo)
190
+ else:
191
+ condicao = f"de {periodo[0]}"
192
+ add_bullet_text(doc, f"[{nome}] = 1, se o dado é {condicao}; e 0, caso contrário;")
193
 
194
  # Linha final com ano base
195
+ if ano_base is not None:
196
+ add_bullet_text(doc, f"{lista} = 0, se o dado é de {ano_base} (ano-base do modelo).")
197
+ else:
198
+ add_placeholder_text(doc, "[Ano-base não identificado no modelo - conferir a variável de ano dos dados]")
 
199
 
200
 
201
  def _adicionar_paragrafo_variavel(doc: Document, titulo: str, descricao: str) -> None:
 
221
  run_desc.font.color.rgb = RGBColor(255, 0, 0)
222
 
223
 
224
+ def _adicionar_tabela_equacao(doc: Document, modelo: ModelData):
225
+ """Tabela de identificação e equação no padrão do laudo LA_107."""
226
+ section = doc.sections[-1]
227
+ table = add_simple_table(
228
+ doc, [['Modelo', 'Nº de dados', 'Equação'],
229
+ [modelo.nome, str(modelo.n_amostras), modelo.equacao or 'Equação não informada']],
230
+ largura_colunas=[23, 14, 63],
231
+ largura_total=int(section.page_width-section.left_margin-section.right_margin),
232
+ )
233
+ borders = OxmlElement('w:tblBorders')
234
+ for side in ('top', 'left', 'bottom', 'right', 'insideH', 'insideV'):
235
+ border = OxmlElement('w:'+side)
236
+ for key, value in [('val', 'single'), ('sz', '4'), ('color', '000000')]:
237
+ border.set(qn('w:'+key), value)
238
+ borders.append(border)
239
+ table._tbl.tblPr.append(borders)
240
+ for index, row in enumerate(table.rows):
241
+ row._tr.get_or_add_trPr().append(OxmlElement('w:cantSplit'))
242
+ for column, cell in enumerate(row.cells):
243
+ props = cell._tc.get_or_add_tcPr()
244
+ for node in list(props.findall(qn('w:shd'))) + list(props.findall(qn('w:noWrap'))):
245
+ props.remove(node)
246
+ shading = OxmlElement('w:shd')
247
+ shading.set(qn('w:fill'), 'FFFFFF' if index == 0 else 'F2F2F2')
248
+ props.append(shading)
249
+ margins = OxmlElement('w:tcMar')
250
+ for side, value in [('top', '100'), ('bottom', '100'), ('left', '80'), ('right', '80')]:
251
+ margin = OxmlElement('w:'+side)
252
+ margin.set(qn('w:w'), value)
253
+ margin.set(qn('w:type'), 'dxa')
254
+ margins.append(margin)
255
+ props.append(margins)
256
+ for paragraph in cell.paragraphs:
257
+ paragraph.paragraph_format.line_spacing = 1
258
+ paragraph.paragraph_format.keep_with_next = index == 0
259
+ for run in paragraph.runs:
260
+ run.font.size = Pt(9 if index == 1 and column == 0 else 10)
261
+ return table
262
 
263
 
264
  # ---------------------------------------------------------------------------
 
270
  numero = num.subsubsecao() if num else ""
271
  add_subsubsection_title(doc, numero, "Aplicação Método Comparativo")
272
 
273
+ _adicionar_pesquisa(doc, modelo)
274
+ _adicionar_fundamentos_comparativos(doc)
275
+
276
+
277
+ def _adicionar_pesquisa(doc: Document, modelo: ModelData) -> None:
278
+ """Reutiliza o texto padrão com a quantidade e o período da amostra."""
279
+
280
  data_inicial, data_final = _obter_periodo_amostra(modelo)
281
  texto = _TEXTO_APLICACAO_P1.format(
282
  n_amostras=modelo.n_amostras,
 
293
  recuo_primeira_linha=1.25,
294
  )
295
 
296
+
297
+ def _adicionar_fundamentos_comparativos(doc: Document) -> None:
298
  add_body_text(doc, _TEXTO_APLICACAO_P2)
299
  add_body_text(doc, _TEXTO_APLICACAO_P3)
300
 
301
 
302
+ def _gerar_variaveis(doc: Document, modelo: ModelData, num: Optional[NumeradorSecoes], nivel=3, tipo_y='unitario') -> None:
303
  """Gera sub-seção 3.2.2 - Variáveis."""
304
+ numero = (num.subsubsubsecao() if nivel == 4 else num.subsubsecao()) if num else ""
305
+ (add_subsubsubsection_title if nivel == 4 else add_subsubsection_title)(doc, numero, "Variáveis")
306
 
307
+ add_body_text(doc, _TEXTO_VARIAVEIS_INTRO if tipo_y == 'unitario' else
308
+ "A variável dependente representa o valor total do imóvel, em reais (R$). As variáveis independentes representam as características dos elementos de mercado utilizadas no modelo.")
309
  add_body_text(doc, _TEXTO_VARIAVEIS_LISTA)
310
 
311
  descricoes = _carregar_descricoes_variaveis()
312
+ anos, outras_variaveis = _extrair_anos_variaveis(modelo.variaveis)
313
 
314
  # Bloco ANO (se houver variáveis de ano)
315
+ if anos:
316
+ _gerar_bloco_ano(doc, anos, modelo.ano_base)
317
 
318
  # Demais variáveis
319
  for var in outras_variaveis:
 
330
  add_subsubsection_title(doc, numero, "Equação Modelo Estatístico")
331
 
332
  add_body_text(doc, "O modelo de regressão que melhor se ajustou aos dados da amostra é transcrito a seguir:")
333
+ doc.paragraphs[-1].paragraph_format.keep_with_next = True
334
 
335
+ _adicionar_tabela_equacao(doc, modelo)
 
336
 
337
  add_body_text(doc, "Os resultados estatísticos e a planilha de cálculo são exibidos no Anexo II.")
338
 
document/avaliacoes_importadas.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Document sections based on the adopted results saved in AME/EVO."""
2
+ from .formatters import add_body_text, add_simple_table, add_subsection_title
3
+ from .formatters.table import set_table_font_size
4
+ from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
5
+
6
+
7
+ def currency(value):
8
+ if value is None or value == '':
9
+ return '—'
10
+ try:
11
+ number = Decimal(str(value)).quantize(Decimal('.01'),rounding=ROUND_HALF_UP)
12
+ if not number.is_finite(): return '—'
13
+ return 'R$ ' + f'{number:,.2f}'.replace(',','#').replace('.',',').replace('#','.')
14
+ except (InvalidOperation,ValueError):
15
+ return '—'
16
+
17
+
18
+ def conclusion_table(doc, imported):
19
+ evo = imported['tipo'] == 'evolutivo'
20
+ header = ['IPTU', 'Objeto / Avaliação', 'Valor adotado']
21
+ rows = [header]
22
+ for item in imported['avaliacoes']:
23
+ reference = f"IPTU {item['ano']}"
24
+ rows.append([reference, f"{item['objeto']} — {item['titulo']} [{item['id']}]", currency(item['valor_total'])])
25
+ if evo:
26
+ from .evolutivo import table
27
+ table(doc,rows,[1.3,3.4,1.6],keep_together=len(rows)<=9,font_size=7)
28
+ else:
29
+ set_table_font_size(add_simple_table(doc, rows, header_row=True, largura_colunas=[1.4, 3.6, 2.5]),7)
30
+ from utils.formatters import numero_para_extenso
31
+ for item in imported['avaliacoes']:
32
+ extenso = numero_para_extenso(Decimal(item['valor_total']))
33
+ paragraph = add_body_text(doc,f"{item['titulo']} [{item['id']}] — valor adotado por extenso: {extenso}.")
34
+ paragraph.paragraph_format.keep_together = True
35
+ calculated = (item.get('evolutivo') or {}).get('resultadoComposicao',{}).get('valorTotalCalculado')
36
+ different_calculated = calculated is not None and Decimal(str(calculated)).quantize(Decimal('.01')) != Decimal(item['valor_total'])
37
+ if item.get('valor_total_origem') is not None or different_calculated:
38
+ paragraph.paragraph_format.keep_with_next = True
39
+ origin = item.get('valor_total_origem',calculated)
40
+ add_body_text(doc,f"Valor registrado na composição de origem: {currency(origin)}; valor adotado no laudo: {currency(item['valor_total'])}. Critério da adoção: {(item.get('complementos') or {}).get('criterio_adocao') or 'não informado; complemento técnico pendente.'}").paragraph_format.keep_together = True
41
+
42
+ def calculation_sections(doc, imported):
43
+ for item in imported['avaliacoes']:
44
+ add_subsection_title(doc, '', f"{item['titulo']} — Ano-base {item['ano_base']}")
45
+ add_body_text(doc, f"Modelo: {item['modelo'] or 'Não identificado no arquivo'}. Objeto: {item['objeto']}.")
46
+ evaluation = item['avaliacao']
47
+ if evaluation.get('precisao'):
48
+ add_body_text(doc, f"Precisão da estimativa do modelo: {evaluation['precisao']}.")
49
+ foundation = item.get('fundamentacao') or {}
50
+ if foundation.get('grade'):
51
+ add_body_text(doc, f"Fundamentação do modelo: {foundation['grade']}.")
52
+ for warning in item.get('avisos', []):
53
+ add_body_text(doc, f"Observação da avaliação: {warning}")
54
+ parameters = (item.get('evolutivo') or {}).get('terreno',{}).get('parametrosAplicados') or evaluation.get('valores_x') or {}
55
+ if parameters:
56
+ add_simple_table(doc, [['Parâmetro', 'Valor informado']] + [[str(key), str(value)] for key, value in parameters.items()], header_row=True, largura_colunas=[3.5, 4])
57
+ values = [['Resultado', 'Valor']]
58
+ if item.get('area') is not None:
59
+ values.append(['Área de aplicação (m²)', str(item['area'])])
60
+ for key, label in [('estimado_unitario', 'Estimativa unitária do modelo'), ('estimado_total', 'Estimativa total do modelo'), ('ca_inf', 'Campo de arbítrio inferior'), ('ca_sup', 'Campo de arbítrio superior'), ('ic_inf', 'Limite inferior do intervalo de confiança'), ('ic_sup', 'Limite superior do intervalo de confiança'), ('knn_estimado_unitario', 'KNN unitário'), ('knn_estimado_total', 'KNN total')]:
61
+ if evaluation.get(key) is not None:
62
+ values.append([label, currency(evaluation[key])])
63
+ if imported['tipo'] == 'evolutivo':
64
+ from .evo_adjustments import applied_reducers
65
+ values.extend([['Valor do terreno', currency(item['valor_terreno'])], ['Valor das construções', currency(item['valor_construcao'])], ['Fator de comercialização', str(item.get('fator_comercializacao', '—'))]])
66
+ if applied_reducers(item,False):
67
+ values.append(['Valor após redutores do imóvel', currency(item.get('valor_apos_redutores_imovel'))])
68
+ if item.get('valor_total_origem') is not None:
69
+ values.append(['Valor total registrado na avaliação de origem', currency(item['valor_total_origem'])])
70
+ values.append(['Valor total adotado', currency(item['valor_total'])])
71
+ add_simple_table(doc, values, header_row=True, largura_colunas=[4.5, 3])
72
+ if imported['tipo'] == 'evolutivo' and item.get('construcoes'):
73
+ add_body_text(doc, 'Construções incorporadas à composição')
74
+ constructions = [['Construção / Tipo', 'Área (m²)', 'CUB / Ano construção', 'Valor calculado']]
75
+ for index, construction in enumerate(item['construcoes']):
76
+ constructions.append([
77
+ str(construction.get('nome') or construction.get('tipoConstrucao') or f'Construção {index + 1}'),
78
+ str(construction.get('areaCalculada', construction.get('areaTotal', '—'))),
79
+ f"{construction.get('cubTipo', '—')} / {construction.get('anoConstrucao', '—')}",
80
+ currency(construction.get('vcalcConstSeq')),
81
+ ])
82
+ add_simple_table(doc, constructions, header_row=True, largura_colunas=[2.5, 1.2, 2.3, 1.5])
83
+ if imported['tipo'] == 'evolutivo':
84
+ from .evolutivo import table, fmt, source
85
+ from .evo_adjustments import visible_memory
86
+ memory = visible_memory(item)
87
+ if memory:
88
+ add_body_text(doc,'Memória das etapas efetivas na ordem de origem')
89
+ table(doc,[['Etapa','Fator','Resultado','Unidade']]+[[str(step.get('rotulo',step.get('codigo',''))),fmt(step.get('fator'),6),currency(step.get('resultado')),str(step.get('unidade') or 'Não informada')] for step in memory if isinstance(step,dict)],[3,1,1.5,.8],compact=True)
90
+ if item.get('observacoes'):
91
+ add_body_text(doc, item['observacoes'])
92
+ adjustment = item.get('ajustes') or {}
93
+ memory = adjustment.get('memoria_calculo')
94
+ if memory:
95
+ add_body_text(doc, 'Memória dos ajustes adotados')
96
+ # Preserve saved adjustment evidence in readable paragraphs instead of a raw JSON dump.
97
+ entries = memory if isinstance(memory, list) else [memory]
98
+ for entry in entries:
99
+ if isinstance(entry, dict):
100
+ text = '; '.join(f'{key}: {value}' for key, value in entry.items() if not isinstance(value, (list, dict)))
101
+ if text:
102
+ add_body_text(doc, text)
103
+ else:
104
+ add_body_text(doc, str(entry))
document/evo_adjustments.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Select adjustments from saved effects, never from a checkbox alone."""
2
+ from .fundamentacao import number
3
+
4
+ REDUCERS = {
5
+ 'redutor88I': ('Art. 88, inciso I', 'Art. 88, inciso I — total'),
6
+ 'redutor6061': ('Art. 60 e 61', 'Art. 60 e 61 — aplicado'),
7
+ 'redutorTopografia': ('Topografia', 'Topografia — total'),
8
+ 'redutorAgua': ('Inundação e ação da água', 'Inundação — aplicado'),
9
+ 'redutor88II': ('Art. 88, inciso II', 'Redução total do imóvel'),
10
+ }
11
+
12
+
13
+ def saved_result(row):
14
+ return (row.get('evolutivo') or {}).get('resultadoComposicao') or {}
15
+
16
+
17
+ def saved_step(row, code):
18
+ return next((s for s in saved_result(row).get('memoria') or []
19
+ if isinstance(s, dict) and s.get('codigo') == code), {})
20
+
21
+
22
+ def step_has_effect(step):
23
+ # The applied factor takes precedence over a selected/reference percentage.
24
+ factor = number(step.get('fator'))
25
+ if factor is not None:
26
+ return factor != 1
27
+ rate = number(step.get('percentualReducao'))
28
+ return rate != 0 if rate is not None else True # Unknown is not zero.
29
+
30
+
31
+ def visible_memory(row):
32
+ return [s for s in saved_result(row).get('memoria') or [] if isinstance(s, dict)
33
+ and (s.get('codigo') not in ('redutores_terreno', 'redutores_imovel') or step_has_effect(s))]
34
+
35
+
36
+ def reducer_rate(row, key):
37
+ values = saved_result(row)
38
+ if key in values:
39
+ return number(values[key])
40
+ if key == 'redutor88II' and 'redutorImovel' in values:
41
+ return number(values['redutorImovel'])
42
+ code = 'redutores_imovel' if key == 'redutor88II' else 'redutores_terreno'
43
+ for component in saved_step(row, code).get('componentes') or []:
44
+ if isinstance(component,dict) and component.get('rotulo') == REDUCERS[key][0]:
45
+ return number(component.get('reducao'))
46
+ return None
47
+
48
+
49
+ def applied_reducers(row, territorial):
50
+ code = 'redutores_terreno' if territorial else 'redutores_imovel'
51
+ step = saved_step(row, code)
52
+ # Explicitly neutral execution wins over selected components, including caps.
53
+ if step and not step_has_effect(step):
54
+ return []
55
+ keys = list(REDUCERS)[:4] if territorial else ['redutor88II']
56
+ items = [(key, REDUCERS[key][0], reducer_rate(row, key)) for key in keys
57
+ if reducer_rate(row, key) is not None and reducer_rate(row, key) > 0]
58
+ if items:
59
+ return items
60
+ aggregate_key = 'redutorTerreno' if territorial else 'redutorImovel'
61
+ rate = number(saved_result(row).get(aggregate_key))
62
+ if rate is None and step:
63
+ factor = number(step.get('fator'))
64
+ rate = 1-factor if factor is not None else number(step.get('percentualReducao'))
65
+ if rate is not None and rate <= 0:
66
+ return []
67
+ if (rate is not None and rate > 0) or (step and step_has_effect(step)):
68
+ return [(aggregate_key, 'Redução territorial registrada' if territorial else 'Redução do imóvel registrada', rate)]
69
+ return []
document/evolutivo.py ADDED
@@ -0,0 +1,545 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """EVO narrative: explain saved evidence in the body, with annexes as support."""
2
+ from collections import OrderedDict
3
+ from decimal import Decimal, ROUND_HALF_UP
4
+ import json
5
+
6
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
7
+ from docx.oxml import OxmlElement
8
+ from docx.oxml.ns import qn
9
+
10
+ from .formatters import (add_body_text, add_simple_table, add_subsection_title,
11
+ add_subsubsection_title, add_subsubsubsection_title, add_subsubsubsubsection_title)
12
+ from .avaliacoes_importadas import currency
13
+ from .mesa_content import content_for
14
+ from .evo_adjustments import REDUCERS, applied_reducers
15
+ from .fundamentacao import number, global_foundation
16
+ from .footnotes import add_footnote
17
+ from .formatters.table import set_table_font_size
18
+ from .anexos.metodologia import (_gerar_variaveis, _adicionar_pesquisa,
19
+ _adicionar_fundamentos_comparativos, _adicionar_tabela_equacao)
20
+ from models.imported_context import context_key, snapshot_model, matching_local
21
+ from models.construction_identity import construction_identity
22
+
23
+
24
+ def fmt(value, digits=2):
25
+ value = number(value)
26
+ if value is None:
27
+ return 'Não informado'
28
+ text = f'{value.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP):,.{digits}f}'
29
+ return text.replace(',', '#').replace('.', ',').replace('#', '.')
30
+
31
+
32
+ def pct(value, fraction=False):
33
+ value = number(value)
34
+ return 'Não informado' if value is None else fmt(value * (100 if fraction else 1)) + '%'
35
+
36
+
37
+ def ref(row, show_base=True):
38
+ value = f"IPTU {row['ano']}"
39
+ if show_base:
40
+ value += f" / base {row['ano_base']}"
41
+ return value + (f" — {row['titulo_origem']} [{row['id']}]" if row.get('referencia_duplicada') else '')
42
+
43
+
44
+ def source(row):
45
+ return row.get('evolutivo') or {}
46
+
47
+
48
+ def terrain(row):
49
+ return source(row).get('terreno') or {}
50
+
51
+
52
+ def result(row):
53
+ return source(row).get('resultadoComposicao') or {}
54
+
55
+
56
+ def complement(row, key, fallback='Não informado no arquivo; complemento técnico pendente.'):
57
+ return (row.get('complementos') or {}).get(key) or fallback
58
+
59
+
60
+ def table(doc, rows, widths=None, compact=False, keep_together=False, font_size=None):
61
+ section = doc.sections[-1]
62
+ if doc.paragraphs:
63
+ previous = doc.paragraphs[-1]
64
+ if previous.text.startswith(('Construção ', 'Composição das áreas', 'IPTU ', 'Memória integral', 'Memória das etapas')):
65
+ previous.paragraph_format.keep_with_next = True
66
+ obj = add_simple_table(doc, rows, largura_colunas=widths,
67
+ largura_total=int(section.page_width-section.left_margin-section.right_margin))
68
+ for row_index,row in enumerate(obj.rows):
69
+ row._tr.get_or_add_trPr().append(OxmlElement('w:cantSplit'))
70
+ for index, cell in enumerate(row.cells):
71
+ props = cell._tc.get_or_add_tcPr()
72
+ for no_wrap in list(props.findall(qn('w:noWrap'))): props.remove(no_wrap)
73
+ margins = OxmlElement('w:tcMar')
74
+ padding = 25 if compact else 65
75
+ for side, size in [('top',padding),('bottom',padding),('left',80),('right',80)]:
76
+ node = OxmlElement('w:'+side); node.set(qn('w:w'), str(size)); node.set(qn('w:type'),'dxa'); margins.append(node)
77
+ props.append(margins)
78
+ for para in cell.paragraphs:
79
+ para.paragraph_format.line_spacing = 1.05
80
+ para.paragraph_format.keep_with_next = keep_together and row_index<len(obj.rows)-1
81
+ para.alignment = WD_ALIGN_PARAGRAPH.LEFT if index == 0 else WD_ALIGN_PARAGRAPH.CENTER
82
+ borders = OxmlElement('w:tblBorders')
83
+ for side in ['top','left','bottom','right','insideH','insideV']:
84
+ node = OxmlElement('w:'+side); node.set(qn('w:val'),'single'); node.set(qn('w:sz'),'4'); node.set(qn('w:color'),'D9D9D9'); borders.append(node)
85
+ obj._tbl.tblPr.append(borders)
86
+ if font_size is not None:
87
+ set_table_font_size(obj, font_size)
88
+ return obj
89
+
90
+
91
+ def heading(doc, num, label, level):
92
+ counter = {2:num.subsecao,3:num.subsubsecao,4:num.subsubsubsecao,5:num.subsubsubsubsecao}[level]
93
+ builder = {2:add_subsection_title,3:add_subsubsection_title,4:add_subsubsubsection_title,5:add_subsubsubsubsection_title}[level]
94
+ value = counter()
95
+ paragraph = builder(doc,value,label)
96
+ paragraph.paragraph_format.keep_with_next = True
97
+ return value
98
+
99
+
100
+ def equation(doc, text):
101
+ # Cost/depreciation formulas remain separate from the statistical model table.
102
+ para = add_body_text(doc,text)
103
+ if para is not None:
104
+ para.alignment = WD_ALIGN_PARAGRAPH.CENTER
105
+ para.paragraph_format.keep_together = True
106
+
107
+
108
+ def compared(doc, rows, metrics, notes=None, batch_size=4, show_base=False):
109
+ """Keep references distinct, with a wider batch for short result labels."""
110
+ for offset in range(0,len(rows),batch_size):
111
+ batch = rows[offset:offset+batch_size]
112
+ labels = [ref(row, show_base=show_base) for row in batch]
113
+ duplicates = len(set(labels)) != len(labels)
114
+ if duplicates: labels = [f"{label}\n{row['titulo_origem']} [{row['id']}]" for label,row in zip(labels,batch)]
115
+ # Short comparative tables stay with their captions; longer ones may
116
+ # continue with the repeated header.
117
+ obj = table(doc, [['Parâmetro / resultado']+labels] + [[label]+[getter(row) for row in batch] for label,getter in metrics], [2.4]+[1.45]*len(batch), keep_together=len(metrics)<=12)
118
+ for index, (label, _) in enumerate(metrics, 1):
119
+ if notes and label in notes:
120
+ add_footnote(doc, obj.rows[index].cells[0].paragraphs[0], notes[label])
121
+ set_table_font_size(obj, 7)
122
+
123
+
124
+ def model_analysis(doc, model, tipo_y):
125
+ """Resumo estatístico no padrão do laudo, sem inferir diagnósticos ausentes."""
126
+ r2 = number(model.r2) if model else None
127
+ text = ''
128
+ if r2 is not None and 0 <= r2 <= 1:
129
+ value = 'valor total em reais' if tipo_y == 'total' else 'valor unitário em reais por metro quadrado'
130
+ correlation = r2.sqrt()
131
+ interpretation = (', indicando a existência de um alto grau de correlação entre as variáveis consideradas.'
132
+ if correlation >= Decimal('.9') else '.')
133
+ text = (f'O coeficiente de correlação linear do modelo é de {fmt(correlation,4)}{interpretation} '
134
+ f'O modelo de regressão obtido explica aproximadamente {fmt(r2*100,0)}% da variabilidade do {value}, '
135
+ 'sendo o restante da variabilidade atribuída a outras variáveis não consideradas ou a oscilações de caráter aleatório. ')
136
+ text += ('As especificações e os resultados detalhados encontram-se no Anexo I - Banco de Dados, '
137
+ 'no Anexo II - Planilha de Cálculo e Resultados Estatísticos e no Anexo III - Gráficos.')
138
+ add_body_text(doc, text)
139
+
140
+
141
+
142
+ def grouped_notes(doc, rows, getter, prefix, always_refs=False):
143
+ groups = OrderedDict()
144
+ for row in rows:
145
+ text = getter(row)
146
+ if text: groups.setdefault(str(text), []).append(ref(row))
147
+ for text, references in groups.items():
148
+ scope = f" ({'; '.join(references)})" if always_refs or len(references)!=len(rows) else ""
149
+ add_body_text(doc, f"{prefix}{scope}: {text}")
150
+
151
+
152
+ def definitions_for(doc, rows, keys):
153
+ """Different historical packages retain their own wording and applicability."""
154
+ for key in keys:
155
+ variants = OrderedDict()
156
+ for row in rows:
157
+ text = content_for(source(row)).get('notas',{}).get(key)
158
+ if text: variants.setdefault(text,[]).append(ref(row))
159
+ for text,references in variants.items():
160
+ add_body_text(doc,text + (f" Aplicável a {'; '.join(references)}." if len(variants)>1 else ''))
161
+
162
+
163
+ def gerar_corpo_evolutivo(doc, imported, num, caracteristicas_refs=None):
164
+ rows = imported['avaliacoes']
165
+ add_body_text(doc, 'O valor do imóvel é obtido pelo método evolutivo, mediante a composição do valor do terreno, estimado pelo método comparativo de dados de mercado, com o custo de reedição das construções, pelo método da quantificação de custo. A composição considera os ajustes registrados e, ao final, o fator de comercialização (Fc).')
166
+ add_body_text(doc, 'A demonstração a seguir transcreve os parâmetros e resultados salvos na avaliação, mantendo suas referências temporais. O Anexo IV reúne a memória detalhada. O valor calculado permanece distinto de eventual valor final adotado pelo avaliador.')
167
+ refs = {}
168
+ refs['terreno'] = heading(doc,num,'AVALIAÇÃO DO TERRENO',2)
169
+ heading(doc,num,'Aplicação Método Comparativo',3)
170
+ models = OrderedDict()
171
+ for row in rows: models.setdefault(context_key(row),[]).append(row)
172
+ groups = [(snapshot_model(group[0]), group) for group in models.values()]
173
+ for model, group in groups:
174
+ if model:
175
+ if len(groups) > 1:
176
+ add_body_text(doc, f"Pesquisa utilizada nas avaliações: {'; '.join(ref(row) for row in group)}.")
177
+ _adicionar_pesquisa(doc, model)
178
+ if any(model for model, _ in groups):
179
+ _adicionar_fundamentos_comparativos(doc)
180
+ for model, group in groups:
181
+ if model:
182
+ if len(groups) > 1:
183
+ add_body_text(doc, f"As variáveis e a equação a seguir se aplicam às avaliações: {'; '.join(ref(row) for row in group)}.")
184
+ _gerar_variaveis(doc, model, num, nivel=4, tipo_y=(terrain(group[0]).get('avaliacao') or {}).get('tipo_y','unitario'))
185
+ heading(doc,num,'Equação Modelo Estatístico',4)
186
+ add_body_text(doc, 'O modelo de regressão que melhor se ajustou aos dados da amostra é transcrito a seguir:')
187
+ doc.paragraphs[-1].paragraph_format.keep_with_next = True
188
+ _adicionar_tabela_equacao(doc, model)
189
+ local, _ = matching_local(group[0])
190
+ model_analysis(doc, local or model, (terrain(group[0]).get('avaliacao') or {}).get('tipo_y'))
191
+ else:
192
+ add_body_text(doc,'O arquivo não preserva o contexto estatístico necessário para transcrever a equação, a amostra e o cadastro de variáveis com segurança. Permanecem documentados os parâmetros e resultados importados; a versão correspondente do modelo deve ser complementada.')
193
+ heading(doc,num,'Cálculo do valor do terreno',3)
194
+ heading(doc,num,'Parâmetros e valor estimado pelo modelo',4)
195
+ keys = list(dict.fromkeys(key for row in rows for key in (terrain(row).get('parametrosAplicados') or row['avaliacao'].get('valores_x') or {})))
196
+ metrics = [(str(key),lambda row,k=key: str((terrain(row).get('parametrosAplicados') or row['avaliacao'].get('valores_x') or {}).get(k,'Não informado'))) for key in keys]
197
+ compared(doc,rows,metrics + [
198
+ ('Estimado Unitário', lambda row: currency(row['avaliacao'].get('estimado_unitario'))),
199
+ ('CA −15%', lambda row: currency(row['avaliacao'].get('ca_inf'))),
200
+ ('CA +15%', lambda row: currency(row['avaliacao'].get('ca_sup'))),
201
+ ('IC 80% Inf.', lambda row: currency(row['avaliacao'].get('ic_inf'))),
202
+ ('IC 80% Sup.', lambda row: currency(row['avaliacao'].get('ic_sup'))),
203
+ ], notes={
204
+ 'Estimado Unitário': 'Estimativa do valor do terreno por metro quadrado (R$/m²), antes dos ajustes. Quando o modelo estima o valor total, corresponde à conversão desse valor pela área utilizada no modelo.',
205
+ 'CA −15%': 'CA: campo de arbítrio. CA −15% e CA +15% são os limites inferior e superior, correspondentes a 85% e 115% da estimativa central. Não representam descontos automaticamente aplicados. Os limites são expressos na unidade da variável dependente do modelo: R$/m² no modelo unitário e R$ no modelo total.',
206
+ 'IC 80% Inf.': 'IC 80%: intervalo de confiança de 80% da estimativa do modelo. Inf. e Sup. indicam seus limites inferior e superior. Expressam a incerteza estatística e não se confundem com o campo de arbítrio. Os limites são expressos em R$/m² no modelo unitário e R$ no modelo total.',
207
+ }, batch_size=5)
208
+ add_body_text(doc,'A referência anual das avaliações é distinta do ano-base omitido na codificação das variáveis dicotômicas do modelo.')
209
+ heading(doc,num,'Ajustes aplicados e valor territorial adotado',4)
210
+ # Load after shared formatters are available to the narrative helper.
211
+ from .territorial_narrative import gerar_ajustes_territoriais, justificar_area
212
+ gerar_ajustes_territoriais(doc,rows,num,caracteristicas_refs or [])
213
+ heading(doc,num,'Síntese do valor territorial adotado',5)
214
+ add_body_text(doc,'Concluída a análise dos ajustes, o quadro a seguir reúne a formação do valor territorial em cada referência: parte-se do valor unitário do modelo, consideram-se os fatores aplicados e a escolha no campo de arbítrio e multiplica-se o valor unitário adotado pela área de aplicação.').paragraph_format.keep_with_next = True
215
+ metrics = [('VU antes dos ajustes (R$/m²)',lambda row:currency(terrain(row).get('valorUnitarioOriginal',row['avaliacao'].get('estimado_unitario'))))]
216
+ metrics += reducer_metrics(rows,True)
217
+ if any(applied_reducers(row,True) for row in rows):
218
+ metrics.append(('VU após redutores territoriais (R$/m²)',lambda row:currency(result(row).get('valorUnitarioAposRedutoresTerritoriais'))))
219
+ metrics += [
220
+ ('Fator do campo de arbítrio',lambda row:fmt(result(row).get('fatorCaFinal'),4)),
221
+ ('VU territorial adotado (R$/m²)',lambda row:currency(row.get('valor_unitario'))),
222
+ ('Área utilizada no modelo (m²)',lambda row:fmt(terrain(row).get('areaModelo',row['avaliacao'].get('valor_area')))),
223
+ ('Área de aplicação do VU (m²)',lambda row:fmt(row.get('area'))),
224
+ ('Valor do terreno (R$)',lambda row:currency(row.get('valor_terreno'))),
225
+ ]
226
+ compared(doc,rows,metrics,show_base=False)
227
+ justificar_area(doc,rows)
228
+ refs['construcoes'] = heading(doc,num,'AVALIAÇÃO DAS CONSTRUÇÕES',2)
229
+ heading(doc,num,'Aplicação do método da quantificação de custo',3)
230
+ add_body_text(doc,'O custo de reedição é documentado a partir da área de cálculo, do custo unitário básico (CUB) histórico, das adequações adotadas, do BDI e da depreciação física com valor residual. Os valores e coeficientes são os registrados para cada sequência construtiva.')
231
+ equation(doc,'CUNIT = CUB × (% CUB / 100)')
232
+ equation(doc,'VC = área de cálculo × CUNIT × (1 + BDI / 100) × Kd')
233
+ content = content_for(source(rows[0]))
234
+ definitions_for(doc,rows,['constructionValue','unitCost'])
235
+ heading(doc,num,'Características das construções e áreas',4)
236
+ for note in content.get('notas_area',[]): add_body_text(doc,note)
237
+ groups = construction_groups(rows)
238
+ if not groups: add_body_text(doc,'Não há sequências construtivas detalhadas preservadas; o total importado permanece identificado na composição.')
239
+ construction_characteristics(doc,groups)
240
+ heading(doc,num,'CUB histórico, referência temporal e adequações',4)
241
+ definitions_for(doc,rows,['cubGroup','cubType','cubValue','cubPercent'])
242
+ construction_cub_notes(doc,groups)
243
+ heading(doc,num,'BDI e justificativa',4)
244
+ definitions_for(doc,rows,['bdi'])
245
+ add_body_text(doc,'O BDI informado em percentual é convertido no multiplicador 1 + BDI/100. As referências gerais da MESA orientam a análise; não constituem, por si, uma composição de BDI efetivamente adotada.')
246
+ construction_bdi_notes(doc,groups)
247
+ heading(doc,num,'Depreciação e conservação',4)
248
+ definitions_for(doc,rows,['depreciation','residual','kd','conservation'])
249
+ add_body_text(doc,'Na expressão de Ross-Heidecke, q é a fração de vida útil consumida e H é o coeficiente de conservação. K representa a depreciação; R é o residual em forma decimal; Kd é o fator remanescente aplicado ao custo.')
250
+ equation(doc,'q = percentual de vida útil / 100; Ross = q/2 + q²/2')
251
+ equation(doc,'K = 1 − (1 − Ross) × (1 − H); Kd = R + (1 − K) × (1 − R)')
252
+ add_body_text(doc,'A MESA registra a curva de Ross com seis casas decimais, K com quatro e Kd com três. Os resultados abaixo são transcritos dos registros salvos, sem recompor os valores a partir de coeficientes arredondados para exibição.')
253
+ construction_depreciation_notes(doc,groups)
254
+ heading(doc,num,'Cálculo por construção e exercício',4)
255
+ for entries in groups.values():
256
+ add_body_text(doc,construction_name(entries[0][1],entries[0][2]))
257
+ by_id = {row['id']: c for row,c,i in entries}
258
+ # One comparative calculation table per effective construction, with all
259
+ # annual inputs together; characteristics above are grouped by changes.
260
+ compared(doc,[row for row,c,i in entries],[
261
+ ('Data do CUB',lambda row:cub_date(row)),
262
+ ('Projeto / composição CUB',lambda row:'CUB composto' if by_id[row['id']].get('cubCompostoAtivo') else str(by_id[row['id']].get('cubTipo') or 'Não informado')),
263
+ ('CUB histórico (R$/m²)',lambda row:currency(by_id[row['id']].get('valorCub'))),
264
+ ('Adequação do CUB (%)',lambda row:pct(by_id[row['id']].get('percentualCubCalculado',by_id[row['id']].get('percentualCub')))),
265
+ ('CUNIT (R$/m²)',lambda row:currency(by_id[row['id']].get('custoUnitario'))),
266
+ ('BDI (%)',lambda row:pct(by_id[row['id']].get('bdi'))),
267
+ ('Multiplicador 1 + BDI/100',lambda row:fmt(1+number(by_id[row['id']]['bdi'])/100,4) if number(by_id[row['id']].get('bdi')) is not None else 'Não informado'),
268
+ ('Vida útil consumida (%)',lambda row:pct(by_id[row['id']].get('percentualVidaUtil'))),
269
+ ('Depreciação K',lambda row:fmt(by_id[row['id']].get('depreciacao'),4)),
270
+ ('Fator remanescente Kd',lambda row:fmt(by_id[row['id']].get('kd'),3)),
271
+ ('Valor da construção - VC (R$)',lambda row:currency(by_id[row['id']].get('vcalcConstSeq'))),
272
+ ])
273
+ refs['composicao'] = heading(doc,num,'AVALIAÇÃO DO IMÓVEL OBJETO',2)
274
+ add_body_text(doc,'Na ausência de outros ajustes, a composição corresponde a VI = (VT + VC) × Fc, em que VT é o valor territorial e VC é o custo de reedição das construções. Havendo ajustes, a sequência efetivamente salva, apresentada abaixo, prevalece sobre essa expressão simplificada.').paragraph_format.keep_with_next = True
275
+ gerar_ajustes(doc,rows,territorial=False)
276
+ metrics = [
277
+ ('Valor do terreno',lambda row:currency(row['valor_terreno'])),
278
+ ('Valor das construções',lambda row:currency(row['valor_construcao'])),
279
+ ]
280
+ if any(applied_reducers(row,False) for row in rows):
281
+ metrics += [('Base antes dos redutores do imóvel',lambda row:currency(result(row).get('valorAposConstrucao')))]
282
+ metrics += reducer_metrics(rows,False)
283
+ metrics += [('Base após redutores / antes do Fc',lambda row:currency(result(row).get('valorAposRedutoresImovel')))]
284
+ metrics += [
285
+ ('Fc aplicado',lambda row:fmt(row.get('fator_comercializacao'),4)),
286
+ ('Valor total calculado',lambda row:currency(result(row).get('valorTotalCalculado',result(row).get('valorTotal')))),
287
+ ]
288
+ compared(doc,rows,metrics)
289
+ grouped_notes(doc,rows,lambda row:complement(row,'justificativa_fc'),'Justificativa do fator de comercialização')
290
+ grouped_notes(doc,rows,lambda row:complement(row,'ajustes_nao_aplicados',''),'Ajustes expressamente não aplicados e justificativa')
291
+ for row in rows:
292
+ for warning in row.get('revisao_documental',{}).get('conferencias',[]):
293
+ add_body_text(doc,f"Conferência — {ref(row)}: {warning}")
294
+ return refs
295
+
296
+
297
+ def construction_groups(rows):
298
+ groups = OrderedDict()
299
+ for row in rows:
300
+ for index,c in enumerate(row.get('construcoes') or []):
301
+ key = construction_identity(source(row),c,index)
302
+ groups.setdefault(key,[]).append((row,c,index))
303
+ return groups
304
+
305
+
306
+ def construction_name(c,index):
307
+ return f"Construção {c.get('seq') or index+1}" + (f" — {c['nome']}" if c.get('nome') else '')
308
+
309
+
310
+ def cub_date(row):
311
+ construction = source(row).get('construcao') or {}
312
+ value = (construction.get('avaliacao') or {}).get('cubData') or construction.get('cubData')
313
+ if not value: return 'Não informada'
314
+ text = str(value)
315
+ return f'{text[5:7]}/{text[:4]}' if len(text) >= 7 and text[4] == '-' else text
316
+
317
+
318
+ def entry_scope(entries, groups, show_base=True):
319
+ """Compact applicability while keeping changes and duplicate years explicit."""
320
+ selected = {(row['id'],i) for row,c,i in entries}
321
+ labels = []
322
+ for full in groups.values():
323
+ subset = [(row,c,i) for row,c,i in full if (row['id'],i) in selected]
324
+ if not subset: continue
325
+ label = construction_name(full[0][1],full[0][2])
326
+ if len(subset) != len(full): label += ' (' + '; '.join(ref(row, show_base=show_base) for row,c,i in subset) + ')'
327
+ labels.append(label)
328
+ return '; '.join(labels)
329
+
330
+
331
+ def construction_notes(doc, notes, groups):
332
+ for text,entries in notes.items():
333
+ add_body_text(doc,f"{text} Aplicável a {entry_scope(entries,groups)}.")
334
+
335
+
336
+ def construction_characteristics(doc, groups):
337
+ records = [['Construção / referência','Tipo / ano','Área real / de cálculo (m²)','Vida útil / residual R','Conservação / H']]
338
+ for entries in groups.values():
339
+ variants = OrderedDict()
340
+ for row,c,i in entries:
341
+ values = (f"{c.get('tipoConstrucao') or 'Não informado'} / {c.get('anoConstrucao') or 'Não informado'}",
342
+ f"{fmt(c.get('areaTotal'))} / {fmt(c.get('areaCalculada'))}",
343
+ f"{fmt(c.get('vidaUtil'),0)} anos / {pct(c.get('valorResidual'),True)}",
344
+ f"{c.get('estadoConservacao') or 'Não informado'} / {fmt(c.get('coefHeidecke'),4)}")
345
+ variants.setdefault(values,[]).append((row,c,i))
346
+ for values,subset in variants.items():
347
+ records.append([entry_scope(subset,groups,show_base=False)]+list(values))
348
+ if len(records)>1: table(doc,records,[1.6,1.6,1.2,1.1,1.5],font_size=7)
349
+ areas = OrderedDict()
350
+ for entries in groups.values():
351
+ for row,c,i in entries:
352
+ details = [{key:a.get(key) for key in ('descricao','area','coeficiente')} for a in c.get('areasEquivalentes') or []]
353
+ # A single area at coefficient 1 contributes no additional explanation.
354
+ if not details or (len(details)==1 and number(details[0].get('coeficiente'))==1
355
+ and number(c.get('areaTotal'))==number(c.get('areaCalculada'))==number(details[0].get('area'))): continue
356
+ areas.setdefault(json.dumps(details,sort_keys=True,ensure_ascii=False),[]).append((row,c,i))
357
+ for identity,entries in areas.items():
358
+ add_body_text(doc,'Composição das áreas — '+entry_scope(entries,groups)+'.')
359
+ table(doc,[['Tipo de área','Área real (m²)','Coeficiente','Parcela equivalente (m²)']]+[
360
+ [a.get('descricao') or 'Não informado',fmt(a.get('area')),fmt(a.get('coeficiente'),4),
361
+ fmt(number(a['area'])*number(a['coeficiente'])) if number(a.get('area')) is not None and number(a.get('coeficiente')) is not None else 'Não informado'] for a in json.loads(identity)],[3,1,1,1.4])
362
+
363
+
364
+ def construction_cub_notes(doc, groups):
365
+ notes,compositions = OrderedDict(),OrderedDict()
366
+ for entries in groups.values():
367
+ for row,c,i in entries:
368
+ catalog = content_for(source(row))['referencias'].get('siglasCub',[])
369
+ short = str(c.get('cubTipo') or '').split(' (',1)[0]
370
+ found = next((item for item in catalog if item.get('sigla')==short),{})
371
+ if found: notes.setdefault(f"{short}: {found['descricao']}.",[]).append((row,c,i))
372
+ if not c.get('cubCompostoAtivo'): continue
373
+ composition = c.get('cubComposicaoCalculada') or c.get('cubComposicao') or [
374
+ {'cubTipo':c.get('cubTipo'),'peso':c.get('cubPesoPrincipalCalculado'),'valorCub':c.get('valorCubPrincipal')},
375
+ {'cubTipo':c.get('cubTipoSecundario'),'peso':c.get('cubPesoSecundarioCalculado'),'valorCub':c.get('valorCubSecundario')}]
376
+ details = [{key:item.get(key) for key in ('cubTipo','peso','valorCub')} for item in composition]
377
+ # Historical values/weights distinguish compositions, incidental IDs do not.
378
+ compositions.setdefault(json.dumps(details,sort_keys=True,ensure_ascii=False),[]).append((row,c,i))
379
+ construction_notes(doc,notes,groups)
380
+ if compositions:
381
+ add_body_text(doc,'O CUB composto corresponde à média ponderada dos projetos: CUB = Σ(CUBᵢ × pesoᵢ) / Σ(pesoᵢ). Os valores compostos salvos constam no quadro de cálculo.')
382
+ records = [['Construção / referência','Projeto padrão','Peso','CUB histórico (R$/m²)']]
383
+ for identity,entries in compositions.items():
384
+ for item in json.loads(identity):
385
+ records.append([entry_scope(entries,groups,show_base=False),str(item.get('cubTipo') or 'Não informado'),fmt(item.get('peso'),4),currency(item.get('valorCub'))])
386
+ table(doc,records,[2.5,1.8,.8,1.4],font_size=7)
387
+
388
+
389
+ def construction_bdi_notes(doc, groups):
390
+ notes,compositions = OrderedDict(),OrderedDict()
391
+ for entries in groups.values():
392
+ for row,c,i in entries:
393
+ key = str(c.get('id') or c.get('seq') or i+1)
394
+ note = (row.get('complementos') or {}).get('bdi',{}).get(key) or 'não preservada no arquivo; complemento técnico pendente.'
395
+ notes.setdefault('Justificativa do BDI: '+note,[]).append((row,c,i))
396
+ reference = next((item for item in content_for(source(row))['referencias'].get('bdiTiposConstrucao',[]) if item.get('tipo')==c.get('tipoConstrucao')),None)
397
+ if reference:
398
+ notes.setdefault(f"Referência da MESA para {reference['tipo']}: BDI {reference['bdi']}. O percentual adotado consta no quadro de cálculo.",[]).append((row,c,i))
399
+ composition = c.get('bdiComposicaoAdotada')
400
+ if isinstance(composition,list) and composition:
401
+ details = [{key:item.get(key) for key in ('descricao','percentual')} for item in composition]
402
+ compositions.setdefault(json.dumps(details,sort_keys=True,ensure_ascii=False),[]).append((row,c,i))
403
+ construction_notes(doc,notes,groups)
404
+ if compositions:
405
+ records = [['Construção / referência','Componente de BDI adotado','Percentual']]
406
+ for identity,entries in compositions.items():
407
+ records.extend([[entry_scope(entries,groups,show_base=False),str(item.get('descricao') or ''),pct(item.get('percentual'))] for item in json.loads(identity)])
408
+ table(doc,records,[2.3,3.2,1],font_size=7)
409
+
410
+
411
+ def construction_depreciation_notes(doc,groups):
412
+ from backend.app.evo_documentation import public_reason
413
+ notes = OrderedDict()
414
+ for entries in groups.values():
415
+ for row,c,i in entries:
416
+ saved = ((source(row).get('construcao') or {}).get('avaliacao') or {}).get('referenciasCalculo') or content_for(source(row))['referencias']
417
+ life = next((item for item in saved.get('vidaUtilTipos',[]) if item.get('tipo')==c.get('tipoConstrucao')),None)
418
+ if life:
419
+ notes.setdefault(f"Referência de vida útil para {life['tipo']}: {life.get('vidaUtil','não informada')} anos. A vida consumida considera a idade no ano de referência, limitada a 100%, ou o ajuste manual registrado.",[]).append((row,c,i))
420
+ state = next((item for item in saved.get('estadosConservacao',[]) if item.get('estado')==c.get('estadoConservacao')),None)
421
+ if state and state.get('descricao'): notes.setdefault(f"{c['estadoConservacao']}: {state['descricao']}",[]).append((row,c,i))
422
+ if c.get('percentualVidaUtilManualAtivo') or c.get('percentualVidaUtilAjusteManual'):
423
+ reason = public_reason(c,'percentualVidaUtilJustificativa')
424
+ text = f"Ajuste manual de vida consumida: padrão {pct(c.get('percentualVidaUtilPadrao'))}; adotado {pct(c.get('percentualVidaUtil'))}. " + (reason or 'A justificativa pública não está disponível no arquivo.')
425
+ notes.setdefault(text,[]).append((row,c,i))
426
+ construction_notes(doc,notes,groups)
427
+
428
+
429
+ def reducer_metrics(rows,territorial):
430
+ keys = OrderedDict((key,label) for row in rows for key,label,rate in applied_reducers(row,territorial))
431
+ def display(row,key):
432
+ active = {k:rate for k,label,rate in applied_reducers(row,territorial)}
433
+ return pct(active[key],True) if key in active else '—'
434
+ return [(label+' — redução efetiva',lambda row,k=key:display(row,k)) for key,label in keys.items()]
435
+
436
+
437
+ def condition_description(row,territorial):
438
+ active = {key:rate for key,label,rate in applied_reducers(row,territorial)}
439
+ if not active: return ''
440
+ config = ((terrain(row).get('avaliacao') or {}).get('ajuste_territorial') or {}).get('configuracao') or {}
441
+ saved = source(row).get('catalogoAjustes') or {}
442
+ lines = []
443
+ if 'redutor88I' in active:
444
+ catalog = saved.get('art_88_i') or {}
445
+ minimum = number(catalog.get('limite_minimo_area'))
446
+ if minimum is not None:
447
+ lines.append(f"Inciso I: participam as condições com área atingida superior a {pct(minimum,True)}; cada contribuição corresponde à área relativa multiplicada pela redução de referência.")
448
+ for item in catalog.get('condicoes') or []:
449
+ area = number(((config.get('art_88_i') or {}).get('condicoes',{}).get(item.get('codigo')) or {}).get('percentual_area'))
450
+ rate = number(item.get('redutor',item.get('percentual_reducao')))
451
+ if area is not None and area>minimum and rate is not None and rate>0:
452
+ lines.append(f"{item.get('rotulo') or item['codigo']}: área {pct(area,True)} × redução de referência {pct(rate,True)} = contribuição {pct(area*rate,True)}.")
453
+ for key,group in [('redutor6061','art_60_61'),('redutor88II','art_88_ii')]:
454
+ if key not in active: continue
455
+ selected = (config.get(group) or {}).get('selecionados') or []
456
+ choices = [item for item in (saved.get(group) or {}).get('condicoes',[]) if item.get('codigo') in selected]
457
+ if key=='redutor6061':
458
+ choices = [item for item in choices if number(item.get('redutor'))==active[key]]
459
+ lines.append('Arts. 60 e 61: aplica-se somente a maior redução selecionada.')
460
+ if choices:
461
+ from .territorial_narrative import legal_reference, ARTICLE_88_DECREE
462
+ if key == 'redutor88II':
463
+ lines.append('Condições de incidência: '+ '; '.join(
464
+ f"{item.get('rotulo') or item['codigo']} ({legal_reference(key, [item['codigo']])})" for item in choices)+'.')
465
+ lines.append(f'Os dispositivos referidos integram o {ARTICLE_88_DECREE}.')
466
+ else:
467
+ lines.append('Condição de incidência: '+ '; '.join(item.get('rotulo') or item['codigo'] for item in choices)+'.')
468
+ if 'redutorTopografia' in active:
469
+ for part,rate_key in [('relevo','redutorRelevo'),('nivel_rua','redutorNivel')]:
470
+ code = (config.get('topografia') or {}).get(part)
471
+ item = next((item for item in (saved.get('topografia') or {}).get(part,[]) if item.get('codigo')==code),{})
472
+ rate = number(result(row).get(rate_key)) if rate_key in result(row) else number(item.get('redutor'))
473
+ if item and rate is not None and rate>0: lines.append(f"{item.get('rotulo') or code}: {pct(rate,True)}.")
474
+ if 'redutorAgua' in active:
475
+ code = (config.get('agua') or {}).get('situacao')
476
+ item = next((item for item in (saved.get('agua') or {}).get('condicoes',[]) if item.get('codigo')==code),{})
477
+ if item: lines.append((item.get('rotulo') or code)+'.')
478
+ if not saved: lines.append('Catálogo histórico de ajustes ausente; a redução efetiva foi transcrita do resultado salvo, sem atribuir condições presumidas.')
479
+ return ' '.join(lines)
480
+
481
+
482
+ def gerar_ajustes(doc,rows,territorial):
483
+ active_rows = [row for row in rows if applied_reducers(row,territorial)]
484
+ descriptions = OrderedDict()
485
+ for row in active_rows:
486
+ terms = content_for(source(row)).get('redutores_termos',{})
487
+ for key,label,rate in applied_reducers(row,territorial):
488
+ desc = terms.get(REDUCERS.get(key,('',''))[1])
489
+ if desc: descriptions.setdefault(label+': '+desc+'.',[]).append(row)
490
+ for text,applicable in descriptions.items():
491
+ add_body_text(doc,text + (f" Aplicável a {'; '.join(ref(row) for row in applicable)}." if len(applicable)!=len(rows) else ''))
492
+ if territorial and active_rows:
493
+ add_body_text(doc,'Os fatores dos redutores territoriais efetivamente aplicados incidem de forma multiplicativa, antes da escolha no campo de arbítrio. O quadro apresenta os percentuais de redução e os resultados salvos.')
494
+ grouped_notes(doc,rows,lambda row:condition_description(row,territorial),'Condições dos redutores aplicados')
495
+ if territorial:
496
+ apan_rows = [row for row in rows if (number(result(row).get('redutorApan')) or 0)>0
497
+ and number(result(row).get('fatorCaFinal')) not in (None,1)]
498
+ if apan_rows:
499
+ add_body_text(doc,'A referência de APAN participa da decisão no campo de arbítrio; não constitui desconto territorial autônomo e não é aplicada novamente.')
500
+ grouped_notes(doc,apan_rows,lambda row:f"Área atingida: {pct(((terrain(row).get('avaliacao') or {}).get('ajuste_territorial') or {}).get('configuracao',{}).get('apan',{}).get('percentual_area'),True)}; referência de APAN: {pct(result(row).get('redutorApan'),True)}; fator final adotado: {fmt(result(row).get('fatorCaFinal'),4)}.",'APAN considerada no campo de arbítrio',always_refs=True)
501
+ else:
502
+ limited = [row for row in rows if result(row).get('limiteArt88Aplicado')]
503
+ if limited:
504
+ add_body_text(doc,'O limite conjunto dos incisos I e II do art. 88, previsto em seu § 3º, foi efetivamente acionado nas referências abaixo. O percentual do inciso II exibido na composição já considera o limite; os valores de verificação são transcritos da avaliação.')
505
+ def cap_note(row):
506
+ catalog = source(row).get('catalogoAjustes') or {}
507
+ limit = number(catalog.get('limite_reducao_art_88',(catalog.get('art_88_ii') or {}).get('limite_combinado_art_88')))
508
+ if limit is None or not 0 <= limit <= 1:
509
+ return 'Percentual do limite não preservado no catálogo histórico; mantidos os valores de verificação salvos.'
510
+ return f'Limite conjunto de redução: {pct(limit,True)}; piso correspondente: {pct(1-limit,True)} do valor de referência.'
511
+ grouped_notes(doc,limited,cap_note,'Limite do art. 88',always_refs=True)
512
+ compared(doc,limited,[
513
+ ('Referência do art. 88',lambda row:currency(result(row).get('valorReferenciaArt88'))),
514
+ ('Resultado após inciso I',lambda row:currency(result(row).get('valorAposIncisoIArt88'))),
515
+ ('Resultado bruto dos incisos',lambda row:currency(result(row).get('valorAposArt88Bruto'))),
516
+ ('Resultado após limite do art. 88',lambda row:currency(result(row).get('valorAposArt88Aplicado'))),
517
+ ])
518
+
519
+
520
+ def gerar_especificacao_evolutivo(doc,imported,refs):
521
+ rows = imported['avaliacoes']
522
+ foundations = {row['id']:global_foundation(source(row)) for row in rows}
523
+ if doc.paragraphs: doc.paragraphs[-1].paragraph_format.keep_with_next = True
524
+ table(doc,[['IPTU','Terreno','Construções','Global','Precisão / amplitude do terreno']]+[
525
+ [ref(row,show_base=False),foundations[row['id']]['terrain']['grade'],foundations[row['id']]['construction']['grade'],foundations[row['id']]['grade'],str(row['avaliacao'].get('precisao') or 'Indisponível') + (' / ' + pct(row['avaliacao']['amplitude']) if row['avaliacao'].get('amplitude') is not None else '')] for row in rows],[1.4,1,1,1,1.4],font_size=7)
526
+ add_body_text(doc,f"A fundamentação do terreno corresponde à demonstração do item {refs['terreno']}; a das construções, ao item {refs['construcoes']}; e a composição global, ao item {refs['composicao']}. A precisão e a amplitude relativa do intervalo de confiança dizem respeito à estimativa territorial por regressão.")
527
+ content = content_for(source(rows[0]))
528
+ for section,key in [('terreno','terrain'),('construcoes','construction'),('global',None)]:
529
+ norm = content['normas'][section]
530
+ add_subsubsection_title(doc,'',norm['title']).paragraph_format.keep_with_next = True
531
+ add_body_text(doc,norm['source'] + ' ' + norm['framing']).paragraph_format.keep_with_next = True
532
+ first = foundations[rows[0]['id']][key] if key else foundations[rows[0]['id']]
533
+ compared(doc,rows,[(item['label'],lambda row,i=index,k=key: (foundations[row['id']][k] if k else foundations[row['id']])['items'][i]['grade'] + ' / ' + str((foundations[row['id']][k] if k else foundations[row['id']])['items'][i]['points'] if (foundations[row['id']][k] if k else foundations[row['id']])['items'][i]['points'] is not None else '—') + ' ponto(s)') for index,item in enumerate(first['items'])])
534
+ grouped_notes(doc,rows,lambda row,k=key: ' '.join(f"{item['label']}: {item['detail']}" for item in (foundations[row['id']][k] if k else foundations[row['id']])['items']),'Evidências e critérios')
535
+ content_versions = OrderedDict()
536
+ for row in rows:
537
+ f = foundations[row['id']]['terrain']
538
+ if f.get('missingRequirements'): add_body_text(doc,f"{ref(row)} — o Grau III do terreno depende da declaração dos requisitos adicionais; enquadramento pendente.")
539
+ catalog = content_for(source(row))
540
+ saved = source(row).get('documentacaoTecnica')
541
+ note = f"Conteúdo técnico MESA, versão documental {catalog['versao']}, identificação {catalog['identificador']}. " + ('Pacote identificado na exportação; dados e referências históricas do cálculo preservados.' if saved and saved.get('origem') == 'catalogo_exportacao' else 'Pacote preservado na avaliação.' if saved else 'Arquivo sem pacote documental histórico identificado; utilizadas as explicações gerais do catálogo sincronizado, preservando os dados e as referências de cálculo salvos.')
542
+ content_versions.setdefault(note,[]).append(ref(row))
543
+ for note,references in content_versions.items():
544
+ paragraph = add_body_text(doc,note + (f" Aplicável a {', '.join(references)}." if len(content_versions)>1 else ''))
545
+ paragraph.paragraph_format.keep_together = True
document/footnotes.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Notas de rodapé nativas, inclusive quando o documento já contém notas."""
2
+ from lxml import etree
3
+ from docx.oxml import OxmlElement, parse_xml
4
+ from docx.oxml.ns import qn
5
+ from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT
6
+ from docx.opc.packuri import PackURI
7
+ from docx.opc.part import Part, XmlPart
8
+ from docx.shared import Pt
9
+
10
+
11
+ def add_footnote(doc, paragraph, text):
12
+ try:
13
+ part = doc.part.part_related_by(RT.FOOTNOTES)
14
+ root = parse_xml(part.blob)
15
+ except KeyError:
16
+ root = OxmlElement('w:footnotes')
17
+ for identifier, kind in [(-1, 'separator'), (0, 'continuationSeparator')]:
18
+ note = OxmlElement('w:footnote')
19
+ note.set(qn('w:id'), str(identifier)); note.set(qn('w:type'), kind)
20
+ p = OxmlElement('w:p'); r = OxmlElement('w:r')
21
+ r.append(OxmlElement('w:'+kind)); p.append(r); note.append(p); root.append(note)
22
+ part = Part(PackURI('/word/footnotes.xml'), CT.WML_FOOTNOTES, b'', doc.part.package)
23
+ doc.part.relate_to(part, RT.FOOTNOTES)
24
+ identifier = max([0] + [int(n.get(qn('w:id'))) for n in root]) + 1
25
+ note = OxmlElement('w:footnote'); note.set(qn('w:id'), str(identifier))
26
+ p = OxmlElement('w:p'); props = OxmlElement('w:pPr')
27
+ spacing = OxmlElement('w:spacing'); spacing.set(qn('w:after'), '60')
28
+ props.append(spacing); p.append(props)
29
+ for marker, value in [('footnoteRef', None), ('t', ' '+text)]:
30
+ r = OxmlElement('w:r'); rp = OxmlElement('w:rPr')
31
+ font = OxmlElement('w:rFonts')
32
+ font.set(qn('w:ascii'), 'Arial'); font.set(qn('w:hAnsi'), 'Arial'); rp.append(font)
33
+ size = OxmlElement('w:sz'); size.set(qn('w:val'), '14'); rp.append(size)
34
+ if value is None:
35
+ vertical = OxmlElement('w:vertAlign'); vertical.set(qn('w:val'), 'superscript'); rp.append(vertical)
36
+ r.append(rp); element = OxmlElement('w:'+marker)
37
+ if value is not None:
38
+ element.text = value; element.set(qn('xml:space'), 'preserve')
39
+ r.append(element); p.append(r)
40
+ note.append(p); root.append(note)
41
+ if isinstance(part, XmlPart):
42
+ part._element = root
43
+ else:
44
+ part._blob = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
45
+ run = paragraph.add_run(); run.font.superscript = True
46
+ run.font.size = Pt(7)
47
+ reference = OxmlElement('w:footnoteReference'); reference.set(qn('w:id'), str(identifier))
48
+ run._r.append(reference)
49
+ return identifier
document/formatters/__init__.py CHANGED
@@ -14,6 +14,7 @@ from .heading import (
14
  add_subsection_title,
15
  add_subsubsection_title,
16
  add_subsubsubsection_title,
 
17
  )
18
  from .table import (
19
  set_cell_shading,
@@ -45,6 +46,7 @@ __all__ = [
45
  'add_subsection_title',
46
  'add_subsubsection_title',
47
  'add_subsubsubsection_title',
 
48
  # Tabelas
49
  'set_cell_shading',
50
  'formatar_celula_tabela',
 
14
  add_subsection_title,
15
  add_subsubsection_title,
16
  add_subsubsubsection_title,
17
+ add_subsubsubsubsection_title,
18
  )
19
  from .table import (
20
  set_cell_shading,
 
46
  'add_subsection_title',
47
  'add_subsubsection_title',
48
  'add_subsubsubsection_title',
49
+ 'add_subsubsubsubsection_title',
50
  # Tabelas
51
  'set_cell_shading',
52
  'formatar_celula_tabela',
document/formatters/heading.py CHANGED
@@ -7,6 +7,12 @@ from docx.enum.text import WD_ALIGN_PARAGRAPH
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 = {
@@ -15,7 +21,7 @@ def add_heading_custom(doc: Document, text: str, level: int = 1):
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
@@ -24,7 +30,7 @@ def add_heading_custom(doc: Document, text: str, level: int = 1):
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
  )
@@ -32,7 +38,7 @@ def add_section_title(doc: Document, number: str, text: str):
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
@@ -41,7 +47,7 @@ def add_subsection_title(doc: Document, number: str, text: str):
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
@@ -50,7 +56,16 @@ def add_subsubsection_title(doc: Document, number: str, text: str):
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
 
7
  from .paragraph import criar_paragrafo_formatado
8
 
9
 
10
+ def _heading_paragraph(*args, **kwargs):
11
+ paragraph = criar_paragrafo_formatado(*args, **kwargs)
12
+ paragraph.paragraph_format.keep_with_next = True
13
+ return paragraph
14
+
15
+
16
  def add_heading_custom(doc: Document, text: str, level: int = 1):
17
  """Adiciona título customizado."""
18
  configs = {
 
21
  }
22
  cfg = configs.get(level, {'tamanho': 11, 'antes': 6, 'depois': 6})
23
 
24
+ return _heading_paragraph(
25
  doc, text, negrito=True, sublinhado=True, tamanho=cfg['tamanho'],
26
  espaco_antes=cfg['antes'], espaco_depois=cfg['depois'],
27
  alinhamento=WD_ALIGN_PARAGRAPH.LEFT
 
30
 
31
  def add_section_title(doc: Document, number: str, text: str):
32
  """Adiciona título de seção principal (ex: 1. SOLICITAÇÃO)."""
33
+ return _heading_paragraph(
34
  doc, f"{number}. {text}", negrito=True, tamanho=12,
35
  espaco_antes=18, espaco_depois=12, alinhamento=WD_ALIGN_PARAGRAPH.LEFT
36
  )
 
38
 
39
  def add_subsection_title(doc: Document, number: str, text: str):
40
  """Adiciona título de subseção (ex: 1.1 Considerações)."""
41
+ return _heading_paragraph(
42
  doc, f"{number} {text}", negrito=True, tamanho=11,
43
  espaco_antes=12, espaco_depois=6, recuo_esq=0.3,
44
  alinhamento=WD_ALIGN_PARAGRAPH.LEFT
 
47
 
48
  def add_subsubsection_title(doc: Document, number: str, text: str):
49
  """Adiciona título de sub-subseção (ex: 3.2.1)."""
50
+ return _heading_paragraph(
51
  doc, f"{number} {text}", negrito=True, tamanho=11,
52
  espaco_antes=10, espaco_depois=4, recuo_esq=0.5,
53
  alinhamento=WD_ALIGN_PARAGRAPH.LEFT
 
56
 
57
  def add_subsubsubsection_title(doc: Document, number: str, text: str):
58
  """Adiciona título de sub-sub-subseção (ex: 3.2.1.1)."""
59
+ return _heading_paragraph(
60
+ doc, f"{number} {text}", negrito=True, tamanho=10,
61
+ espaco_antes=8, espaco_depois=4, recuo_esq=0.7,
62
+ alinhamento=WD_ALIGN_PARAGRAPH.LEFT
63
+ )
64
+
65
+
66
+ def add_subsubsubsubsection_title(doc: Document, number: str, text: str):
67
+ """Título de quinto nível sem reduzir a legibilidade do texto."""
68
+ return _heading_paragraph(
69
  doc, f"{number} {text}", negrito=True, tamanho=10,
70
  espaco_antes=8, espaco_depois=4, recuo_esq=0.7,
71
  alinhamento=WD_ALIGN_PARAGRAPH.LEFT
document/formatters/table.py CHANGED
@@ -15,6 +15,15 @@ from .paragraph import aplicar_cor_run
15
  WD_ALIGN_VERTICAL = WD_CELL_VERTICAL_ALIGNMENT
16
 
17
 
 
 
 
 
 
 
 
 
 
18
  def set_cell_shading(cell, color: str) -> None:
19
  """
20
  Define a cor de fundo de uma célula.
 
15
  WD_ALIGN_VERTICAL = WD_CELL_VERTICAL_ALIGNMENT
16
 
17
 
18
+ def set_table_font_size(table, size):
19
+ """Apply a uniform size to every run, including headers and note markers."""
20
+ for row in table.rows:
21
+ for cell in row.cells:
22
+ for paragraph in cell.paragraphs:
23
+ for run in paragraph.runs:
24
+ run.font.size = Pt(size)
25
+
26
+
27
  def set_cell_shading(cell, color: str) -> None:
28
  """
29
  Define a cor de fundo de uma célula.
document/fundamentacao.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Documentary framing equivalent to MESA modelFoundation/globalFoundation.
2
+
3
+ No valuation is recalculated. Missing evidence remains pending. Equivalence is
4
+ checked against MESA's JavaScript, including the per-item minima.
5
+ """
6
+ from decimal import Decimal, InvalidOperation
7
+ from .mesa_content import content_for
8
+
9
+ POINTS = {'Grau III': 3, 'Grau II': 2, 'Grau I': 1, 'Sem enquadramento': 0}
10
+
11
+
12
+ def number(value):
13
+ try:
14
+ if value is None or value == '' or isinstance(value, bool):
15
+ return None
16
+ text = str(value)
17
+ if ',' in text:
18
+ text = text.replace('.', '').replace(',', '.')
19
+ result = Decimal(text)
20
+ return result if result.is_finite() else None
21
+ except (InvalidOperation, ValueError):
22
+ return None
23
+
24
+
25
+ def grade(points):
26
+ return {3: 'Grau III', 2: 'Grau II', 1: 'Grau I', 0: 'Sem enquadramento'}.get(points, 'Pendente')
27
+
28
+
29
+ def threshold(value, limits):
30
+ value = number(value)
31
+ if value is None:
32
+ return None
33
+ return next((3-i for i, limit in enumerate(limits) if value <= Decimal(str(limit))), 0)
34
+
35
+
36
+ def model_foundation(source):
37
+ terrain = source.get('terreno') or {}
38
+ state = terrain.get('fundamentacaoModelo') or {}
39
+ context = terrain.get('contextoModelo') or {}
40
+ evidence = context.get('normativeEvidence') or {}
41
+ evaluation = terrain.get('avaliacao') or {}
42
+ content = content_for(source)
43
+ items = []
44
+ def add(key, label, points, detail=''):
45
+ items.append({'key': key, 'label': label, 'points': points, 'grade': grade(points), 'detail': detail})
46
+ for key, label in [('caracterizacao', 'Caracterização do imóvel avaliando'), ('identificacaoDados', 'Identificação dos dados de mercado')]:
47
+ saved = state.get(key) or {}
48
+ selected = saved.get('grau')
49
+ desc = content.get('descricoes_modelo', {}).get(key, {}).get(selected, '')
50
+ detail = ' '.join(filter(None, [desc, saved.get('justificativa')]))
51
+ add(key, label, POINTS.get(selected), detail or 'Enquadramento não informado pelo avaliador.')
52
+ n, k = number(evidence.get('n')), number(evidence.get('k'))
53
+ qty = next((points for mult, points in [(6, 3), (4, 2), (3, 1)] if n >= mult*(k+1)), 0) if n is not None and k is not None and k >= 0 else None
54
+ add('quantidadeDados', 'Quantidade de dados de mercado', qty, f'n = {n}; k = {k}.' if qty is not None else 'n e k indisponíveis no contexto salvo.')
55
+ extrap = POINTS.get(evaluation.get('fundamentacao'))
56
+ if extrap is None and 'qtd_extrapolacoes' in evaluation:
57
+ count = max(Decimal(0),number(evaluation['qtd_extrapolacoes']) or Decimal(0))
58
+ impact = number(evaluation.get('perc_ext')) or Decimal(0)
59
+ severe = any(isinstance(item, dict) and item.get('status') == 'grave' for item in (evaluation.get('extrapolacoes') or {}).values())
60
+ extrap = 0 if severe or (impact is not None and impact > 20) else 3 if count == 0 else 2 if count == 1 and impact is not None and impact <= 15 else 1
61
+ count_text = evaluation.get('qtd_extrapolacoes')
62
+ impact_text = evaluation.get('perc_ext')
63
+ detail = 'Nenhuma variável extrapolada.' if count_text == 0 else f"Variáveis extrapoladas: {count_text if count_text is not None else 'não informado'}." + (f' Impacto: {impact_text}%.' if impact_text is not None else '')
64
+ add('extrapolacao', 'Extrapolação', extrap, detail)
65
+ pvalues = [(item.get('name', ''), number(item.get('pValue'))) for item in evidence.get('regressorPValues', []) if isinstance(item, dict)]
66
+ worst = max(((name, p) for name, p in pvalues if p is not None), key=lambda item: item[1], default=('', None))
67
+ add('significanciaRegressores', 'Significância dos regressores', threshold(worst[1], [.1,.2,.3]), f'Maior p-valor: {worst[1]} ({worst[0]}).' if worst[1] is not None else 'p-valores indisponíveis.')
68
+ add('significanciaModelo', 'Teste F', threshold(evidence.get('fPValue'), [.01,.02,.05]), f"p-valor: {evidence.get('fPValue', 'não informado')}.")
69
+ by_key = {item['key']: item['points'] for item in items}
70
+ p = [by_key[key] for key in ['caracterizacao','quantidadeDados','identificacaoDados','extrapolacao','significanciaRegressores','significanciaModelo']]
71
+ total = sum(value for value in p if value is not None)
72
+ result = 'Pendente'
73
+ requirements = state.get('requisitosGrauIII') or {}
74
+ req_keys = [item['key'] for item in content.get('requisitos_modelo', [])]
75
+ candidate = all(value is not None for value in p) and total >= 16 and all(value >= minimum for value, minimum in zip(p,[2,3,2,3,3,3]))
76
+ missing_req = candidate and any(requirements.get(key) not in ('sim','nao',True,False) for key in req_keys)
77
+ if all(value is not None for value in p) and not missing_req:
78
+ if candidate and all(requirements.get(key) in ('sim',True) for key in req_keys): result = 'Grau III'
79
+ elif total >= 10 and all(value >= minimum for value, minimum in zip(p,[1,2,1,2,2,2])): result = 'Grau II'
80
+ elif all(value >= 1 for value in p): result = 'Grau I'
81
+ else: result = 'Sem enquadramento'
82
+ return {'items': items, 'totalPoints': total, 'grade': result, 'requirements': requirements, 'missingRequirements': bool(missing_req)}
83
+
84
+
85
+ def construction_foundation(source):
86
+ construction = source.get('construcao') or {}
87
+ saved = construction.get('fundamentacaoMqc') or (construction.get('avaliacao') or {}).get('fundamentacaoMqc') or {}
88
+ defs = saved.get('definitions') or {}
89
+ # Labels are extracted from the very function that assigns the MESA scores.
90
+ import re
91
+ rule = content_for(source)['funcoes_mqc']['calcularEnquadramento']
92
+ options = re.findall(r"definitions\.(\w+) === '([^']+)' \? (\d)", rule)
93
+ items = []
94
+ for key, label in [('formaCub','Custo direto'),('tipoBdi','BDI'),('depreciacao','Depreciação física')]:
95
+ value = defs.get(key)
96
+ points = next((int(score) for field, text, score in options if field == key and value == text), 1 if value else None)
97
+ items.append({'label':label,'points':points,'grade':grade(points),'detail':value or 'Critério não informado.'})
98
+ p = [item['points'] for item in items]
99
+ total = sum(value for value in p if value is not None)
100
+ result = 'Pendente'
101
+ if all(value is not None for value in p):
102
+ if total >= 7 and p[0] == 3 and min(p[1:]) >= 2: result = 'Grau III'
103
+ elif total >= 5 and p[0] >= 2 and p[1] >= 2: result = 'Grau II'
104
+ elif total >= 3: result = 'Grau I'
105
+ else: result = 'Fora dos critérios'
106
+ return {'items':items,'totalPoints':total,'grade':result}
107
+
108
+
109
+ def global_foundation(source):
110
+ terrain = model_foundation(source)
111
+ construction = construction_foundation(source)
112
+ result = source.get('resultadoComposicao') or {}
113
+ vt = number((source.get('terreno') or {}).get('valorTotal'))
114
+ vc = number((source.get('construcao') or {}).get('valorTotal'))
115
+ # calculate(year).beforeFc in MESA is the sum of these two saved components.
116
+ total_value = vt+vc if vt is not None and vc is not None else None
117
+ items = []
118
+ for label, value, foundation in [('Terreno',vt,terrain),('Construções',vc,construction)]:
119
+ share = value/total_value if value is not None and total_value is not None and total_value > 0 else None
120
+ reduced = value is not None and value > 0 and share is not None and share < Decimal('.15')
121
+ points = 2 if reduced else POINTS.get(foundation['grade'])
122
+ # Global MESA only recognises the three positive grades.
123
+ if not reduced and points == 0: points = None
124
+ items.append({'label':label,'grade':grade(points),'points':points,'detail':'Participação inferior a 15%; atribuídos dois pontos ao item.' if reduced else f"Fundamentação do componente: {foundation['grade']}.", 'share':share})
125
+ fc = str((source.get('fundamentacaoGlobal') or {}).get('fatorComercializacao') or '')
126
+ option = next((item for item in content_for(source)['criterios_fc'] if item['value'] == fc), {})
127
+ items.append({'label':'Fator de comercialização','grade':option.get('grade','Pendente'),'points':option.get('points'),'detail':option.get('label','Critério do Fc não informado.')})
128
+ p = [item['points'] for item in items]
129
+ total = sum(value for value in p if value is not None)
130
+ result_grade = 'Pendente'
131
+ if all(value is not None for value in p):
132
+ if total >= 8 and p[0] == p[1] == 3 and p[2] >= 2: result_grade = 'Grau III'
133
+ elif total >= 5 and p[0] >= 2 and p[1] >= 2: result_grade = 'Grau II'
134
+ elif total >= 3 and min(p) >= 1: result_grade = 'Grau I'
135
+ else: result_grade = 'Sem enquadramento'
136
+ return {'items':items,'totalPoints':total,'grade':result_grade,'terrain':terrain,'construction':construction}
document/generator.py CHANGED
@@ -11,7 +11,11 @@ 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,
@@ -29,7 +33,7 @@ from .formatters import (
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
  anexo_banco_dados_cabe_na_largura,
@@ -151,12 +155,16 @@ class LaudoGenerator:
151
  cell = row.cells[0]
152
  cell.merge(row.cells[1])
153
  criar_celula_cabecalho_tabela(cell, text)
 
 
154
 
155
  def add_row(label, value):
156
  row = table.add_row()
157
  configurar_linha_tabela_altura(row, 0.6)
158
  criar_celula_dados_tabela(row.cells[0], label, is_label=True)
159
  criar_celula_dados_tabela(row.cells[1], str(value) if value else "")
 
 
160
 
161
  # Definição das seções
162
  secoes = [
@@ -228,15 +236,19 @@ class LaudoGenerator:
228
 
229
  add_subsection_title(doc, num.subsecao(), "CARACTERÍSTICAS PARTICULARMENTE DESVALORIZANTES")
230
 
 
231
  secoes_motivos = motivos_formatados.get('secoes', [])
232
  if not secoes_motivos:
233
  add_placeholder_text(doc)
234
  else:
235
  for secao in secoes_motivos:
236
- add_subsubsection_title(doc, num.subsubsecao(), secao['titulo'])
 
 
237
  p = doc.add_paragraph()
238
  p.add_run(f"Status: {secao['status']}").italic = True
239
  p.paragraph_format.left_indent = Inches(0.5)
 
240
  add_placeholder_text(doc)
241
 
242
  # 3. ANÁLISE DE MERCADO
@@ -245,18 +257,34 @@ class LaudoGenerator:
245
  add_placeholder_text(doc, "[Inserir diagnóstico de mercado]")
246
 
247
  add_subsection_title(doc, num.subsecao(), "METODOLOGIA DE AVALIAÇÃO")
248
- # Tentar gerar metodologia a partir do modelo
249
- modelo_nome = dados.get('modelo_avaliacao', '')
250
- modelo = get_model(modelo_nome) if modelo_nome else None
251
- if modelo:
252
- gerar_anexo_metodologia(doc, modelo, num=num)
 
 
 
 
 
 
 
 
253
  else:
254
- add_placeholder_text(doc, "[Inserir metodologia - selecione um modelo]")
 
 
 
 
 
255
 
256
  # 4. ESPECIFICAÇÃO
257
  add_section_title(doc, num.secao(), "ESPECIFICAÇÃO DA AVALIAÇÃO")
258
  add_body_text(doc, f"Método: {dados.get('metodo_avaliacao', '')}")
259
- add_placeholder_text(doc)
 
 
 
260
 
261
  # 5. CONCLUSÃO
262
  add_section_title(doc, num.secao(), "CONCLUSÃO TÉCNICA")
@@ -275,19 +303,27 @@ class LaudoGenerator:
275
 
276
  add_subsection_title(doc, num.subsecao(), "SOBRE O VALOR DE MERCADO")
277
  valores_mercado_lista = dados.get('valores_mercado_lista', [])
278
- if valores_mercado_lista:
 
 
279
  self._gerar_tabela_valores_mercado(doc, valores_mercado_lista)
280
  else:
281
  add_placeholder_text(doc)
282
 
283
  add_subsection_title(doc, num.subsecao(), "OBSERVAÇÕES COMPLEMENTARES")
284
- add_placeholder_text(doc, "[Inserir observações]")
 
 
 
 
 
 
285
 
286
  # 6. ANEXOS (lista)
287
  add_section_title(doc, num.secao(), "ANEXOS")
288
  for anexo in ["I. BANCO DE DADOS", "II. PLANILHA DE CÁLCULO", "III. GRÁFICOS",
289
  "IV. CÁLCULO DO VALOR", "V. DADOS DO IMÓVEL", "VI. REGISTRO FOTOGRÁFICO"]:
290
- add_body_text(doc, anexo)
291
 
292
  def _gerar_tabela_valores_mercado(self, doc: Document, valores_lista: List[Dict]) -> None:
293
  """Gera uma tabela arejada de valores e lista os valores por extenso abaixo."""
@@ -408,38 +444,84 @@ class LaudoGenerator:
408
  section = doc.sections[-1]
409
  return int(section.page_width - section.left_margin - section.right_margin)
410
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  def _gerar_anexos(self, doc: Document, dados: Dict) -> None:
412
  """Gera as páginas de anexos a partir dos dados do modelo."""
413
- # Tentar carregar o modelo selecionado
414
- modelo_nome = dados.get('modelo_avaliacao', '')
415
- modelo = get_model(modelo_nome) if modelo_nome else None
416
- banco_dados_colunas = dados.get('banco_dados_colunas') or None
417
-
418
- # ANEXO I - BANCO DE DADOS. Usa paisagem apenas quando as colunas
419
- # selecionadas não cabem com legibilidade em retrato.
420
- if not anexo_banco_dados_cabe_na_largura(
421
- modelo,
422
- banco_dados_colunas,
423
- self._largura_util_secao_atual(doc),
424
- ):
425
- iniciar_secao_paisagem(doc)
426
- self._criar_titulo_anexo(doc, "ANEXO I – BANCO DE DADOS")
427
- gerar_anexo_banco_dados(doc, modelo, colunas=banco_dados_colunas)
428
-
429
- # ANEXO II - PLANILHA DE CÁLCULO (volta para retrato)
430
- iniciar_secao_retrato(doc)
431
- self._criar_titulo_anexo(doc, "ANEXO II – PLANILHA DE CÁLCULO E RESULTADOS ESTATÍSTICOS")
432
- gerar_anexo_planilha(doc, modelo)
433
-
434
- # ANEXO III - GRÁFICOS
435
- doc.add_page_break()
436
- self._criar_titulo_anexo(doc, "ANEXO III – GRÁFICOS")
437
- gerar_anexo_graficos(doc, modelo)
 
 
 
 
438
 
439
- # ANEXO IV - CÁLCULO DO VALOR
440
- doc.add_page_break()
441
- self._criar_titulo_anexo(doc, "ANEXO IV – CÁLCULO DO VALOR")
442
- gerar_anexo_calculo(doc, modelo)
443
 
444
  # ANEXO V - DADOS E LOCALIZAÇÃO (placeholder manual)
445
  doc.add_page_break()
 
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
+ from docx.oxml import OxmlElement
15
 
16
+ from .avaliacoes_importadas import conclusion_table, calculation_sections
17
+ from .evolutivo import gerar_corpo_evolutivo, gerar_especificacao_evolutivo
18
+ from models.imported_context import context_key, matching_local
19
  from .numbering import NumeradorSecoes
20
  from .formatters import (
21
  criar_paragrafo_formatado,
 
33
  )
34
 
35
  from config.settings import TEMPLATES_DIR, TEXTOS_DIR, OUTPUT_DIR
36
+ from models import get_model, list_available_models
37
  from utils.docx_loader import ler_texto_docx, mesclar_documento_fotos
38
  from .anexos import (
39
  anexo_banco_dados_cabe_na_largura,
 
155
  cell = row.cells[0]
156
  cell.merge(row.cells[1])
157
  criar_celula_cabecalho_tabela(cell, text)
158
+ for paragraph in cell.paragraphs:
159
+ paragraph.paragraph_format.keep_with_next = True
160
 
161
  def add_row(label, value):
162
  row = table.add_row()
163
  configurar_linha_tabela_altura(row, 0.6)
164
  criar_celula_dados_tabela(row.cells[0], label, is_label=True)
165
  criar_celula_dados_tabela(row.cells[1], str(value) if value else "")
166
+ if label in ('Valores de Mercado:', 'Referências:'):
167
+ row._tr.get_or_add_trPr().append(OxmlElement('w:cantSplit'))
168
 
169
  # Definição das seções
170
  secoes = [
 
236
 
237
  add_subsection_title(doc, num.subsecao(), "CARACTERÍSTICAS PARTICULARMENTE DESVALORIZANTES")
238
 
239
+ caracteristicas_refs = []
240
  secoes_motivos = motivos_formatados.get('secoes', [])
241
  if not secoes_motivos:
242
  add_placeholder_text(doc)
243
  else:
244
  for secao in secoes_motivos:
245
+ numero_caracteristica = num.subsubsecao()
246
+ add_subsubsection_title(doc, numero_caracteristica, secao['titulo'])
247
+ caracteristicas_refs.append({**secao, 'referencia':numero_caracteristica})
248
  p = doc.add_paragraph()
249
  p.add_run(f"Status: {secao['status']}").italic = True
250
  p.paragraph_format.left_indent = Inches(0.5)
251
+ p.paragraph_format.keep_with_next = True
252
  add_placeholder_text(doc)
253
 
254
  # 3. ANÁLISE DE MERCADO
 
257
  add_placeholder_text(doc, "[Inserir diagnóstico de mercado]")
258
 
259
  add_subsection_title(doc, num.subsecao(), "METODOLOGIA DE AVALIAÇÃO")
260
+ imported = dados.get('avaliacoes_importadas')
261
+ evo_refs = None
262
+ if imported and imported['tipo'] == 'evolutivo':
263
+ evo_refs = gerar_corpo_evolutivo(doc, imported, num, caracteristicas_refs)
264
+ elif imported:
265
+ add_body_text(doc, dados['metodo_avaliacao'])
266
+ for name in dict.fromkeys(item['modelo'] for item in imported['avaliacoes'] if item['modelo']):
267
+ modelo = get_model(name) if name in list_available_models() else None
268
+ if modelo:
269
+ add_body_text(doc, f"Modelo {name}")
270
+ gerar_anexo_metodologia(doc, modelo, num=num)
271
+ else:
272
+ add_body_text(doc, f"Modelo utilizado: {name}. O modelo não está disponível na biblioteca local para gerar os anexos estatísticos completos.")
273
  else:
274
+ modelo_nome = dados.get('modelo_avaliacao', '')
275
+ modelo = get_model(modelo_nome) if modelo_nome else None
276
+ if modelo:
277
+ gerar_anexo_metodologia(doc, modelo, num=num)
278
+ else:
279
+ add_placeholder_text(doc, "[Inserir metodologia - selecione um modelo]")
280
 
281
  # 4. ESPECIFICAÇÃO
282
  add_section_title(doc, num.secao(), "ESPECIFICAÇÃO DA AVALIAÇÃO")
283
  add_body_text(doc, f"Método: {dados.get('metodo_avaliacao', '')}")
284
+ if evo_refs:
285
+ gerar_especificacao_evolutivo(doc, imported, evo_refs)
286
+ else:
287
+ add_placeholder_text(doc)
288
 
289
  # 5. CONCLUSÃO
290
  add_section_title(doc, num.secao(), "CONCLUSÃO TÉCNICA")
 
303
 
304
  add_subsection_title(doc, num.subsecao(), "SOBRE O VALOR DE MERCADO")
305
  valores_mercado_lista = dados.get('valores_mercado_lista', [])
306
+ if dados.get('avaliacoes_importadas'):
307
+ conclusion_table(doc, dados['avaliacoes_importadas'])
308
+ elif valores_mercado_lista:
309
  self._gerar_tabela_valores_mercado(doc, valores_mercado_lista)
310
  else:
311
  add_placeholder_text(doc)
312
 
313
  add_subsection_title(doc, num.subsecao(), "OBSERVAÇÕES COMPLEMENTARES")
314
+ if evo_refs:
315
+ for row in imported['avaliacoes']:
316
+ if row.get('observacoes'):
317
+ add_body_text(doc, f"{row['titulo']}: {row['observacoes']}")
318
+ add_body_text(doc, f"Referências temporais: {dados.get('datas_referencia', '')}. A composição calculada está demonstrada no item {evo_refs['composicao']}; a conclusão e o cabeçalho apresentam os valores finais adotados.")
319
+ else:
320
+ add_placeholder_text(doc, "[Inserir observações]")
321
 
322
  # 6. ANEXOS (lista)
323
  add_section_title(doc, num.secao(), "ANEXOS")
324
  for anexo in ["I. BANCO DE DADOS", "II. PLANILHA DE CÁLCULO", "III. GRÁFICOS",
325
  "IV. CÁLCULO DO VALOR", "V. DADOS DO IMÓVEL", "VI. REGISTRO FOTOGRÁFICO"]:
326
+ add_body_text(doc, anexo).paragraph_format.keep_with_next = not anexo.startswith("VI.")
327
 
328
  def _gerar_tabela_valores_mercado(self, doc: Document, valores_lista: List[Dict]) -> None:
329
  """Gera uma tabela arejada de valores e lista os valores por extenso abaixo."""
 
444
  section = doc.sections[-1]
445
  return int(section.page_width - section.left_margin - section.right_margin)
446
 
447
+ def _gerar_anexos_importados(self, doc, imported):
448
+ available_names = set(list_available_models())
449
+ if imported['tipo'] == 'evolutivo':
450
+ distinct = {context_key(item): item for item in imported['avaliacoes']}
451
+ models = [(f'{name} — contexto {fingerprint}', matching_local(item)[0]) for (name,fingerprint),item in distinct.items()]
452
+ else:
453
+ models = [(name, get_model(name) if name in available_names else None) for name in dict.fromkeys(
454
+ item['modelo'] for item in imported['avaliacoes'] if item['modelo'])]
455
+ available = [(name, model) for name, model in models if model is not None]
456
+ if not available:
457
+ doc.add_page_break()
458
+ for title, builder in [
459
+ ('ANEXO I – BANCO DE DADOS', gerar_anexo_banco_dados),
460
+ ('ANEXO II – PLANILHA DE CÁLCULO E RESULTADOS ESTATÍSTICOS', gerar_anexo_planilha),
461
+ ('ANEXO III – GRÁFICOS', gerar_anexo_graficos),
462
+ ]:
463
+ if available:
464
+ landscape = builder == gerar_anexo_banco_dados and any(
465
+ not anexo_banco_dados_cabe_na_largura(model, None, self._largura_util_secao_atual(doc))
466
+ for _, model in available)
467
+ if landscape:
468
+ iniciar_secao_paisagem(doc)
469
+ else:
470
+ iniciar_secao_retrato(doc)
471
+ self._criar_titulo_anexo(doc, title)
472
+ if imported['tipo'] == 'evolutivo':
473
+ for item in distinct.values():
474
+ local, warning = matching_local(item)
475
+ if warning:
476
+ add_body_text(doc, f"{item['modelo']}: {warning} Os parâmetros e resultados originais permanecem no corpo e no Anexo IV.")
477
+ if not available:
478
+ add_body_text(doc, 'Não gerado: o modelo correspondente não está disponível na biblioteca local. Os resultados adotados por ano constam do Anexo IV.')
479
+ for name, model in available:
480
+ add_subsection_title(doc, '', name)
481
+ builder(doc, model)
482
+ if available:
483
+ iniciar_secao_retrato(doc)
484
+ else:
485
+ doc.add_page_break()
486
+ self._criar_titulo_anexo(doc, 'ANEXO IV – CÁLCULO DO VALOR POR ANO')
487
+ calculation_sections(doc, imported)
488
+
489
  def _gerar_anexos(self, doc: Document, dados: Dict) -> None:
490
  """Gera as páginas de anexos a partir dos dados do modelo."""
491
+ imported = dados.get('avaliacoes_importadas')
492
+ if imported:
493
+ self._gerar_anexos_importados(doc, imported)
494
+ else:
495
+ # Tentar carregar o modelo selecionado
496
+ modelo_nome = dados.get('modelo_avaliacao', '')
497
+ modelo = get_model(modelo_nome) if modelo_nome else None
498
+ banco_dados_colunas = dados.get('banco_dados_colunas') or None
499
+
500
+ # ANEXO I - BANCO DE DADOS. Usa paisagem apenas quando as colunas
501
+ # selecionadas não cabem com legibilidade em retrato.
502
+ if not anexo_banco_dados_cabe_na_largura(
503
+ modelo,
504
+ banco_dados_colunas,
505
+ self._largura_util_secao_atual(doc),
506
+ ):
507
+ iniciar_secao_paisagem(doc)
508
+ self._criar_titulo_anexo(doc, "ANEXO I – BANCO DE DADOS")
509
+ gerar_anexo_banco_dados(doc, modelo, colunas=banco_dados_colunas)
510
+
511
+ # ANEXO II - PLANILHA DE CÁLCULO (volta para retrato)
512
+ iniciar_secao_retrato(doc)
513
+ self._criar_titulo_anexo(doc, "ANEXO II – PLANILHA DE CÁLCULO E RESULTADOS ESTATÍSTICOS")
514
+ gerar_anexo_planilha(doc, modelo)
515
+
516
+ # ANEXO III - GRÁFICOS
517
+ doc.add_page_break()
518
+ self._criar_titulo_anexo(doc, "ANEXO III – GRÁFICOS")
519
+ gerar_anexo_graficos(doc, modelo)
520
 
521
+ # ANEXO IV - CÁLCULO DO VALOR
522
+ doc.add_page_break()
523
+ self._criar_titulo_anexo(doc, "ANEXO IV – CÁLCULO DO VALOR")
524
+ gerar_anexo_calculo(doc, modelo)
525
 
526
  # ANEXO V - DADOS E LOCALIZAÇÃO (placeholder manual)
527
  doc.add_page_break()
document/mesa_content.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Versioned documentary content extracted from MESA, never manually duplicated."""
2
+ import json
3
+ from functools import lru_cache
4
+ from config.settings import BASE_DIR
5
+
6
+
7
+ @lru_cache(maxsize=1)
8
+ def current_content():
9
+ return json.loads((BASE_DIR / 'config/mesa_documentacao.json').read_text())
10
+
11
+
12
+ def content_for(source):
13
+ saved = source.get('documentacaoTecnica') or {}
14
+ keys = ('notas','referencias','normas','redutores_termos','funcoes_mqc','criterios_fc','descricoes_modelo','requisitos_modelo')
15
+ valid = isinstance(saved,dict) and saved.get('versao') == 1 and saved.get('identificador') and all(key in saved for key in keys)
16
+ return saved if valid else current_content()
document/numbering.py CHANGED
@@ -21,7 +21,7 @@ class NumeradorSecoes:
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."""
@@ -49,8 +49,14 @@ class NumeradorSecoes:
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:
@@ -59,8 +65,10 @@ class NumeradorSecoes:
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."""
 
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, 0]
25
 
26
  def _reset_niveis_abaixo(self, nivel: int) -> None:
27
  """Reseta contadores de níveis abaixo do especificado."""
 
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
+ self._reset_niveis_abaixo(3)
53
  return f"{self.contadores[0]}.{self.contadores[1]}.{self.contadores[2]}.{self.contadores[3]}"
54
 
55
+ def subsubsubsubsecao(self) -> str:
56
+ """Incrementa o quinto nível (ex: '3.3.2.2.1')."""
57
+ self.contadores[4] += 1
58
+ return '.'.join(map(str,self.contadores))
59
+
60
  def numero_atual(self, nivel: int = 0) -> str:
61
  """Retorna o número atual de um nível sem incrementar."""
62
  if nivel == 0:
 
65
  return f"{self.contadores[0]}.{self.contadores[1]}"
66
  elif nivel == 2:
67
  return f"{self.contadores[0]}.{self.contadores[1]}.{self.contadores[2]}"
68
+ elif nivel == 3:
69
  return f"{self.contadores[0]}.{self.contadores[1]}.{self.contadores[2]}.{self.contadores[3]}"
70
+ else:
71
+ return '.'.join(map(str,self.contadores))
72
 
73
  def definir_secao(self, numero: int) -> None:
74
  """Define manualmente o número da seção atual."""
document/territorial_narrative.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Narrative from the recorded property conditions to the adopted land value.
2
+
3
+ Percentages and effects come from the saved MESA assessment. Legal references
4
+ identify the condition without replacing the historical percentages or results.
5
+ """
6
+ from collections import OrderedDict
7
+ import re
8
+ import unicodedata
9
+
10
+ from .evo_adjustments import REDUCERS, applied_reducers
11
+ from .fundamentacao import number
12
+ from .evolutivo import (source, terrain, result, ref, fmt, pct, complement,
13
+ heading, add_body_text)
14
+
15
+ # Original arts. 56, 60 and 61 and the 2019 wording of art. 88 checked on
16
+ # 21/10/2026. Percentages remain those recorded in the assessment.
17
+ DECREE_SOURCE = 'https://legislacao.portoalegre.rs.gov.br/media/sapl/public/normajuridica/sirel/Norma_16500_20091110_1.html'
18
+ DECREE = 'Decreto Municipal nº 16.500, de 10 de novembro de 2009'
19
+ AMENDMENT_SOURCE = 'https://dopaonlineupload.procempa.com.br/dopaonlineupload/3123_ce_272713_1.pdf'
20
+ ARTICLE_88_DECREE = DECREE+', na redação dada pelo Decreto Municipal nº 20.396, de 11 de novembro de 2019'
21
+ ARTICLE_88_LETTERS = {
22
+ 'redutor88I': {'app': 'a', 'diretriz_logradouro': 'b', 'faixa_redes': 'c',
23
+ 'alta_tensao': 'd', 'ocupacoes_irregulares': 'e'},
24
+ 'redutor88II': {'sem_agua': 'a', 'sem_luz': 'b', 'sem_esgoto': 'c', 'sem_pavimentacao': 'd'},
25
+ }
26
+ ALIASES = {
27
+ 'app': ['APP', 'área de preservação permanente'],
28
+ 'diretriz_logradouro': ['diretriz de logradouro', 'diretriz para implantação de logradouro'],
29
+ 'faixa_redes': ['faixa não edificável', 'passagem de redes'],
30
+ 'alta_tensao': ['alta tensão'],
31
+ 'ocupacoes_irregulares': ['ocupação irregular', 'ocupações irregulares'],
32
+ 'vila': ['imóvel em vila'], 'encravado': ['terreno encravado'],
33
+ 'triangular_ate_1000': ['terreno triangular', 'terreno interno triangular'],
34
+ 'topografia': ['topografia desfavorável', 'condições topográficas', 'declive', 'aclive', 'desnível'],
35
+ 'agua': ['inundação', 'alagamento', 'enchente'],
36
+ 'apan': ['APAN', 'área de proteção do ambiente natural', 'área de proteção ao ambiente natural'],
37
+ }
38
+
39
+
40
+ def normalized(text):
41
+ text = ''.join(c for c in unicodedata.normalize('NFKD',str(text)) if not unicodedata.combining(c))
42
+ return ' '.join(re.findall(r'[a-z0-9]+',text.casefold()))
43
+
44
+
45
+ def condition_refs(labels, characteristics):
46
+ """Only refer to actual numbered headings; no guessed positional mapping."""
47
+ phrases = [normalized(label) for label in labels if normalized(label)]
48
+ matches = []
49
+ for item in characteristics:
50
+ title = ' '+normalized(item.get('titulo',''))+' '
51
+ if any(' '+phrase+' ' in title for phrase in phrases):
52
+ reference = item.get('referencia')
53
+ if reference and reference not in matches: matches.append(reference)
54
+ return matches
55
+
56
+
57
+ def reference_sentence(labels, characteristics):
58
+ refs = condition_refs(labels,characteristics)
59
+ if not refs:
60
+ return 'Não há remissão específica a uma característica individualizada do capítulo 2.2 para esta condição.'
61
+ if len(refs)==1:
62
+ return f'A condição é examinada no item {refs[0]}, ao qual se remete sua caracterização.'
63
+ return 'As condições são examinadas nos itens '+join_words(refs)+', aos quais se remete sua caracterização.'
64
+
65
+
66
+ def join_words(items):
67
+ return items[0] if len(items)==1 else ', '.join(items[:-1])+' e '+items[-1]
68
+
69
+
70
+ def config_for(row):
71
+ return ((terrain(row).get('avaliacao') or {}).get('ajuste_territorial') or {}).get('configuracao') or {}
72
+
73
+
74
+ def weighted_conditions(row):
75
+ catalog = (source(row).get('catalogoAjustes') or {}).get('art_88_i') or {}
76
+ config = config_for(row)
77
+ minimum = number(catalog.get('limite_minimo_area'))
78
+ if minimum is None: return []
79
+ conditions = []
80
+ for item in catalog.get('condicoes') or []:
81
+ code = item.get('codigo')
82
+ area = number(((config.get('art_88_i') or {}).get('condicoes',{}).get(code) or {}).get('percentual_area'))
83
+ rate = number(item.get('redutor',item.get('percentual_reducao')))
84
+ if area is not None and area>minimum and rate is not None and rate>0:
85
+ conditions.append((code,item.get('rotulo') or code,area,rate,minimum))
86
+ return conditions
87
+
88
+
89
+ def scope(rows, selected):
90
+ if len(selected)==len(rows): return 'Para todas as referências avaliadas'
91
+ return 'Para '+join_words([ref(row) for row in selected])
92
+
93
+
94
+ def grouped_prose(doc, rows, entries):
95
+ variants = OrderedDict()
96
+ for row,text in entries: variants.setdefault(text,[]).append(row)
97
+ for text,selected in variants.items():
98
+ add_body_text(doc,scope(rows,selected)+', '+text).paragraph_format.keep_together = True
99
+
100
+
101
+ def legal_reference(key, codes=()):
102
+ if key in ARTICLE_88_LETTERS:
103
+ clause = 'I' if key == 'redutor88I' else 'II'
104
+ reference = f'art. 88, inciso {clause}'
105
+ mapping = ARTICLE_88_LETTERS[key]
106
+ # A missing/unknown condition must not inherit another condition's letter.
107
+ if codes and all(code in mapping for code in codes):
108
+ letters = list(dict.fromkeys('“'+mapping[code]+'”' for code in codes))
109
+ reference += (', alínea ' if len(letters)==1 else ', alíneas ')+join_words(letters)
110
+ return reference
111
+ if key in ('redutorTopografia', 'redutorAgua', 'apan'):
112
+ return 'art. 88, § 5º'
113
+ if key=='redutor6061':
114
+ articles = []
115
+ if 'vila' in codes: articles.append('art. 60, caput')
116
+ if 'encravado' in codes: articles.append('art. 61, inciso I')
117
+ if 'triangular_ate_1000' in codes: articles.append('art. 61, inciso II')
118
+ return join_words(articles) if articles else ''
119
+ return ''
120
+
121
+
122
+ def legal_basis(key, codes=()):
123
+ reference = legal_reference(key, codes)
124
+ if not reference:
125
+ return 'A memória não identifica a hipótese legal deste ajuste; o fundamento específico não pode ser atribuído apenas a partir do resultado numérico.'
126
+ if key=='redutor6061':
127
+ return f'O enquadramento da configuração do terreno fundamenta-se no {reference} do {DECREE}. O art. 56, parágrafo único, admite que a correção seja efetuada no preço do metro quadrado, pelos mesmos coeficientes.'
128
+ text = f'O tratamento desta condição fundamenta-se no {reference} do {ARTICLE_88_DECREE}.'
129
+ if key in ('redutorTopografia', 'redutorAgua', 'apan'):
130
+ text += ' O dispositivo admite a identificação particularizada de outras hipóteses de desvalorização e a avaliação de seu impacto, desde que fundamentadas tecnicamente.'
131
+ return text
132
+
133
+
134
+ def reducer_parts(row, key, label, rate):
135
+ """Split only documented, effective conditions, without revaluing the land."""
136
+ saved = source(row).get('catalogoAjustes') or {}
137
+ config = config_for(row)
138
+ if key=='redutor88I':
139
+ parts = []
140
+ for code,name,area,reference_rate,minimum in weighted_conditions(row):
141
+ text = f'a condição {name} foi considerada sobre {pct(area,True)} da área territorial. O percentual de referência registrado é de {pct(reference_rate,True)} sobre a parcela atingida; sua contribuição ponderada corresponde a {pct(area*reference_rate,True)} do valor unitário. O atingimento supera o limiar de {pct(minimum,True)} registrado para a incidência do ajuste.'
142
+ parts.append((key+':'+code,name,[name]+ALIASES.get(code,[]),text,[code]))
143
+ if parts: return parts
144
+ elif key=='redutor6061':
145
+ codes = (config.get('art_60_61') or {}).get('selecionados') or []
146
+ choices = [item for item in (saved.get('art_60_61') or {}).get('condicoes',[])
147
+ if item.get('codigo') in codes and number(item.get('redutor'))==rate]
148
+ if choices:
149
+ names = [item.get('rotulo') or item['codigo'] for item in choices]
150
+ labels = names+[alias for item in choices for alias in ALIASES.get(item['codigo'],[])]
151
+ return [(key,'Configuração do terreno',labels,f'considerou-se {join_words(names)}, aplicando-se a redução de {pct(rate,True)} ao valor unitário territorial. Entre as condições selecionadas nos arts. 60 e 61, este é o maior percentual efetivamente utilizado; os percentuais não são somados.',[item['codigo'] for item in choices])]
152
+ elif key=='redutorTopografia':
153
+ descriptions,labels = [],[]
154
+ for part,rate_key in [('relevo','redutorRelevo'),('nivel_rua','redutorNivel')]:
155
+ code = (config.get('topografia') or {}).get(part)
156
+ item = next((item for item in (saved.get('topografia') or {}).get(part,[]) if item.get('codigo')==code),{})
157
+ part_rate = number(result(row).get(rate_key)) if rate_key in result(row) else number(item.get('redutor'))
158
+ if item and part_rate is not None and part_rate>0:
159
+ name = item.get('rotulo') or code
160
+ labels.append(name);descriptions.append(f'{name} ({pct(part_rate,True)})')
161
+ if descriptions:
162
+ return [(key,'Condições topográficas',labels+ALIASES['topografia'],f'foram consideradas as condições {join_words(descriptions)}. Suas parcelas integram o ajuste topográfico, cuja redução efetiva registrada é de {pct(rate,True)} do valor unitário territorial.',[])]
163
+ elif key=='redutorAgua':
164
+ code = (config.get('agua') or {}).get('situacao')
165
+ item = next((item for item in (saved.get('agua') or {}).get('condicoes',[]) if item.get('codigo')==code),{})
166
+ if item:
167
+ name = item.get('rotulo') or code
168
+ return [(key,'Inundação e ação da água',[name]+ALIASES['agua'],f'a situação considerada foi {name}, com redução efetiva de {pct(rate,True)} do valor unitário territorial.',[])]
169
+ # A missing catalog or breakdown must not be replaced with today's rates.
170
+ text = f'o resultado da avaliação registra redução de {pct(rate,True)} do valor unitário territorial.' if rate is not None else 'o arquivo registra esta etapa de ajuste, mas não preserva seu percentual efetivo.'
171
+ return [(key,label,[label],text+' A condição específica e os parâmetros de incidência não foram preservados em detalhe.',[])]
172
+
173
+
174
+ def summarize_restrictions(doc,rows):
175
+ entries = []
176
+ for row in rows:
177
+ active = {key:rate for key,label,rate in applied_reducers(row,True)}
178
+ if 'redutor88I' not in active: continue
179
+ rate = active['redutor88I']
180
+ text = f'consideradas em conjunto as restrições ponderadas pela área, a redução efetiva registrada é de {pct(rate,True)} do valor unitário territorial.'
181
+ conditions = weighted_conditions(row)
182
+ weighted = sum(area*reference for code,name,area,reference,minimum in conditions)
183
+ if conditions and rate is not None and abs(weighted-rate)>number('0.000000001'):
184
+ text += ' A soma das contribuições ponderadas não coincide com esse percentual salvo; a divergência requer conferência, preservando-se o resultado de origem.'
185
+ entries.append((row,text))
186
+ grouped_prose(doc,rows,entries)
187
+
188
+
189
+ def gerar_ajustes_territoriais(doc, rows, num, characteristics):
190
+ groups = OrderedDict()
191
+ for row in rows:
192
+ for key,label,rate in applied_reducers(row,True):
193
+ for identity,title,labels,text,codes in reducer_parts(row,key,label,rate):
194
+ groups.setdefault(identity,[]).append((row,key,title,labels,text,codes))
195
+ if groups:
196
+ add_body_text(doc,'A passagem da estimativa do modelo ao valor territorial adotado considera as condições particulares do imóvel examinadas no capítulo 2.2. Cada ajuste efetivamente aplicado é fundamentado nos itens seguintes, com indicação da condição de incidência e do percentual utilizado em cada referência.')
197
+ if any(identity.startswith('redutor88I:') for identity in groups):
198
+ add_body_text(doc,'As contribuições das restrições ponderadas pela área são somadas para formar um único redutor. Esse fator participa da composição multiplicativa com os demais ajustes territoriais, antes da escolha do valor no campo de arbítrio.')
199
+ order = {key:i for i,key in enumerate(REDUCERS)}
200
+ ordered = sorted(groups.items(),key=lambda pair:order.get(pair[0].split(':',1)[0],len(order)))
201
+ restrictions = [identity for identity in groups if identity.startswith('redutor88I:')]
202
+ for identity,entries in ordered:
203
+ first = entries[0]
204
+ heading(doc,num,first[2],5)
205
+ # Group the full narrative so historical labels or legal hypotheses
206
+ # are never silently borrowed from another exercise.
207
+ variants = OrderedDict()
208
+ for row,key,title,labels,text,codes in entries:
209
+ opening = reference_sentence(labels,characteristics)+' '+legal_basis(key,codes)
210
+ variants.setdefault(opening,[]).append((row,text))
211
+ for opening,details in variants.items():
212
+ add_body_text(doc,opening).paragraph_format.keep_with_next = True
213
+ grouped_prose(doc,rows,details)
214
+ if restrictions and identity==restrictions[-1]:
215
+ summarize_restrictions(doc,rows)
216
+ else:
217
+ add_body_text(doc,'O resultado salvo não registra redutores territoriais com efeito identificado. A passagem ao valor adotado considera a escolha no campo de arbítrio e a área de aplicação, demonstradas a seguir.')
218
+ campo_arbitrio(doc,rows,num,characteristics)
219
+
220
+
221
+ def campo_arbitrio(doc, rows, num, characteristics):
222
+ active = [row for row in rows if number(result(row).get('fatorCaFinal')) not in (None,1)]
223
+ # A public choice justification can matter even when the final factor is 1.
224
+ justified = any((row.get('complementos') or {}).get('justificativa_vu') for row in rows)
225
+ if not active and not justified: return
226
+ heading(doc,num,'Escolha do valor no campo de arbítrio',5)
227
+ add_body_text(doc,'Após a incidência dos ajustes territoriais, a escolha do valor unitário é tratada no campo de arbítrio da estimativa, no âmbito da metodologia da ABNT NBR 14653-2. O fator adotado expressa essa decisão técnica e deve ser lido em conjunto com sua justificativa.')
228
+ apan = [row for row in active if (number(result(row).get('redutorApan')) or 0)>0]
229
+ if apan:
230
+ add_body_text(doc,reference_sentence(ALIASES['apan'],characteristics)+' '+legal_basis('apan')+' A influência da APAN participa desta decisão, sem constituir um redutor autônomo a ser descontado novamente.')
231
+ grouped_prose(doc,rows,[(row,f'a área atingida por APAN é de {pct((config_for(row).get("apan") or {}).get("percentual_area"),True)}, com referência de influência de {pct(result(row).get("redutorApan"),True)}. O fator final escolhido para o campo de arbítrio é {fmt(result(row).get("fatorCaFinal"),4)}.') for row in apan])
232
+ entries = []
233
+ for row in rows:
234
+ factor = number(result(row).get('fatorCaFinal'))
235
+ reason = complement(row,'justificativa_vu','')
236
+ if factor in (None,1) and not reason: continue
237
+ text = f'o fator do campo de arbítrio adotado foi {fmt(factor,4)}. ' if factor is not None else 'o fator do campo de arbítrio não foi preservado no arquivo. '
238
+ text += 'A justificativa técnica registrada para essa escolha é: '+reason if reason else 'A justificativa técnica dessa escolha não foi informada no arquivo.'
239
+ entries.append((row,text))
240
+ grouped_prose(doc,rows,entries)
241
+
242
+
243
+ def justificar_area(doc,rows):
244
+ entries = []
245
+ for row in rows:
246
+ model_area = number(terrain(row).get('areaModelo',row['avaliacao'].get('valor_area')))
247
+ area = number(row.get('area'))
248
+ if model_area is None or area is None or model_area==area: continue
249
+ reason = complement(row,'justificativa_area','')
250
+ text = f'o modelo utilizou a área de {fmt(model_area)} m² para estimar o valor unitário, enquanto sua aplicação ao imóvel considerou {fmt(area)} m². '
251
+ text += 'A distinção de áreas foi justificada nos seguintes termos: '+reason if reason else 'A justificativa para a distinção de áreas não foi informada no arquivo.'
252
+ entries.append((row,text))
253
+ grouped_prose(doc,rows,entries)
frontend/package.json CHANGED
@@ -4,6 +4,7 @@
4
  "version": "0.1.0",
5
  "type": "module",
6
  "scripts": {
 
7
  "dev": "vite",
8
  "build": "vite build",
9
  "preview": "vite preview --host 0.0.0.0 --port 4173"
 
4
  "version": "0.1.0",
5
  "type": "module",
6
  "scripts": {
7
+ "test": "node --test tests/*.test.mjs",
8
  "dev": "vite",
9
  "build": "vite build",
10
  "preview": "vite preview --host 0.0.0.0 --port 4173"
frontend/src/App.jsx CHANGED
@@ -1,4 +1,8 @@
1
- import { useEffect, useMemo, useState } from "react";
 
 
 
 
2
 
3
  const API_BASE = (
4
  import.meta.env.VITE_API_BASE_URL ||
@@ -6,27 +10,16 @@ const API_BASE = (
6
  ).trim();
7
 
8
  const TAB_LABELS = [
 
9
  "1. Solicitação",
10
  "2. Documentação",
11
  "3. Motivos Desvalorizantes",
12
  "4. Imóvel Objeto",
13
  "5. Avaliação",
14
- "6. Valores de Mercado",
15
- "7. Registro Fotográfico",
16
- "8. Gerar Documento",
17
  ];
18
 
19
- const DEFAULT_UNSELECTED_COLUMNS = new Set([
20
- "REGISTRO",
21
- "IMO-INSC",
22
- "CTM",
23
- "NUMERO",
24
- "NUM",
25
- "UNIDADE",
26
- "COMPLEMENTO",
27
- "COMPL",
28
- ]);
29
-
30
  const emptyMotivo = {
31
  descricao: "",
32
  alegado: true,
@@ -43,8 +36,7 @@ const normalizeApiUrl = (path) => {
43
  return `${base}${route}`;
44
  };
45
 
46
- const isSelectedByDefault = (columnName) =>
47
- !DEFAULT_UNSELECTED_COLUMNS.has(String(columnName).trim().toLocaleUpperCase("pt-BR"));
48
 
49
  const applyCurrencyMask = (value) => {
50
  if (!value) {
@@ -166,10 +158,13 @@ const applyDummyToForm = (base, dummy) => {
166
  function App() {
167
  const [options, setOptions] = useState(null);
168
  const [form, setForm] = useState(null);
169
- const [activeTab, setActiveTab] = useState(0);
 
 
170
 
171
- const [anoInicial, setAnoInicial] = useState(2020);
172
- const [anoFinal, setAnoFinal] = useState(new Date().getFullYear());
 
173
 
174
  const [pdfFile, setPdfFile] = useState(null);
175
  const [registroFotoFile, setRegistroFotoFile] = useState(null);
@@ -177,9 +172,7 @@ function App() {
177
  const [loadingOptions, setLoadingOptions] = useState(true);
178
  const [loadingPdf, setLoadingPdf] = useState(false);
179
  const [loadingGenerate, setLoadingGenerate] = useState(false);
180
- const [loadingYears, setLoadingYears] = useState(false);
181
- const [loadingColumns, setLoadingColumns] = useState(false);
182
- const [databaseColumns, setDatabaseColumns] = useState([]);
183
 
184
  const [error, setError] = useState("");
185
  const [pdfWarnings, setPdfWarnings] = useState([]);
@@ -201,8 +194,7 @@ function App() {
201
 
202
  setForm(buildEmptyForm(payload));
203
 
204
- setAnoInicial(payload.defaults?.ano_inicial_mercado ?? 2020);
205
- setAnoFinal(payload.defaults?.ano_final_mercado ?? new Date().getFullYear());
206
  } catch (err) {
207
  setError(String(err));
208
  } finally {
@@ -220,82 +212,28 @@ function App() {
220
  return normalizeApiUrl(result.download_url);
221
  }, [result]);
222
 
223
- const limits = options?.limits || {};
224
- const selectedColumns = useMemo(
225
- () => databaseColumns.filter((column) => column.selected).map((column) => column.name),
226
- [databaseColumns],
227
- );
228
-
229
  useEffect(() => {
230
- const modelName = form?.modelo_avaliacao;
231
- if (!modelName) {
232
- setDatabaseColumns([]);
233
- return undefined;
234
- }
235
-
236
- const controller = new AbortController();
237
- const loadColumns = async () => {
238
- setLoadingColumns(true);
239
- try {
240
- const response = await fetch(
241
- normalizeApiUrl(`/api/models/${encodeURIComponent(modelName)}/columns`),
242
- { signal: controller.signal },
243
- );
244
- const payload = await response.json();
245
- if (!response.ok) {
246
- throw new Error(payload.detail || "Não foi possível carregar as colunas do modelo.");
247
- }
248
- setDatabaseColumns(
249
- (payload.columns || []).map((name) => ({ name, selected: isSelectedByDefault(name) })),
250
- );
251
- } catch (err) {
252
- if (err.name !== "AbortError") {
253
- setDatabaseColumns([]);
254
- setError(String(err));
255
- }
256
- } finally {
257
- if (!controller.signal.aborted) {
258
- setLoadingColumns(false);
259
- }
260
- }
261
- };
262
 
263
- loadColumns();
264
- return () => controller.abort();
265
- }, [form?.modelo_avaliacao]);
266
 
 
267
  const updateField = (field, value) => {
 
 
268
  setForm((prev) => ({ ...prev, [field]: value }));
269
  };
270
 
271
- const toggleDatabaseColumn = (columnName) => {
272
- setDatabaseColumns((current) =>
273
- current.map((column) =>
274
- column.name === columnName ? { ...column, selected: !column.selected } : column,
275
- ),
276
- );
277
- };
278
-
279
- const moveDatabaseColumn = (index, direction) => {
280
- setDatabaseColumns((current) => {
281
- const targetIndex = index + direction;
282
- if (targetIndex < 0 || targetIndex >= current.length) {
283
- return current;
284
- }
285
- const next = [...current];
286
- [next[index], next[targetIndex]] = [next[targetIndex], next[index]];
287
- return next;
288
- });
289
- };
290
-
291
- const selectAllDatabaseColumns = () => {
292
- setDatabaseColumns((current) => current.map((column) => ({ ...column, selected: true })));
293
- };
294
-
295
- const clearDatabaseColumns = () => {
296
- setDatabaseColumns((current) => current.map((column) => ({ ...column, selected: false })));
297
- };
298
-
299
  const updateStringList = (field, index, value) => {
300
  setForm((prev) => {
301
  const list = [...prev[field]];
@@ -323,6 +261,8 @@ function App() {
323
  };
324
 
325
  const updateMotivo = (index, patch) => {
 
 
326
  setForm((prev) => {
327
  const next = [...prev.motivos_desvalorizantes];
328
  next[index] = { ...next[index], ...patch };
@@ -331,6 +271,7 @@ function App() {
331
  };
332
 
333
  const addMotivo = () => {
 
334
  setForm((prev) => {
335
  if (prev.motivos_desvalorizantes.length >= limits.max_motivos_desvalorizantes) {
336
  return prev;
@@ -343,6 +284,7 @@ function App() {
343
  };
344
 
345
  const removeMotivo = () => {
 
346
  setForm((prev) => {
347
  if (prev.motivos_desvalorizantes.length <= 1) {
348
  return prev;
@@ -354,50 +296,6 @@ function App() {
354
  });
355
  };
356
 
357
- const updateValorMercado = (index, patch) => {
358
- setForm((prev) => {
359
- const next = [...prev.valores_mercado];
360
- next[index] = { ...next[index], ...patch };
361
- return { ...prev, valores_mercado: next };
362
- });
363
- };
364
-
365
- const handleGenerateYears = async () => {
366
- setLoadingYears(true);
367
- setError("");
368
-
369
- try {
370
- const response = await fetch(normalizeApiUrl("/api/market-values/generate"), {
371
- method: "POST",
372
- headers: { "Content-Type": "application/json" },
373
- body: JSON.stringify({
374
- ano_inicial: Number(anoInicial),
375
- ano_final: Number(anoFinal),
376
- }),
377
- });
378
-
379
- if (!response.ok) {
380
- const body = await response.json().catch(() => ({}));
381
- throw new Error(body.detail || "Erro ao gerar anos.");
382
- }
383
-
384
- const payload = await response.json();
385
- setForm((prev) => ({
386
- ...prev,
387
- valores_mercado: payload.anos.map((ano) => ({ ano, valor: "" })),
388
- datas_referencia: payload.referencias || "",
389
- }));
390
- } catch (err) {
391
- setError(String(err));
392
- } finally {
393
- setLoadingYears(false);
394
- }
395
- };
396
-
397
- const handleClearYears = () => {
398
- setForm((prev) => ({ ...prev, valores_mercado: [], datas_referencia: "" }));
399
- };
400
-
401
  const handleExtractPdf = async () => {
402
  if (!pdfFile) {
403
  setError("Selecione um arquivo PDF antes de processar.");
@@ -458,6 +356,8 @@ function App() {
458
  return;
459
  }
460
 
 
 
461
  setForm((prev) => {
462
  const dropdowns = options.dropdowns || {};
463
  const demoBase = {
@@ -470,25 +370,37 @@ function App() {
470
  metodo_avaliacao: dropdowns.metodos_avaliacao?.[0] || prev.metodo_avaliacao,
471
  modelo_avaliacao: options.models?.[0] || prev.modelo_avaliacao,
472
  };
473
- return applyDummyToForm(demoBase, options.dummy);
 
 
 
 
474
  });
475
  };
476
 
477
  const handleClearForm = () => {
 
 
478
  setForm(buildEmptyForm(options));
479
- setDatabaseColumns([]);
 
 
 
480
  setError("");
481
  setPdfWarnings([]);
482
  setResult(null);
 
483
  };
484
 
485
  const handleGenerateDocument = async () => {
486
- if (loadingColumns) {
487
- setError("Aguarde o carregamento das colunas do banco de dados.");
 
488
  return;
489
  }
490
- if (databaseColumns.length > 0 && selectedColumns.length === 0) {
491
- setError("Selecione ao menos uma coluna para o Anexo I - Banco de Dados.");
 
492
  return;
493
  }
494
 
@@ -502,12 +414,15 @@ function App() {
502
  documentacao_padrao: form.documentacao_padrao.filter((item) => item.trim()),
503
  documentacao_especifica: form.documentacao_especifica.filter((item) => item.trim()),
504
  motivos_desvalorizantes: form.motivos_desvalorizantes.filter((item) => item.descricao.trim()),
505
- valores_mercado: form.valores_mercado.filter((item) => item.ano && item.valor),
506
- banco_dados_colunas: selectedColumns,
 
 
507
  };
508
 
509
  const formData = new FormData();
510
  formData.append("payload", JSON.stringify(payload));
 
511
  if (registroFotoFile) {
512
  formData.append("registro_fotografico", registroFotoFile);
513
  }
@@ -524,7 +439,7 @@ function App() {
524
 
525
  const generated = await response.json();
526
  setResult(generated);
527
- setActiveTab(7);
528
  } catch (err) {
529
  setError(String(err));
530
  } finally {
@@ -563,8 +478,8 @@ function App() {
563
  <button
564
  key={label}
565
  type="button"
566
- className={index === activeTab ? "tab-btn active" : "tab-btn"}
567
- onClick={() => setActiveTab(index)}
568
  >
569
  {label}
570
  </button>
@@ -572,6 +487,14 @@ function App() {
572
  </nav>
573
 
574
  <main className="tab-panel">
 
 
 
 
 
 
 
 
575
  {activeTab === 0 && (
576
  <section className="form-section">
577
  <h2>Dados Gerais</h2>
@@ -733,7 +656,8 @@ function App() {
733
 
734
  {activeTab === 2 && (
735
  <section className="form-section">
736
- <h2>Motivos Desvalorizantes Alegados</h2>
 
737
  {form.motivos_desvalorizantes.map((item, index) => (
738
  <div key={`motivo-${index}`} className="motivo-row">
739
  <input
@@ -884,6 +808,7 @@ function App() {
884
  value={form.unidade_responsavel}
885
  onChange={(event) => updateField("unidade_responsavel", event.target.value)}
886
  >
 
887
  {options.dropdowns.unidades_responsaveis.map((item) => (
888
  <option key={item} value={item}>
889
  {item}
@@ -909,6 +834,7 @@ function App() {
909
  value={form.tecnico_responsavel}
910
  onChange={(event) => updateField("tecnico_responsavel", event.target.value)}
911
  >
 
912
  {options.dropdowns.tecnicos_responsaveis.map((item) => (
913
  <option key={item} value={item}>
914
  {item}
@@ -927,171 +853,11 @@ function App() {
927
  )}
928
  </div>
929
 
930
- <div className="grid-2">
931
- <label>
932
- Método de Avaliação
933
- <select
934
- value={form.metodo_avaliacao}
935
- onChange={(event) => updateField("metodo_avaliacao", event.target.value)}
936
- >
937
- {options.dropdowns.metodos_avaliacao.map((item) => (
938
- <option key={item} value={item}>
939
- {item}
940
- </option>
941
- ))}
942
- </select>
943
- </label>
944
- {form.metodo_avaliacao === "Outros" && (
945
- <label>
946
- Especifique
947
- <input
948
- value={form.metodo_avaliacao_custom}
949
- onChange={(event) => updateField("metodo_avaliacao_custom", event.target.value)}
950
- />
951
- </label>
952
- )}
953
- </div>
954
-
955
- <label>
956
- Modelo de Avaliação (.dai)
957
- <select
958
- value={form.modelo_avaliacao}
959
- onChange={(event) => updateField("modelo_avaliacao", event.target.value)}
960
- >
961
- {options.models.length === 0 && <option value="">Nenhum modelo encontrado</option>}
962
- {options.models.map((item) => (
963
- <option key={item} value={item}>
964
- {item}
965
- </option>
966
- ))}
967
- </select>
968
- </label>
969
-
970
- <div className="columns-section">
971
- <div className="columns-heading">
972
- <div>
973
- <h3>Colunas do Anexo I - Banco de Dados</h3>
974
- <p className="muted-message">
975
- Selecione e ordene as colunas que serão exibidas no anexo do modelo.
976
- </p>
977
- </div>
978
- <span className="columns-counter">{selectedColumns.length} selecionadas</span>
979
- </div>
980
-
981
- <div className="inline-buttons">
982
- <button type="button" onClick={selectAllDatabaseColumns} disabled={loadingColumns}>
983
- Todas
984
- </button>
985
- <button type="button" onClick={clearDatabaseColumns} disabled={loadingColumns}>
986
- Nenhuma
987
- </button>
988
- </div>
989
-
990
- {loadingColumns ? (
991
- <p className="muted-message">Carregando colunas do modelo...</p>
992
- ) : (
993
- <div className="columns-list">
994
- {databaseColumns.map((column, index) => (
995
- <div className="column-row" key={column.name}>
996
- <label className="checkbox-line">
997
- <input
998
- type="checkbox"
999
- checked={column.selected}
1000
- onChange={() => toggleDatabaseColumn(column.name)}
1001
- />
1002
- {column.name}
1003
- </label>
1004
- <div className="column-order-actions">
1005
- <button
1006
- type="button"
1007
- onClick={() => moveDatabaseColumn(index, -1)}
1008
- disabled={index === 0}
1009
- aria-label={`Mover ${column.name} para cima`}
1010
- >
1011
- ↑
1012
- </button>
1013
- <button
1014
- type="button"
1015
- onClick={() => moveDatabaseColumn(index, 1)}
1016
- disabled={index === databaseColumns.length - 1}
1017
- aria-label={`Mover ${column.name} para baixo`}
1018
- >
1019
- ↓
1020
- </button>
1021
- </div>
1022
- </div>
1023
- ))}
1024
- </div>
1025
- )}
1026
- </div>
1027
  </section>
1028
  )}
1029
 
1030
  {activeTab === 5 && (
1031
- <section className="form-section">
1032
- <h2>Definir Intervalo de Anos</h2>
1033
- <div className="grid-3">
1034
- <label>
1035
- Ano Inicial
1036
- <input
1037
- type="number"
1038
- value={anoInicial}
1039
- onChange={(event) => setAnoInicial(event.target.value)}
1040
- />
1041
- </label>
1042
- <label>
1043
- Ano Final
1044
- <input
1045
- type="number"
1046
- value={anoFinal}
1047
- onChange={(event) => setAnoFinal(event.target.value)}
1048
- />
1049
- </label>
1050
- <div className="inline-buttons">
1051
- <button type="button" onClick={handleGenerateYears} disabled={loadingYears}>
1052
- {loadingYears ? "Gerando..." : "Gerar Campos"}
1053
- </button>
1054
- <button type="button" onClick={handleClearYears}>
1055
- Limpar
1056
- </button>
1057
- </div>
1058
- </div>
1059
-
1060
- <h2>Valores por Ano</h2>
1061
- {form.valores_mercado.length === 0 && (
1062
- <p className="muted-message">Nenhum ano gerado ainda. Defina o intervalo acima.</p>
1063
- )}
1064
- {form.valores_mercado.map((item, index) => (
1065
- <div key={`vm-${index}`} className="grid-2 compact-row">
1066
- <label>
1067
- Ano
1068
- <input value={item.ano} disabled readOnly />
1069
- </label>
1070
- <label>
1071
- Valor (R$)
1072
- <input
1073
- value={item.valor}
1074
- onChange={(event) => updateValorMercado(index, { valor: event.target.value })}
1075
- onBlur={(event) =>
1076
- updateValorMercado(index, { valor: applyCurrencyMask(event.target.value) })
1077
- }
1078
- placeholder="R$ 0,00"
1079
- />
1080
- </label>
1081
- </div>
1082
- ))}
1083
-
1084
- <label>
1085
- Datas de Referência
1086
- <input
1087
- value={form.datas_referencia}
1088
- onChange={(event) => updateField("datas_referencia", event.target.value)}
1089
- />
1090
- </label>
1091
- </section>
1092
- )}
1093
-
1094
- {activeTab === 6 && (
1095
  <section className="form-section">
1096
  <h2>Registro Fotográfico</h2>
1097
  <p className="muted-message">
@@ -1108,11 +874,11 @@ function App() {
1108
  </section>
1109
  )}
1110
 
1111
- {activeTab === 7 && (
1112
  <section className="form-section">
1113
  <h2>Gerar Laudo de Avaliação</h2>
1114
  <p className="muted-message">Confira as abas anteriores e execute a geração do documento.</p>
1115
- <button type="button" className="primary-btn" onClick={handleGenerateDocument} disabled={loadingGenerate}>
1116
  {loadingGenerate ? "Gerando Documento..." : "Gerar Documento"}
1117
  </button>
1118
 
 
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+
3
+ import ImportacaoAvaliacoes from "./ImportacaoAvaliacoes";
4
+ import RevisaoAvaliacao from "./RevisaoAvaliacao";
5
+ import { adoptedValues, applyImportDefaults, importDefaults, REVIEW_FIELDS } from "./importDefaults";
6
 
7
  const API_BASE = (
8
  import.meta.env.VITE_API_BASE_URL ||
 
10
  ).trim();
11
 
12
  const TAB_LABELS = [
13
+ "Início · Importar avaliação",
14
  "1. Solicitação",
15
  "2. Documentação",
16
  "3. Motivos Desvalorizantes",
17
  "4. Imóvel Objeto",
18
  "5. Avaliação",
19
+ "6. Registro Fotográfico",
20
+ "7. Gerar Documento",
 
21
  ];
22
 
 
 
 
 
 
 
 
 
 
 
 
23
  const emptyMotivo = {
24
  descricao: "",
25
  alegado: true,
 
36
  return `${base}${route}`;
37
  };
38
 
39
+
 
40
 
41
  const applyCurrencyMask = (value) => {
42
  if (!value) {
 
158
  function App() {
159
  const [options, setOptions] = useState(null);
160
  const [form, setForm] = useState(null);
161
+ const [activeTab, setActiveTab] = useState(-1);
162
+ const editedFields = useRef(new Set());
163
+ const previousDefaults = useRef({});
164
 
165
+ const [importacao, setImportacao] = useState(null);
166
+ const [importEpoch, setImportEpoch] = useState(0);
167
+ const [loadingImport, setLoadingImport] = useState(false);
168
 
169
  const [pdfFile, setPdfFile] = useState(null);
170
  const [registroFotoFile, setRegistroFotoFile] = useState(null);
 
172
  const [loadingOptions, setLoadingOptions] = useState(true);
173
  const [loadingPdf, setLoadingPdf] = useState(false);
174
  const [loadingGenerate, setLoadingGenerate] = useState(false);
175
+
 
 
176
 
177
  const [error, setError] = useState("");
178
  const [pdfWarnings, setPdfWarnings] = useState([]);
 
194
 
195
  setForm(buildEmptyForm(payload));
196
 
197
+
 
198
  } catch (err) {
199
  setError(String(err));
200
  } finally {
 
212
  return normalizeApiUrl(result.download_url);
213
  }, [result]);
214
 
 
 
 
 
 
 
215
  useEffect(() => {
216
+ const defaults = importDefaults(importacao);
217
+ const previous = previousDefaults.current;
218
+ setForm((current) => applyImportDefaults(current, defaults, previous, editedFields.current));
219
+ previousDefaults.current = defaults;
220
+ }, [importacao]);
221
+
222
+ const handleImportChange = (value) => {
223
+ setImportacao(value);
224
+ setResult(null);
225
+ setError("");
226
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
 
228
+ useEffect(() => { setResult(null); }, [form, registroFotoFile]);
 
 
229
 
230
+ const limits = options?.limits || {};
231
  const updateField = (field, value) => {
232
+ editedFields.current.add(field);
233
+ setResult(null);
234
  setForm((prev) => ({ ...prev, [field]: value }));
235
  };
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  const updateStringList = (field, index, value) => {
238
  setForm((prev) => {
239
  const list = [...prev[field]];
 
261
  };
262
 
263
  const updateMotivo = (index, patch) => {
264
+ editedFields.current.add('motivos_desvalorizantes');
265
+ setResult(null);
266
  setForm((prev) => {
267
  const next = [...prev.motivos_desvalorizantes];
268
  next[index] = { ...next[index], ...patch };
 
271
  };
272
 
273
  const addMotivo = () => {
274
+ editedFields.current.add('motivos_desvalorizantes');
275
  setForm((prev) => {
276
  if (prev.motivos_desvalorizantes.length >= limits.max_motivos_desvalorizantes) {
277
  return prev;
 
284
  };
285
 
286
  const removeMotivo = () => {
287
+ editedFields.current.add('motivos_desvalorizantes');
288
  setForm((prev) => {
289
  if (prev.motivos_desvalorizantes.length <= 1) {
290
  return prev;
 
296
  });
297
  };
298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  const handleExtractPdf = async () => {
300
  if (!pdfFile) {
301
  setError("Selecione um arquivo PDF antes de processar.");
 
356
  return;
357
  }
358
 
359
+ setResult(null);
360
+
361
  setForm((prev) => {
362
  const dropdowns = options.dropdowns || {};
363
  const demoBase = {
 
370
  metodo_avaliacao: dropdowns.metodos_avaliacao?.[0] || prev.metodo_avaliacao,
371
  modelo_avaliacao: options.models?.[0] || prev.modelo_avaliacao,
372
  };
373
+ const example = applyDummyToForm(demoBase, options.dummy);
374
+ if (importacao) {
375
+ for (const field of [...REVIEW_FIELDS, 'motivos_desvalorizantes']) example[field] = prev[field];
376
+ }
377
+ return example;
378
  });
379
  };
380
 
381
  const handleClearForm = () => {
382
+ editedFields.current.clear();
383
+ previousDefaults.current = {};
384
  setForm(buildEmptyForm(options));
385
+ setImportacao(null);
386
+ setPdfFile(null);
387
+ setRegistroFotoFile(null);
388
+ setImportEpoch((current) => current + 1);
389
  setError("");
390
  setPdfWarnings([]);
391
  setResult(null);
392
+ setActiveTab(-1);
393
  };
394
 
395
  const handleGenerateDocument = async () => {
396
+ if (loadingImport || !importacao) {
397
+ setError("Importe um arquivo .ame ou .evo antes de gerar o documento.");
398
+ setActiveTab(-1);
399
  return;
400
  }
401
+ if (importacao.preview.avaliacoes.some((item) => !/^(19|20|21)\d{2}$/.test(importacao.anos[item.id] || ''))) {
402
+ setError("Informe o ano de todas as avaliações na etapa inicial de importação.");
403
+ setActiveTab(-1);
404
  return;
405
  }
406
 
 
414
  documentacao_padrao: form.documentacao_padrao.filter((item) => item.trim()),
415
  documentacao_especifica: form.documentacao_especifica.filter((item) => item.trim()),
416
  motivos_desvalorizantes: form.motivos_desvalorizantes.filter((item) => item.descricao.trim()),
417
+ valores_mercado: [],
418
+ banco_dados_colunas: null,
419
+ importacao_avaliacoes: { referencia_anos: importacao.referenciaAnos, anos: importacao.anos, objeto_modelo: importacao.objeto,
420
+ valores_adotados: adoptedValues(importacao), complementos: importacao.complementos || {}, campos_editados: REVIEW_FIELDS.filter((field) => editedFields.current.has(field)) },
421
  };
422
 
423
  const formData = new FormData();
424
  formData.append("payload", JSON.stringify(payload));
425
+ formData.append("arquivo_avaliacoes", importacao.file);
426
  if (registroFotoFile) {
427
  formData.append("registro_fotografico", registroFotoFile);
428
  }
 
439
 
440
  const generated = await response.json();
441
  setResult(generated);
442
+ setActiveTab(6);
443
  } catch (err) {
444
  setError(String(err));
445
  } finally {
 
478
  <button
479
  key={label}
480
  type="button"
481
+ className={index - 1 === activeTab ? "tab-btn active" : "tab-btn"}
482
+ onClick={() => setActiveTab(index - 1)}
483
  >
484
  {label}
485
  </button>
 
487
  </nav>
488
 
489
  <main className="tab-panel">
490
+ {activeTab === -1 && (
491
+ <section className="form-section">
492
+ <h2>Comece pela avaliação</h2>
493
+ <p className="muted-message">Importe o arquivo da MESA para preencher os motivos desvalorizantes e os dados da avaliação. Os campos continuarão editáveis nas etapas seguintes; suas alterações manuais serão preservadas.</p>
494
+ <ImportacaoAvaliacoes key={importEpoch} value={importacao} onChange={handleImportChange} onLoading={setLoadingImport} apiUrl={normalizeApiUrl} />
495
+ <button type="button" className="primary-btn" disabled={!importacao || loadingImport} onClick={() => setActiveTab(0)}>Continuar para a solicitação</button>
496
+ </section>
497
+ )}
498
  {activeTab === 0 && (
499
  <section className="form-section">
500
  <h2>Dados Gerais</h2>
 
656
 
657
  {activeTab === 2 && (
658
  <section className="form-section">
659
+ <h2>Motivos Desvalorizantes</h2>
660
+ <p className="muted-message">As condições identificadas na avaliação vêm preenchidas por padrão. Revise a descrição e indique quais foram alegadas pelo contribuinte e confirmadas na análise.</p>
661
  {form.motivos_desvalorizantes.map((item, index) => (
662
  <div key={`motivo-${index}`} className="motivo-row">
663
  <input
 
808
  value={form.unidade_responsavel}
809
  onChange={(event) => updateField("unidade_responsavel", event.target.value)}
810
  >
811
+ <option value="">Selecione a unidade responsável</option>
812
  {options.dropdowns.unidades_responsaveis.map((item) => (
813
  <option key={item} value={item}>
814
  {item}
 
834
  value={form.tecnico_responsavel}
835
  onChange={(event) => updateField("tecnico_responsavel", event.target.value)}
836
  >
837
+ <option value="">Selecione o técnico responsável</option>
838
  {options.dropdowns.tecnicos_responsaveis.map((item) => (
839
  <option key={item} value={item}>
840
  {item}
 
853
  )}
854
  </div>
855
 
856
+ <RevisaoAvaliacao form={form} onFieldChange={updateField} value={importacao} onChange={handleImportChange} onImport={() => setActiveTab(-1)} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
857
  </section>
858
  )}
859
 
860
  {activeTab === 5 && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
861
  <section className="form-section">
862
  <h2>Registro Fotográfico</h2>
863
  <p className="muted-message">
 
874
  </section>
875
  )}
876
 
877
+ {activeTab === 6 && (
878
  <section className="form-section">
879
  <h2>Gerar Laudo de Avaliação</h2>
880
  <p className="muted-message">Confira as abas anteriores e execute a geração do documento.</p>
881
+ <button type="button" className="primary-btn" onClick={handleGenerateDocument} disabled={loadingGenerate || loadingImport || !importacao}>
882
  {loadingGenerate ? "Gerando Documento..." : "Gerar Documento"}
883
  </button>
884
 
frontend/src/EvolutivoReview.jsx ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fields = [
2
+ ['justificativa_vu', 'Escolha do valor unitário e do campo de arbítrio'],
3
+ ['justificativa_area', 'Diferença entre área do modelo e área de aplicação'],
4
+ ['justificativa_fc', 'Escolha do fator de comercialização'],
5
+ ['ajustes_nao_aplicados', 'Ajustes expressamente não aplicados e justificativa'],
6
+ ];
7
+ const gradeOptions = ['', 'Grau III', 'Grau II', 'Grau I', 'Sem enquadramento'];
8
+ export const documentaryValues = (value, row) => ({ ...row.complementos, ...value.complementos?.[row.id],
9
+ bdi: { ...row.complementos?.bdi, ...value.complementos?.[row.id]?.bdi },
10
+ requisitos_grau_iii: { ...row.complementos?.requisitos_grau_iii, ...value.complementos?.[row.id]?.requisitos_grau_iii } });
11
+
12
+ export default function EvolutivoReview({ value, row, onChange, changed }) {
13
+ if (value.preview.tipo !== 'evolutivo') return null;
14
+ const current = documentaryValues(value, row);
15
+ const review = row.revisao_documental || {};
16
+ const set = (key, text) => onChange({ ...value, complementos: { ...value.complementos, [row.id]: { ...current, [key]: text } } });
17
+ const reuse = (key) => {
18
+ const next = { ...value.complementos };
19
+ for (const target of value.preview.avaliacoes) next[target.id] = { ...documentaryValues(value, target), [key]: current[key] };
20
+ onChange({ ...value, complementos: next });
21
+ };
22
+ const reuseBdi = (construction) => {
23
+ const next = { ...value.complementos };
24
+ for (const target of value.preview.avaliacoes) {
25
+ const matches = (target.revisao_documental?.construcoes || []).filter((item) => item.grupo === construction.grupo);
26
+ if (matches.length !== 1) continue;
27
+ const saved = documentaryValues(value,target);
28
+ next[target.id] = { ...saved, bdi: { ...saved.bdi, [matches[0].id]: current.bdi?.[construction.id] || '' } };
29
+ }
30
+ onChange({ ...value, complementos: next });
31
+ };
32
+ return <details className="evolutivo-review">
33
+ <summary>Revisar fundamentos e complementos desta avaliação</summary>
34
+ <p>As definições técnicas de CUB, BDI, depreciação e redutores são incluídas automaticamente a partir da MESA. Aqui entram as decisões específicas do trabalho. Campos ausentes ficam identificados como pendentes no laudo.</p>
35
+ <p>Área do modelo: {review.area_modelo ?? 'não informada'} m² · Área de aplicação: {review.area_aplicacao ?? 'não informada'} m²</p>
36
+ <p>Enquadramentos na origem: terreno {review.fundamentacao?.terrain || 'pendente'}; construções {review.fundamentacao?.construction || 'pendente'}; global {review.fundamentacao?.global || 'pendente'}. As alterações serão consideradas na geração.</p>
37
+ {!!review.pendencias?.length && <p className="muted-message">Lacunas identificadas no arquivo: {review.pendencias.join('; ')}.</p>}
38
+ {(review.conferencias || []).map((warning, index) => <p role="status" className="muted-message" key={index}>{warning}</p>)}
39
+ {fields.map(([key, label]) => <div className="documentary-field" key={key}>
40
+ <label>{label}<textarea rows={3} value={current[key] || ''} onChange={(event) => set(key, event.target.value)} placeholder="Complemento específico do avaliador" /></label>
41
+ {value.preview.avaliacoes.length > 1 && <button type="button" disabled={!current[key]} onClick={() => reuse(key)}>Usar esta justificativa nos demais exercícios</button>}
42
+ </div>)}
43
+ <h5>BDI por construção</h5>
44
+ {(review.construcoes || []).map((construction) => <div key={construction.id}><label>{construction.nome} · BDI {construction.bdi ?? 'não informado'}% — justificativa
45
+ <textarea rows={3} value={current.bdi?.[construction.id] || ''} onChange={(event) => set('bdi', { ...current.bdi, [construction.id]: event.target.value })} />
46
+ </label>{value.preview.avaliacoes.length > 1 && <button type="button" disabled={!current.bdi?.[construction.id]} onClick={() => reuseBdi(construction)}>Usar para esta sequência nos demais exercícios</button>}</div>)}
47
+ {changed && <label>Critério do valor final adotado<textarea rows={3} value={current.criterio_adocao || ''} onChange={(event) => set('criterio_adocao', event.target.value)} placeholder="Explique a diferença em relação ao resultado calculado" /></label>}
48
+ <h5>Critérios de fundamentação do terreno</h5>
49
+ <p className="muted-message">A precisão e o critério de extrapolação não substituem a fundamentação completa. Os demais critérios automáticos usam as evidências estatísticas preservadas.</p>
50
+ {[['caracterizacao', 'Caracterização do avaliando'], ['identificacao', 'Identificação dos dados de mercado']].map(([prefix, label]) => <div className="grid-2" key={prefix}>
51
+ <label>{label}<select value={current[`${prefix}_grau`] || ''} onChange={(event) => set(`${prefix}_grau`, event.target.value)}>{gradeOptions.map((grade) => <option key={grade} value={grade}>{grade || 'Não informado'}</option>)}</select></label>
52
+ <label>Justificativa de {label.toLowerCase()}<textarea rows={2} value={current[`${prefix}_justificativa`] || ''} onChange={(event) => set(`${prefix}_justificativa`, event.target.value)} /></label>
53
+ </div>)}
54
+ <details><summary>Requisitos adicionais para Grau III do terreno</summary>
55
+ {(review.requisitos_modelo || []).map((requirement) => <label key={requirement.key}>{requirement.label}<select value={current.requisitos_grau_iii?.[requirement.key] === true ? 'sim' : current.requisitos_grau_iii?.[requirement.key] === false ? 'nao' : current.requisitos_grau_iii?.[requirement.key] || ''} onChange={(event) => set('requisitos_grau_iii', { ...current.requisitos_grau_iii, [requirement.key]: event.target.value })}>
56
+ <option value="">Não informado</option><option value="sim">Sim</option><option value="nao">Não</option>
57
+ </select></label>)}
58
+ </details>
59
+ <label>Critério de fundamentação do fator de comercialização<select value={current.fc_criterio || ''} onChange={(event) => set('fc_criterio', event.target.value)}>
60
+ <option value="">Não informado</option>{(review.criterios_fc || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
61
+ </select></label>
62
+ </details>;
63
+ }
frontend/src/ImportacaoAvaliacoes.jsx ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ const reais = (value) => value == null ? '—' : Number(value).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
4
+
5
+ export default function ImportacaoAvaliacoes({ value, onChange, onLoading, apiUrl }) {
6
+ const request = useRef(null);
7
+ const [error, setError] = useState('');
8
+ const [busy, setBusy] = useState(false);
9
+ useEffect(() => () => { request.current?.abort(); onLoading(false); }, [onLoading]);
10
+
11
+ async function upload(file) {
12
+ request.current?.abort();
13
+ const controller = new AbortController();
14
+ request.current = controller;
15
+ setError('');
16
+ onChange(null);
17
+ if (!file) { setBusy(false); onLoading(false); return; }
18
+ if (!/\.(ame|evo)$/i.test(file.name) || file.size > 50 * 1024 * 1024) {
19
+ setError('Envie um arquivo .ame ou .evo de até 50 MB.');
20
+ setBusy(false); onLoading(false); return;
21
+ }
22
+ setBusy(true); onLoading(true);
23
+ try {
24
+ const data = new FormData();
25
+ data.append('arquivo', file);
26
+ const response = await fetch(apiUrl('/api/avaliacoes/importar'), { method: 'POST', body: data, signal: controller.signal });
27
+ const preview = await response.json();
28
+ if (!response.ok) throw new Error(preview.detail || 'Não foi possível ler a avaliação.');
29
+ if (controller.signal.aborted) return;
30
+ onChange({ file, preview, referenciaAnos: 'ano_base', objeto: 'Objeto avaliado por modelo', valoresAdotados: {}, anos: Object.fromEntries(preview.avaliacoes.map((item) => [item.id, item.ano_sugerido || ''])) });
31
+ } catch (err) {
32
+ if (err.name !== 'AbortError') setError(err.message);
33
+ } finally {
34
+ if (!controller.signal.aborted) { setBusy(false); onLoading(false); }
35
+ }
36
+ }
37
+ const years = value ? Object.values(value.anos).filter(Boolean) : [];
38
+ const duplicate = new Set(years).size !== years.length;
39
+ return <div className="importacao-avaliacoes">
40
+ <h3>Importar avaliações do MESA</h3>
41
+ <p>Envie um arquivo .ame com avaliações por modelo ou .evo com composições de terreno e construção. Os valores salvos serão organizados por ano no laudo.</p>
42
+ <label>Arquivo de avaliações (.ame ou .evo)
43
+ <input type="file" accept=".ame,.evo" onClick={(event) => { event.currentTarget.value = ''; }} onChange={(event) => upload(event.target.files?.[0])} />
44
+ </label>
45
+ {busy && <p role="status">Lendo avaliações…</p>}
46
+ {error && <p className="error-message" role="alert">{error}</p>}
47
+ {value && <>
48
+ <p><strong>{value.preview.arquivo}</strong> · {value.preview.avaliacoes.length} avaliação(ões) · {value.preview.tipo === 'evolutivo' ? 'Método evolutivo' : 'Modelo estatístico'}</p>
49
+ <div className="grid-2">
50
+ <label>Os anos informados representam
51
+ <select value={value.referenciaAnos} onChange={(event) => onChange({ ...value, referenciaAnos: event.target.value })}>
52
+ <option value="ano_base">Ano do modelo / ano-base (exercício no ano seguinte)</option>
53
+ <option value="exercicio_iptu">Exercício do IPTU (ano-base anterior)</option>
54
+ </select>
55
+ </label>
56
+ {value.preview.tipo === 'modelo' && <label>Objeto avaliado pelo modelo
57
+ <input value={value.objeto} onChange={(event) => onChange({ ...value, objeto: event.target.value })} placeholder="Ex.: terreno, imóvel completo, unidade comercial" />
58
+ </label>}
59
+ </div>
60
+ <p className="muted-message">Confira os anos detectados. O cenário com todos os anos em zero usa o ano indicado no título da avaliação. Referências ausentes ou divergentes precisam ser preenchidas antes de gerar o documento.</p>
61
+ {duplicate && <p className="muted-message">Há mais de uma avaliação para o mesmo ano. Elas serão identificadas pelo título e apresentadas separadamente, sem somar seus valores.</p>}
62
+ {value.preview.avaliacoes.map((item) => {
63
+ const year = Number(value.anos[item.id]);
64
+ const valid = /^(19|20|21)\d{2}$/.test(value.anos[item.id]);
65
+ const base = year - (value.referenciaAnos === 'exercicio_iptu' ? 1 : 0);
66
+ return <section className="importacao-avaliacao" key={item.id}>
67
+ <div className="grid-2">
68
+ <div>
69
+ <h4>{valid ? `Exercício ${base + 1}` : item.titulo}{item.cenario_base ? ' · Cenário base' : ''}</h4>
70
+ <p>{valid && !/^(19|20|21)\d{2}$/.test(item.titulo.trim()) && !item.cenario_base ? `${item.titulo} · ` : ''}{item.modelo}</p>
71
+ </div>
72
+ <label>{value.referenciaAnos === 'ano_base' ? 'Ano-base' : 'Exercício IPTU'}
73
+ <input type="number" min="1900" max="2199" required value={value.anos[item.id]} onChange={(event) => onChange({ ...value, anos: { ...value.anos, [item.id]: event.target.value } })} />
74
+ <small>{valid ? `Ano-base ${base} / IPTU ${base + 1}` : 'Informe o ano desta avaliação'}</small>
75
+ </label>
76
+ </div>
77
+ <dl className="importacao-valores">
78
+ {value.preview.tipo === 'evolutivo' && <><div><dt>Terreno</dt><dd>{reais(item.valor_terreno)}</dd></div><div><dt>Construção</dt><dd>{reais(item.valor_construcao)}</dd></div></>}
79
+ <div><dt>Valor total adotado</dt><dd>{reais(item.valor_total)}</dd></div>
80
+ </dl>
81
+ {item.avisos.map((warning, index) => <p className="muted-message" key={index}>{warning}</p>)}
82
+ </section>;
83
+ })}
84
+ {value.preview.tipo === 'evolutivo' && <p className="muted-message">Terreno e construção são componentes anteriores aos redutores de imóvel e ao fator de comercialização. O total adotado é o resultado final salvo no MESA.</p>}
85
+ {value.preview.tipo === 'evolutivo' && <p className="muted-message">O corpo do laudo apresentará terreno, construções, redutores, CUB, BDI, depreciação, composição e fundamentação. Revise as justificativas específicas em “5. Avaliação”.</p>}
86
+ <p className="muted-message">Os anexos estatísticos completos dependem da correspondência da versão do modelo local com a avaliação salva. O cálculo por ano é gerado com os resultados do arquivo.</p>
87
+ </>}
88
+ </div>;
89
+ }
frontend/src/RevisaoAvaliacao.jsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { moneyInput } from './importDefaults';
2
+ import EvolutivoReview from './EvolutivoReview';
3
+
4
+ export default function RevisaoAvaliacao({ form, onFieldChange, value, onChange, onImport }) {
5
+ if (!value) return <div className="importacao-avaliacoes"><p>Importe a avaliação para preencher os dados desta seção.</p><button type="button" onClick={onImport}>Importar avaliação</button></div>;
6
+ return <div className="importacao-avaliacoes">
7
+ <h3>Dados da avaliação</h3>
8
+ <p className="muted-message">Os campos foram preenchidos a partir de {value.preview.arquivo}. Você pode editá-los para o laudo.</p>
9
+ <label>Método de avaliação<input value={form.metodo_avaliacao} onChange={(event) => onFieldChange('metodo_avaliacao', event.target.value)} /></label>
10
+ <label>Modelo(s) utilizado(s)<input value={form.modelo_avaliacao} onChange={(event) => onFieldChange('modelo_avaliacao', event.target.value)} /></label>
11
+ <label>Referências<textarea aria-label="Referências" rows={2} value={form.datas_referencia} onChange={(event) => onFieldChange('datas_referencia', event.target.value)} /></label>
12
+ <h3>Valores de mercado adotados no laudo</h3>
13
+ <p className="muted-message">O padrão é o valor salvo na MESA. Alterações ficam registradas junto com o valor de origem na memória do laudo.</p>
14
+ {value.preview.avaliacoes.map((row) => {
15
+ const selected = String(value.anos[row.id] || '');
16
+ const valid = /^(19|20|21)\d{2}$/.test(selected);
17
+ const base = Number(selected) - (value.referenciaAnos === 'exercicio_iptu' ? 1 : 0);
18
+ const changed = Object.hasOwn(value.valoresAdotados || {}, row.id);
19
+ return <section className="importacao-avaliacao" key={row.id}>
20
+ <h4>{valid ? `Exercício ${base + 1} · Ano-base ${base}` : 'Ano de referência pendente'} · {row.titulo}</h4>
21
+ <p className="muted-message">Valor registrado na MESA: R$ {moneyInput(row.valor_total)}</p>
22
+ <label>Valor adotado (R$)<input inputMode="decimal" value={changed ? value.valoresAdotados[row.id] : moneyInput(row.valor_total)} onChange={(event) => onChange({ ...value, valoresAdotados: { ...value.valoresAdotados, [row.id]: event.target.value } })} /></label>
23
+ {changed && <button type="button" onClick={() => {
24
+ const next = { ...value.valoresAdotados };
25
+ delete next[row.id];
26
+ onChange({ ...value, valoresAdotados: next });
27
+ }}>Usar valor da MESA</button>}
28
+ <EvolutivoReview value={value} row={row} onChange={onChange} changed={changed} />
29
+ </section>;
30
+ })}
31
+ <button type="button" onClick={onImport}>Revisar arquivo e anos da importação</button>
32
+ </div>;
33
+ }
frontend/src/importDefaults.js ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const EMPTY_REASON = { descricao: '', alegado: true, confirmado: false };
2
+ export const REVIEW_FIELDS = ['metodo_avaliacao', 'modelo_avaliacao', 'datas_referencia'];
3
+
4
+ export function importDefaults(value) {
5
+ if (!value) return {};
6
+ const suggestions = value.preview.sugestoes || {};
7
+ const rows = value.preview.avaliacoes;
8
+ const baseYears = rows.map((row) => {
9
+ const year = String(value.anos[row.id] || '');
10
+ return /^(19|20|21)\d{2}$/.test(year)
11
+ ? Number(year) - (value.referenciaAnos === 'exercicio_iptu' ? 1 : 0) : null;
12
+ }).filter((year) => year !== null).sort((a, b) => a - b);
13
+ return {
14
+ metodo_avaliacao: suggestions.metodo_avaliacao || ('Método Comparativo Dados de Mercado - ABNT NBR 14.653-2' + (value.preview.tipo === 'evolutivo' ? ' e Método de Quantificação de Custo' : '')),
15
+ modelo_avaliacao: suggestions.modelo_avaliacao ?? [...new Set(rows.map((row) => row.modelo).filter(Boolean))].join(', '),
16
+ datas_referencia: [...new Set(baseYears)].map((year) => `Dezembro/${year}`).join(', '),
17
+ motivos_desvalorizantes: suggestions.motivos_desvalorizantes?.length
18
+ ? suggestions.motivos_desvalorizantes.map((item) => ({ ...item })) : [{ ...EMPTY_REASON }],
19
+ };
20
+ }
21
+
22
+ export function applyImportDefaults(form, next, previous, edited) {
23
+ if (!form) return form;
24
+ const patch = {};
25
+ for (const field of new Set([...Object.keys(previous), ...Object.keys(next)])) {
26
+ if (edited.has(field)) continue;
27
+ const value = next[field] ?? (field === 'motivos_desvalorizantes' ? [{ ...EMPTY_REASON }] : '');
28
+ if (JSON.stringify(form[field]) !== JSON.stringify(value)) patch[field] = value;
29
+ }
30
+ return Object.keys(patch).length ? { ...form, ...patch } : form;
31
+ }
32
+
33
+ export function decimalValue(input) {
34
+ const value = String(input ?? '').trim().replace(/^R\$\s*/, '');
35
+ if (value.includes(',')) {
36
+ if (!/^(?:\d{1,3}(?:\.\d{3})+|\d+),\d{1,2}$/.test(value)) return null;
37
+ return value.replace(/\./g, '').replace(',', '.');
38
+ }
39
+ return /^\d+(?:\.\d{1,2})?$/.test(value) ? value : null;
40
+ }
41
+
42
+ export function moneyInput(value) {
43
+ const [whole, cents = '00'] = String(value).split('.');
44
+ return `${whole.replace(/\B(?=(\d{3})+(?!\d))/g, '.')},${cents.padEnd(2, '0')}`;
45
+ }
46
+
47
+ export function adoptedValues(value) {
48
+ const output = {};
49
+ for (const [id, input] of Object.entries(value.valoresAdotados || {})) {
50
+ const parsed = decimalValue(input);
51
+ if (parsed === null) throw new Error('Informe valores de mercado válidos na seção 5. Avaliação.');
52
+ output[id] = parsed;
53
+ }
54
+ return output;
55
+ }
frontend/src/styles.css CHANGED
@@ -485,3 +485,15 @@ button:disabled {
485
  flex-direction: column;
486
  }
487
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  flex-direction: column;
486
  }
487
  }
488
+
489
+ .importacao-avaliacoes { display: grid; gap: 16px; }
490
+ .importacao-avaliacao { padding: 16px; border: 1px solid #d4dce3; border-radius: 10px; background: #f8fafb; }
491
+ .importacao-avaliacao h4 { margin: 0 0 6px; }
492
+ .importacao-valores { display: flex; flex-wrap: wrap; gap: 20px; margin: 16px 0 0; }
493
+ .importacao-valores dt { color: #536874; font-size: 13px; }
494
+ .importacao-valores dd { margin: 4px 0 0; font-weight: 700; }
495
+ .evolutivo-review { margin-top: 1rem; padding-top: .75rem; border-top: 1px solid #d7dde4; }
496
+ .evolutivo-review summary { cursor: pointer; font-weight: 600; margin-bottom: .75rem; }
497
+ .evolutivo-review h5 { font-size: 1rem; margin: 1.25rem 0 .5rem; }
498
+ .evolutivo-review label { margin: .7rem 0; }
499
+ .documentary-field { margin: 1rem 0; }
frontend/tests/importDefaults.test.mjs ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { adoptedValues, applyImportDefaults, importDefaults, decimalValue, EMPTY_REASON } from '../src/importDefaults.js';
4
+
5
+ const imported = (patch = {}) => ({
6
+ preview: { tipo: 'evolutivo', avaliacoes: [{ id: 'evo-1', modelo: 'MODELO_A' }, { id: 'evo-2', modelo: 'MODELO_A' }],
7
+ sugestoes: { motivos_desvalorizantes: [{ descricao: 'Sem esgoto', alegado: false, confirmado: true }] } },
8
+ referenciaAnos: 'ano_base', anos: { 'evo-1': '2025', 'evo-2': '2023' }, ...patch,
9
+ });
10
+
11
+ test('importação preenche motivos, método, modelo e referências ordenadas', () => {
12
+ const defaults = importDefaults(imported());
13
+ assert.equal(defaults.metodo_avaliacao, 'Método Comparativo Dados de Mercado - ABNT NBR 14.653-2 e Método de Quantificação de Custo');
14
+ assert.equal(defaults.modelo_avaliacao, 'MODELO_A');
15
+ assert.equal(defaults.datas_referencia, 'Dezembro/2023, Dezembro/2025');
16
+ assert.deepEqual(defaults.motivos_desvalorizantes, [{ descricao: 'Sem esgoto', alegado: false, confirmado: true }]);
17
+ });
18
+
19
+ test('corrigir exercício ou ano-base atualiza somente os campos ainda automáticos', () => {
20
+ const previous = importDefaults(imported());
21
+ const next = importDefaults(imported({ referenciaAnos: 'exercicio_iptu' }));
22
+ const form = { ...previous, modelo_avaliacao: 'Texto revisado', motivos_desvalorizantes: [] };
23
+ const result = applyImportDefaults(form, next, previous, new Set(['modelo_avaliacao', 'motivos_desvalorizantes']));
24
+ assert.equal(result.datas_referencia, 'Dezembro/2022, Dezembro/2024');
25
+ assert.equal(result.modelo_avaliacao, 'Texto revisado');
26
+ assert.deepEqual(result.motivos_desvalorizantes, []);
27
+ });
28
+
29
+ test('trocar arquivo substitui sugestões e preserva inclusive edições vazias', () => {
30
+ const previous = importDefaults(imported());
31
+ const next = { ...previous, modelo_avaliacao: 'MODELO_B', datas_referencia: 'Dezembro/2020' };
32
+ const result = applyImportDefaults({ ...previous, datas_referencia: '' }, next, previous, new Set(['datas_referencia']));
33
+ assert.equal(result.modelo_avaliacao, 'MODELO_B');
34
+ assert.equal(result.datas_referencia, '');
35
+ });
36
+
37
+ test('remover arquivo limpa sugestões antigas e mantém campos manuais', () => {
38
+ const previous = importDefaults(imported());
39
+ const result = applyImportDefaults({ ...previous, numero_laudo: 'LA_01', modelo_avaliacao: 'Manual' }, {}, previous, new Set(['modelo_avaliacao']));
40
+ assert.equal(result.modelo_avaliacao, 'Manual');
41
+ assert.equal(result.metodo_avaliacao, '');
42
+ assert.equal(result.numero_laudo, 'LA_01');
43
+ assert.deepEqual(result.motivos_desvalorizantes, [EMPTY_REASON]);
44
+ });
45
+
46
+ test('referências pendentes não são adivinhadas', () => {
47
+ assert.equal(importDefaults(imported({ anos: {} })).datas_referencia, '');
48
+ });
49
+
50
+ test('valores adotados aceitam reais e zero sem perder a parte decimal', () => {
51
+ assert.deepEqual(adoptedValues({ valoresAdotados: { a: 'R$ 1.234.567,89', b: '0,00', c: '100.12' } }),
52
+ { a: '1234567.89', b: '0.00', c: '100.12' });
53
+ for (const input of ['', '-1', 'NaN', '1..2,00', '1,234', '1e3']) assert.equal(decimalValue(input), null);
54
+ assert.throws(() => adoptedValues({ valoresAdotados: { a: '' } }), /valores de mercado válidos/);
55
+ });
models/anos.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Identificação das variáveis temporais e da referência registrada no modelo."""
2
+ import re
3
+ from typing import Optional
4
+
5
+ import pandas as pd
6
+
7
+
8
+ def periodo_variavel_ano(nome: str) -> Optional[tuple[tuple[int, ...], bool]]:
9
+ """Retorna os anos da dicotômica e se ela significa 'a partir de'."""
10
+ nome = str(nome)
11
+ ano = r"((?:19|20)\d{2}|2100)"
12
+ individual = re.fullmatch(rf"(?:a|ANO_?)?{ano}", nome)
13
+ if individual:
14
+ return (int(individual[1]),), False
15
+ a_partir = re.fullmatch(rf"AP{ano}", nome)
16
+ if a_partir:
17
+ return (int(a_partir[1]),), True
18
+ grupo = re.fullmatch(r"ANOS_((?:(?:19|20)\d{2}|2100)(?:_(?:(?:19|20)\d{2}|2100))+)", nome)
19
+ if grupo:
20
+ return tuple(int(valor) for valor in grupo[1].split("_")), False
21
+ abreviado = re.fullmatch(rf"{ano}/(\d{{2}})", nome)
22
+ if abreviado:
23
+ primeiro = int(abreviado[1])
24
+ return (primeiro, primeiro // 100 * 100 + int(abreviado[2])), False
25
+ return None
26
+
27
+
28
+ def ano_base_explicito(referencia_temporal: dict, colunas_ano: list[str]) -> Optional[int]:
29
+ """Read the declared reference only when it belongs to these annual variables."""
30
+ if not isinstance(referencia_temporal, dict) or referencia_temporal.get('tipo') != 'ano':
31
+ return None
32
+ bases = referencia_temporal.get('anos_base')
33
+ variaveis = referencia_temporal.get('variaveis_ano')
34
+ if (not isinstance(bases, list) or len(bases) != 1
35
+ or not isinstance(variaveis, list) or not colunas_ano
36
+ or set(map(str, variaveis)) != set(map(str, colunas_ano))):
37
+ return None
38
+ if not re.fullmatch(r'(?:19|20)\d{2}|2100', str(bases[0])):
39
+ return None
40
+ base = int(bases[0])
41
+ for nome in colunas_ano:
42
+ periodo = periodo_variavel_ano(str(nome))
43
+ if periodo is None:
44
+ return None
45
+ anos, a_partir = periodo
46
+ if (a_partir and base >= anos[0]) or (not a_partir and base in anos):
47
+ return None
48
+ return base
49
+
50
+
51
+ def detectar_ano_base(df: pd.DataFrame, colunas_ano: list[str], referencia_temporal=None) -> Optional[int]:
52
+ """Confere o ano original dos registros em que todas as dicotômicas são zero.
53
+
54
+ Uma referência ausente, ambígua ou incompatível com as dicotômicas não é
55
+ substituída por um ano presumido.
56
+ """
57
+ # New DAI files declare the reference independently of a source ANO column.
58
+ # Keep inference from original data for older files only.
59
+ if referencia_temporal is not None:
60
+ return ano_base_explicito(referencia_temporal, colunas_ano)
61
+ if not isinstance(df, pd.DataFrame) or df.empty or not colunas_ano:
62
+ return None
63
+ periodos = {coluna: periodo_variavel_ano(coluna) for coluna in colunas_ano}
64
+ if any(periodo is None for periodo in periodos.values()):
65
+ return None
66
+ # Alguns arquivos antigos usam inteiros como nomes das colunas de ano.
67
+ nomes = {str(coluna): coluna for coluna in df.columns}
68
+ if any(coluna not in nomes for coluna in colunas_ano):
69
+ return None
70
+ anuais = df[[nomes[coluna] for coluna in colunas_ano]].apply(pd.to_numeric, errors="coerce")
71
+ if anuais.isna().any().any() or not anuais.isin([0, 1]).all().all():
72
+ return None
73
+ if anuais.sum(axis=1).gt(1).any():
74
+ return None
75
+ registros_base = anuais.eq(0).all(axis=1)
76
+ if not registros_base.any():
77
+ return None
78
+
79
+ candidatos = []
80
+ for coluna in df.columns:
81
+ if re.sub(r"[\s_-]", "", str(coluna)).casefold() not in ("ano", "anobase"):
82
+ continue
83
+ anos = pd.to_numeric(df[coluna], errors="coerce")
84
+ if anos.isna().any() or not anos.between(1900, 2100).all() or not anos.mod(1).eq(0).all():
85
+ continue
86
+ consistente = True
87
+ for nome, (periodo, a_partir) in periodos.items():
88
+ esperado = anos.ge(periodo[0]) if a_partir else anos.isin(periodo)
89
+ if not anuais[nomes[nome]].eq(esperado.astype(int)).all():
90
+ consistente = False
91
+ break
92
+ referencias = anos[registros_base].unique()
93
+ if consistente and len(referencias) == 1:
94
+ candidatos.append(int(referencias[0]))
95
+ return candidatos[0] if candidatos and len(set(candidatos)) == 1 else None
models/construction_identity.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Annual MQC copies get new UUIDs; group by property and sequence instead."""
2
+ import json
3
+ import re
4
+
5
+
6
+ def construction_identity(source, construction, index):
7
+ name = str((source.get('construcao') or {}).get('fonteNome') or '')
8
+ registration = re.search(r'inscri[çc][aã]o\s*[:\-]?\s*([\d.\-/]+)',name,re.I)
9
+ unit = source.get('imovelId') or source.get('unidadeId') or source.get('identificacaoImovel')
10
+ unit = unit or (registration[1] if registration else '')
11
+ location = (source.get('terreno') or {}).get('localizacao') or {}
12
+ return json.dumps([unit,location.get('lat'),location.get('lon'),construction.get('seq') or index+1,
13
+ construction.get('nome') or '',construction.get('anoConstrucao') or ''],ensure_ascii=False)
models/hf_repository.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Read the MESA model dataset with revision-aware caching; never use bundled DAI files."""
2
+ import os
3
+ from pathlib import Path, PurePosixPath
4
+ from threading import RLock
5
+ from time import monotonic
6
+
7
+ from .model_loader import load_model
8
+
9
+
10
+ class ModelRepositoryError(RuntimeError):
11
+ pass
12
+
13
+
14
+ def use_hf_repository():
15
+ if any(os.getenv(key, '').strip() for key in ('SPACE_ID', 'SPACE_AUTHOR_NAME', 'HF_SPACE_ID')):
16
+ return True
17
+ return os.getenv('MODELOS_REPOSITORIO_PROVIDER', 'local').strip().lower() in {
18
+ 'hf', 'hf_dataset', 'dataset', 'huggingface',
19
+ }
20
+
21
+
22
+ class HfModelRegistry:
23
+ def __init__(self):
24
+ self.repo_id = os.getenv('MODELOS_REPOSITORIO_HF_REPO_ID', 'gui-sparim/repositorio_mesa').strip()
25
+ self.revision = os.getenv('MODELOS_REPOSITORIO_HF_REVISION', 'main').strip()
26
+ self.subdir = os.getenv('MODELOS_REPOSITORIO_HF_SUBDIR', 'modelos_dai').strip().strip('/')
27
+ if '..' in PurePosixPath(self.subdir).parts or '\\' in self.subdir:
28
+ raise ModelRepositoryError('Pasta do repositório de modelos inválida.')
29
+ self.ttl = max(0, float(os.getenv('MODELOS_REPOSITORIO_HF_CACHE_TTL_SECONDS', '300')))
30
+ self._revision_sha = None
31
+ self._files = {}
32
+ self._checked_at = 0
33
+ self._cache = {}
34
+ self._lock = RLock()
35
+
36
+ def _token(self):
37
+ return next((os.getenv(key) for key in ('HF_TOKEN', 'HUGGINGFACE_HUB_TOKEN', 'HUGGINGFACE_TOKEN')
38
+ if os.getenv(key)), None)
39
+
40
+ def _refresh(self):
41
+ if self._revision_sha and monotonic() - self._checked_at < self.ttl:
42
+ return
43
+ try:
44
+ from huggingface_hub import HfApi
45
+ info = HfApi().dataset_info(self.repo_id, revision=self.revision, token=self._token())
46
+ if not info.sha:
47
+ raise ValueError('Revisão ausente')
48
+ prefix = self.subdir + '/' if self.subdir else ''
49
+ files = {}
50
+ for entry in info.siblings or []:
51
+ filename = entry.rfilename
52
+ if not filename.startswith(prefix):
53
+ continue
54
+ relative = filename[len(prefix):]
55
+ if '/' not in relative and '\\' not in relative and relative.endswith('.dai'):
56
+ files[Path(relative).stem] = filename
57
+ except Exception as exc:
58
+ raise ModelRepositoryError(
59
+ 'Não foi possível consultar o repositório de modelos da MESA. '
60
+ 'Confira a conexão e o secret HF_TOKEN com permissão de leitura do dataset.'
61
+ ) from exc
62
+ if info.sha != self._revision_sha:
63
+ self._cache.clear()
64
+ self._revision_sha = info.sha
65
+ self._files = files
66
+ self._checked_at = monotonic()
67
+
68
+ def discover_models(self):
69
+ with self._lock:
70
+ self._refresh()
71
+ return sorted(self._files)
72
+
73
+ def list_models(self):
74
+ return self.discover_models()
75
+
76
+ def get_model(self, name):
77
+ with self._lock:
78
+ self._refresh()
79
+ if name not in self._files:
80
+ return None
81
+ if name in self._cache:
82
+ return self._cache[name]
83
+ try:
84
+ from huggingface_hub import hf_hub_download
85
+ path = hf_hub_download(repo_id=self.repo_id, repo_type='dataset',
86
+ filename=self._files[name], revision=self._revision_sha,
87
+ token=self._token())
88
+ except Exception as exc:
89
+ raise ModelRepositoryError('Não foi possível baixar o modelo solicitado do repositório da MESA.') from exc
90
+ model = load_model(Path(path))
91
+ if model is None:
92
+ raise ModelRepositoryError('O modelo da MESA não pôde ser carregado nesta versão do gerador.')
93
+ self._cache[name] = model
94
+ return model
95
+
96
+ def clear_cache(self):
97
+ with self._lock:
98
+ self._cache.clear()
99
+ self._revision_sha = None
100
+ self._files = {}
101
+ self._checked_at = 0
models/imported_context.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Use the imported model snapshot; local names alone do not establish identity."""
2
+ import hashlib
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+ from . import get_model, list_available_models
10
+ from .model_data import ModelData
11
+ from .anos import detectar_ano_base, periodo_variavel_ano
12
+
13
+
14
+ def context_key(row):
15
+ terrain = row.get('evolutivo', {}).get('terreno', {})
16
+ context = terrain.get('contextoModelo') or {}
17
+ return row.get('modelo', ''), hashlib.sha256(json.dumps(context, sort_keys=True, ensure_ascii=False).encode()).hexdigest()[:12]
18
+
19
+
20
+ def matching_local(row):
21
+ name = row.get('modelo')
22
+ context = row.get('evolutivo', {}).get('terreno', {}).get('contextoModelo') or {}
23
+ if name not in list_available_models():
24
+ return None, 'Modelo indisponível no catálogo.'
25
+ local = get_model(name)
26
+ if not context:
27
+ return None, 'O arquivo não preserva o contexto necessário para confirmar a versão do modelo do catálogo.'
28
+ prediction = context.get('prediction') or {}
29
+ names, params = prediction.get('names') or [], prediction.get('params') or []
30
+ sm = local.modelos_sm if local else None
31
+ try:
32
+ local_names = list(map(str, sm.model.exog_names))
33
+ same = names == local_names and len(params) == len(sm.params) and np.allclose(params, sm.params, rtol=1e-12, atol=1e-12)
34
+ # Equal parameters are necessary, but not sufficient: verify fitted data too.
35
+ x = context.get('transformacoes', {}).get('X')
36
+ y = context.get('transformacoes', {}).get('y')
37
+ portable_x = pd.DataFrame(x).reindex(columns=names, fill_value=1).to_numpy(dtype=float)
38
+ portable_y = np.asarray(y, dtype=float).reshape(-1)
39
+ same = same and portable_x.shape == sm.model.exog.shape and portable_y.shape == sm.model.endog.shape
40
+ same = same and np.allclose(portable_x, sm.model.exog, rtol=1e-12, atol=1e-12) and np.allclose(portable_y, sm.model.endog, rtol=1e-12, atol=1e-12)
41
+ covariance = np.asarray(prediction.get('covariance'),dtype=float)
42
+ same = same and covariance.shape == np.asarray(sm.cov_params()).shape and np.allclose(covariance,sm.cov_params(),rtol=1e-12,atol=1e-12)
43
+ same = same and context.get('transformacoes',{}).get('info') == local.formatted_top_transformation_info
44
+ if same:
45
+ return local, ''
46
+ except (AttributeError, TypeError, ValueError, KeyError):
47
+ pass
48
+ return None, 'A correspondência da versão do catálogo não foi confirmada com os coeficientes e a amostra salvos; os anexos desse modelo não serão usados.'
49
+
50
+
51
+ def snapshot_model(row):
52
+ context = row.get('evolutivo', {}).get('terreno', {}).get('contextoModelo') or {}
53
+ prediction = context.get('prediction') or {}
54
+ names, params = prediction.get('names') or [], prediction.get('params') or []
55
+ if not names or len(names) != len(params):
56
+ return None
57
+ raw = context.get('dados', {}).get('df') or {}
58
+ try:
59
+ df = pd.DataFrame(**raw) if isinstance(raw, dict) and 'columns' in raw else pd.DataFrame(raw)
60
+ except (ValueError, TypeError):
61
+ df = pd.DataFrame()
62
+ transforms = context.get('transformacoes') or {}
63
+ info = transforms.get('info') or []
64
+ mapping = dict(item.split(': ', 1) for item in info if isinstance(item, str) and ': ' in item)
65
+ # Transcribe the fitted expression; do not fit or evaluate a new model.
66
+ terms = []
67
+ for name, param in zip(names, params):
68
+ coefficient = format(float(param), '.12g')
69
+ if name == 'const':
70
+ terms.append(coefficient)
71
+ else:
72
+ transformed = mapping.get(name, '(x)').replace('x', f'[{name}]')
73
+ terms.append(f'({coefficient} × {transformed})')
74
+ dependent = next((item.split(': ', 1) for item in info if isinstance(item, str) and ': ' in item and item.split(': ', 1)[0] not in names), ['Y', 'y'])
75
+ lhs = dependent[1].replace('y', dependent[0])
76
+ evidence = context.get('normativeEvidence') or {}
77
+ annual_names = [str(name) for name in names if periodo_variavel_ano(str(name))]
78
+ reference = context.get('referencia_temporal')
79
+ base_year = detectar_ano_base(df, annual_names, reference)
80
+ if base_year is None and reference is None:
81
+ # Legacy EVO contexts omitted the declaration. A name alone is not
82
+ # enough to borrow it: matching_local verifies the fitted model/data.
83
+ local, _ = matching_local(row)
84
+ if local is not None:
85
+ base_year = local.ano_base
86
+ return ModelData(
87
+ nome=row.get('modelo') or context.get('name') or 'Modelo importado', path=Path('contexto_importado'),
88
+ xy_preview=df, tabelas_coef=pd.DataFrame({'coef': params}, index=names),
89
+ modelos_resumos={'n': evidence.get('n', len(df)), 'k': evidence.get('k', len(names)-1),
90
+ 'r2': evidence.get('r2'), 'equacao': f'{lhs} = ' + ' + '.join(terms)},
91
+ formatted_top_transformation_info=info,
92
+ variaveis_dicotomicas=transforms.get('dicotomicas') or [],
93
+ periodo_dados_mercado=context.get('periodo_dados_mercado') or {},
94
+ ano_base=base_year,
95
+ )
models/model_data.py CHANGED
@@ -35,6 +35,7 @@ class ModelData:
35
  variaveis_dicotomicas: List[str] = field(default_factory=list)
36
  periodo_dados_mercado: Dict[str, Any] = field(default_factory=dict)
37
  graf_model: str = ""
 
38
 
39
  @property
40
  def r2(self) -> Optional[float]:
 
35
  variaveis_dicotomicas: List[str] = field(default_factory=list)
36
  periodo_dados_mercado: Dict[str, Any] = field(default_factory=dict)
37
  graf_model: str = ""
38
+ ano_base: Optional[int] = None
39
 
40
  @property
41
  def r2(self) -> Optional[float]:
models/model_loader.py CHANGED
@@ -11,6 +11,7 @@ import joblib
11
  import pandas as pd
12
 
13
  from .model_data import ModelData
 
14
 
15
 
16
  def _get_first(container: dict[str, Any], *keys: str, default: Any = None) -> Any:
@@ -208,6 +209,15 @@ def load_model(dai_path: Path) -> Optional[ModelData]:
208
  modelo_dict, "sm", "statsmodels", "statsmodels_result", default=None
209
  )
210
 
 
 
 
 
 
 
 
 
 
211
  model = ModelData(
212
  nome=dai_path.stem,
213
  path=dai_path,
@@ -242,6 +252,7 @@ def load_model(dai_path: Path) -> Optional[ModelData]:
242
  )
243
  ),
244
  graf_model='',
 
245
  )
246
  except (KeyError, TypeError) as e:
247
  print(f"Erro ao processar estrutura de {dai_path}: {e}")
 
11
  import pandas as pd
12
 
13
  from .model_data import ModelData
14
+ from .anos import detectar_ano_base, periodo_variavel_ano
15
 
16
 
17
  def _get_first(container: dict[str, Any], *keys: str, default: Any = None) -> Any:
 
209
  modelo_dict, "sm", "statsmodels", "statsmodels_result", default=None
210
  )
211
 
212
+ # O preview da modelagem pode omitir a coluna original ANO. Identifique
213
+ # a referência nos dados de origem antes de descartar essa informação.
214
+ dados_ano = _as_dataframe(_get_first(
215
+ dados, "df", "xy_preview", "df_preview", "df_modelagem", "preview",
216
+ default=xy_preview,
217
+ ))
218
+ colunas_ano = [str(coluna) for coluna in tabelas_coef.index
219
+ if periodo_variavel_ano(str(coluna)) is not None]
220
+
221
  model = ModelData(
222
  nome=dai_path.stem,
223
  path=dai_path,
 
252
  )
253
  ),
254
  graf_model='',
255
+ ano_base=detectar_ano_base(dados_ano, colunas_ano, data_dict.get('referencia_temporal')),
256
  )
257
  except (KeyError, TypeError) as e:
258
  print(f"Erro ao processar estrutura de {dai_path}: {e}")
models/registry.py CHANGED
@@ -1,12 +1,14 @@
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:
@@ -74,10 +76,10 @@ class ModelRegistry:
74
 
75
 
76
  # Instância global do registro
77
- _registry: Optional[ModelRegistry] = None
78
 
79
 
80
- def get_registry(models_dir: Optional[Path] = None) -> ModelRegistry:
81
  """
82
  Obtém a instância global do registro.
83
 
@@ -85,15 +87,17 @@ def get_registry(models_dir: Optional[Path] = None) -> ModelRegistry:
85
  models_dir: Diretório dos modelos (usa padrão se não fornecido)
86
 
87
  Returns:
88
- Instância do ModelRegistry
89
  """
90
  global _registry
91
 
92
  if _registry is None:
93
- if models_dir is None:
 
 
94
  # Caminho padrão relativo ao projeto
95
- models_dir = Path(__file__).parent / "files"
96
- _registry = ModelRegistry(models_dir)
97
 
98
  return _registry
99
 
 
1
  """
2
  Registro de modelos de avaliação.
3
+ Usa o dataset da MESA em Spaces e os arquivos locais no desenvolvimento.
4
  """
5
+ import os
6
  from pathlib import Path
7
  from typing import List, Optional, Dict
8
 
9
  from .model_data import ModelData
10
  from .model_loader import load_model
11
+ from .hf_repository import HfModelRegistry, use_hf_repository
12
 
13
 
14
  class ModelRegistry:
 
76
 
77
 
78
  # Instância global do registro
79
+ _registry: Optional[ModelRegistry | HfModelRegistry] = None
80
 
81
 
82
+ def get_registry(models_dir: Optional[Path] = None) -> ModelRegistry | HfModelRegistry:
83
  """
84
  Obtém a instância global do registro.
85
 
 
87
  models_dir: Diretório dos modelos (usa padrão se não fornecido)
88
 
89
  Returns:
90
+ Registro da fonte configurada para o ambiente
91
  """
92
  global _registry
93
 
94
  if _registry is None:
95
+ if use_hf_repository():
96
+ _registry = HfModelRegistry()
97
+ else:
98
  # Caminho padrão relativo ao projeto
99
+ models_dir = models_dir or Path(os.getenv('MODELOS_REPOSITORIO_LOCAL_DIR') or Path(__file__).parent / 'files')
100
+ _registry = ModelRegistry(models_dir)
101
 
102
  return _registry
103
 
requirements.txt CHANGED
@@ -12,3 +12,4 @@ matplotlib
12
  scipy
13
  plotly
14
  numpy
 
 
12
  scipy
13
  plotly
14
  numpy
15
+ huggingface_hub>=0.34,<2
run_dev.sh CHANGED
@@ -6,6 +6,49 @@ VENV_PYTHON="$ROOT_DIR/.venv/bin/python"
6
  BACKEND_PID=""
7
  FRONTEND_PID=""
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  find_homebrew_python() {
10
  if [[ -x "/opt/homebrew/opt/python@3.14/bin/python3" ]]; then
11
  printf '%s\n' "/opt/homebrew/opt/python@3.14/bin/python3"
@@ -34,8 +77,16 @@ cleanup() {
34
  echo "Encerrando backend e frontend..."
35
  terminate_tree "$BACKEND_PID"
36
  terminate_tree "$FRONTEND_PID"
 
 
 
 
 
 
37
  }
38
- trap cleanup EXIT INT TERM
 
 
39
 
40
  terminate_tree() {
41
  local pid="${1:-}"
@@ -50,14 +101,26 @@ terminate_tree() {
50
  kill "$pid" 2>/dev/null || true
51
  }
52
 
53
- echo "Subindo backend em http://localhost:${PORT:-8000}"
 
 
 
 
 
 
 
 
 
 
 
 
54
  "$VENV_PYTHON" "$ROOT_DIR/app.py" &
55
  BACKEND_PID=$!
56
 
57
- echo "Subindo frontend em http://localhost:5173"
58
  (
59
  cd "$ROOT_DIR/frontend"
60
- npm run dev -- --host 0.0.0.0
61
  ) &
62
  FRONTEND_PID=$!
63
 
 
6
  BACKEND_PID=""
7
  FRONTEND_PID=""
8
 
9
+ find_available_ports() {
10
+ "$VENV_PYTHON" - "${PORT:-8000}" "${FRONTEND_PORT:-5173}" <<'PY'
11
+ import errno
12
+ import socket
13
+ import sys
14
+
15
+
16
+ def next_port(value, reserved=()):
17
+ start = int(value)
18
+ if not 1 <= start <= 65535:
19
+ raise ValueError(f"Porta inválida: {value}")
20
+ for port in range(start, 65536):
21
+ if port in reserved:
22
+ continue
23
+ addresses = [(socket.AF_INET, "0.0.0.0")]
24
+ if socket.has_ipv6:
25
+ addresses.append((socket.AF_INET6, "::"))
26
+ available = True
27
+ for family, host in addresses:
28
+ try:
29
+ # Sem SO_REUSEADDR: no macOS ele pode permitir um bind em
30
+ # 0.0.0.0 mesmo com outro serviço escutando em 127.0.0.1.
31
+ with socket.socket(family, socket.SOCK_STREAM) as probe:
32
+ probe.bind((host, port))
33
+ probe.listen(1)
34
+ except OSError as error:
35
+ if error.errno in (errno.EAFNOSUPPORT, errno.EADDRNOTAVAIL):
36
+ continue
37
+ if error.errno in (errno.EADDRINUSE, errno.EACCES):
38
+ available = False
39
+ break
40
+ raise
41
+ if available:
42
+ return port
43
+ raise RuntimeError(f"Nenhuma porta disponível a partir de {start}")
44
+
45
+
46
+ backend_port = next_port(sys.argv[1])
47
+ frontend_port = next_port(sys.argv[2], reserved=(backend_port,))
48
+ print(backend_port, frontend_port)
49
+ PY
50
+ }
51
+
52
  find_homebrew_python() {
53
  if [[ -x "/opt/homebrew/opt/python@3.14/bin/python3" ]]; then
54
  printf '%s\n' "/opt/homebrew/opt/python@3.14/bin/python3"
 
77
  echo "Encerrando backend e frontend..."
78
  terminate_tree "$BACKEND_PID"
79
  terminate_tree "$FRONTEND_PID"
80
+ if [[ -n "$BACKEND_PID" ]]; then
81
+ wait "$BACKEND_PID" 2>/dev/null || true
82
+ fi
83
+ if [[ -n "$FRONTEND_PID" ]]; then
84
+ wait "$FRONTEND_PID" 2>/dev/null || true
85
+ fi
86
  }
87
+ trap cleanup EXIT
88
+ trap 'exit 130' INT
89
+ trap 'exit 143' TERM
90
 
91
  terminate_tree() {
92
  local pid="${1:-}"
 
101
  kill "$pid" 2>/dev/null || true
102
  }
103
 
104
+ AVAILABLE_PORTS="$(find_available_ports)"
105
+ read -r BACKEND_PORT FRONTEND_PORT <<< "$AVAILABLE_PORTS"
106
+ BACKEND_URL="http://localhost:$BACKEND_PORT"
107
+ FRONTEND_URL="http://localhost:$FRONTEND_PORT"
108
+
109
+ # O backend permite a origem escolhida e o Vite usa a porta real da API,
110
+ # inclusive quando há um VITE_API_BASE_URL antigo no frontend/.env.
111
+ export PORT="$BACKEND_PORT"
112
+ export CORS_ORIGINS="${CORS_ORIGINS:+$CORS_ORIGINS,}$FRONTEND_URL,http://127.0.0.1:$FRONTEND_PORT"
113
+ export VITE_API_BASE_URL="$BACKEND_URL"
114
+
115
+ cd "$ROOT_DIR"
116
+ echo "Subindo backend em $BACKEND_URL"
117
  "$VENV_PYTHON" "$ROOT_DIR/app.py" &
118
  BACKEND_PID=$!
119
 
120
+ echo "Subindo frontend em $FRONTEND_URL"
121
  (
122
  cd "$ROOT_DIR/frontend"
123
+ npm run dev -- --host 0.0.0.0 --port "$FRONTEND_PORT" --strictPort
124
  ) &
125
  FRONTEND_PID=$!
126
 
scripts/sync_mesa_documentacao.mjs ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Extrai os conteúdos compartilhados da MESA sem copiar definições manualmente.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { createRequire } from 'node:module';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
9
+ const mesa = path.resolve(process.argv[2] || path.join(root, '..', 'mesa-frame'));
10
+ const require = createRequire(path.join(mesa, 'frontend/package.json'));
11
+ const { parse } = require('@babel/parser');
12
+ const sources = {};
13
+ function ast(relative) {
14
+ const text = fs.readFileSync(path.join(mesa, relative), 'utf8');
15
+ sources[relative] = crypto.createHash('sha256').update(text).digest('hex');
16
+ return parse(text, { sourceType: 'module', plugins: ['jsx'] });
17
+ }
18
+ function walk(node, fn) {
19
+ if (!node || typeof node !== 'object') return;
20
+ fn(node);
21
+ for (const value of Object.values(node)) {
22
+ if (Array.isArray(value)) value.forEach((child) => walk(child, fn));
23
+ else if (value && typeof value === 'object') walk(value, fn);
24
+ }
25
+ }
26
+ function literal(node) {
27
+ if (['StringLiteral', 'NumericLiteral', 'BooleanLiteral'].includes(node?.type)) return node.value;
28
+ if (node?.type === 'NullLiteral') return null;
29
+ if (node?.type === 'ArrayExpression') return node.elements.map(literal);
30
+ if (node?.type === 'ObjectExpression') return Object.fromEntries(node.properties.map((prop) => [prop.key.name || prop.key.value, literal(prop.value)]));
31
+ if (node?.type === 'UnaryExpression' && node.operator === '-') return -literal(node.argument);
32
+ throw new Error(`Conteúdo não literal: ${node?.type}`);
33
+ }
34
+ function variable(tree, name) {
35
+ let result;
36
+ walk(tree, (node) => { if (node.type === 'VariableDeclarator' && node.id.name === name) result = node.init; });
37
+ if (!result) throw new Error(`Variável ausente na MESA: ${name}`);
38
+ return result;
39
+ }
40
+ const refs = ast('frontend/src/mqc/data/referenceData.js');
41
+ const notes = ast('frontend/src/mqc/wordExportOptions.js');
42
+ const norms = ast('frontend/src/components/NormativeReference.jsx');
43
+ const reducers = ast('frontend/src/components/RedutoresAvaliacaoModal.jsx');
44
+ const dep = ast('frontend/src/mqc/calculations.js');
45
+ const models = ast('frontend/src/modelFoundation.js');
46
+ const global = ast('frontend/src/globalFoundation.js');
47
+ const exports = ast('frontend/src/mqc/exporters.js');
48
+ const redutoresTermos = {};
49
+ walk(variable(reducers, 'memoriaCompleta'), (node) => {
50
+ if (node.type !== 'ObjectExpression') return;
51
+ const props = Object.fromEntries(node.properties.filter((p) => p.type === 'ObjectProperty').map((p) => [p.key.name || p.key.value, p.value]));
52
+ if (props.nome?.type === 'StringLiteral' && props.descricao?.type === 'StringLiteral') redutoresTermos[props.nome.value] = props.descricao.value;
53
+ });
54
+ const functions = {};
55
+ for (const name of ['calcularDepreciacao', 'calcularKd', 'calcularEnquadramento']) {
56
+ walk(dep, (node) => { if (node.type === 'FunctionDeclaration' && node.id.name === name) functions[name] = fs.readFileSync(path.join(mesa, 'frontend/src/mqc/calculations.js'), 'utf8').slice(node.start, node.end); });
57
+ }
58
+ const annotations = {};
59
+ walk(exports, (node) => { if (node.type === 'FunctionDeclaration' && ['wordCubTypeExplanation', 'wordMixExplanation', 'wordLifeAdjustmentExplanation'].includes(node.id.name)) annotations[node.id.name] = fs.readFileSync(path.join(mesa, 'frontend/src/mqc/exporters.js'), 'utf8').slice(node.start, node.end); });
60
+ const areaNotes = [];
61
+ walk(notes, (node) => {
62
+ if (node.type === 'FunctionDeclaration' && node.id.name === 'wordAreaHeaderNote') {
63
+ walk(node, (child) => { if (child.type === 'StringLiteral' && child.value.includes(': área')) areaNotes.push(child.value); });
64
+ }
65
+ });
66
+ // As fórmulas e critérios passam a ter a mesma origem do conteúdo da interface.
67
+ const catalog = {
68
+ versao: 1, fontes: sources,
69
+ notas: Object.fromEntries(literal(variable(notes, 'WORD_HEADER_NOTES'))),
70
+ notas_area: areaNotes,
71
+ referencias: Object.fromEntries(['bdiAdicionais', 'bdiTiposConstrucao', 'estadosConservacao', 'vidaUtilTipos', 'valorResidualReferencias', 'areaEquivalenteCoeficientes', 'siglasCub'].map((name) => [name, literal(variable(refs, name))])),
72
+ normas: literal(variable(norms, 'REFERENCES')),
73
+ redutores_termos: redutoresTermos,
74
+ funcoes_mqc: functions,
75
+ anotacoes_mqc: annotations,
76
+ criterios_fc: literal(variable(global, 'FC_FOUNDATION_OPTIONS')),
77
+ descricoes_modelo: literal(variable(models, 'MODEL_FOUNDATION_MANUAL_DESCRIPTIONS')),
78
+ requisitos_modelo: literal(variable(models, 'MODEL_FOUNDATION_REQUIREMENTS')),
79
+ };
80
+ const encoded = JSON.stringify(catalog);
81
+ catalog.identificador = crypto.createHash('sha256').update(encoded).digest('hex').slice(0, 16);
82
+ fs.writeFileSync(path.join(root, 'config/mesa_documentacao.json'), JSON.stringify(catalog, null, 2) + '\n');
83
+ // O mesmo pacote acompanha as novas exportações EVO para leitura histórica.
84
+ fs.writeFileSync(path.join(mesa, 'frontend/src/evolutivo/documentacaoTecnica.json'), JSON.stringify(catalog, null, 2) + '\n');
85
+ console.log(`Conteúdo técnico sincronizado da MESA: ${catalog.identificador}`);
scripts/sync_mesa_redutores.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sincroniza o catálogo versionado da MESA sem importar seu backend."""
2
+ import ast
3
+ import json
4
+ from pathlib import Path
5
+ import sys
6
+
7
+ root = Path(__file__).resolve().parents[1]
8
+ mesa = Path(sys.argv[1]) if len(sys.argv) > 1 else root.parent / "mesa-frame"
9
+ source = mesa / "backend/app/core/redutores_territoriais.py"
10
+ tree = ast.parse(source.read_text(encoding="utf-8"))
11
+ version = next(ast.literal_eval(node.value) for node in tree.body
12
+ if isinstance(node, ast.Assign)
13
+ and any(isinstance(target, ast.Name) and target.id == "VERSAO_REGRAS_REDUTORES" for target in node.targets))
14
+ catalog = next(node.value for node in tree.body if isinstance(node, ast.AnnAssign)
15
+ and isinstance(node.target, ast.Name) and node.target.id == "CATALOGO_REDUTORES")
16
+
17
+ class ResolveVersion(ast.NodeTransformer):
18
+ def visit_Name(self, node):
19
+ return ast.Constant(value=version) if node.id == "VERSAO_REGRAS_REDUTORES" else node
20
+
21
+ data = {"origem": "mesa-frame/backend/app/core/redutores_territoriais.py",
22
+ "catalogo": ast.literal_eval(ResolveVersion().visit(catalog))}
23
+ destination = root / "config/mesa_redutores.json"
24
+ destination.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
25
+ print(f"Catálogo sincronizado: {version}")
tests/test_anos.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from tempfile import TemporaryDirectory
3
+ import unittest
4
+
5
+ import joblib
6
+ import pandas as pd
7
+ from docx import Document
8
+
9
+ from document.anexos.metodologia import gerar_anexo_metodologia
10
+ from models.anos import detectar_ano_base, periodo_variavel_ano
11
+ from models.model_data import ModelData
12
+ from models.model_loader import load_model
13
+
14
+
15
+ def texto_metodologia(colunas, ano_base=None):
16
+ modelo = ModelData(
17
+ nome="TESTE", path=Path("teste.dai"), ano_base=ano_base,
18
+ tabelas_coef=pd.DataFrame({"coef": [1] * (len(colunas) + 1)}, index=["const", *colunas]),
19
+ )
20
+ doc = Document()
21
+ gerar_anexo_metodologia(doc, modelo)
22
+ return "\n".join(paragrafo.text for paragrafo in doc.paragraphs)
23
+
24
+
25
+ class AnosTests(unittest.TestCase):
26
+ def test_usa_referencia_declarada_sem_coluna_ano(self):
27
+ columns = ['2022', '2023', '2024', 'AP2025']
28
+ reference = {'tipo': 'ano', 'anos_base': [2021], 'variaveis_ano': columns}
29
+ df = pd.DataFrame({'2022': [0, 1], '2023': [0, 0], '2024': [0, 0], 'AP2025': [0, 0]})
30
+ self.assertIsNone(detectar_ano_base(df, columns))
31
+ self.assertEqual(detectar_ano_base(df, columns, reference), 2021)
32
+ self.assertEqual(detectar_ano_base(pd.DataFrame(), columns, reference), 2021)
33
+
34
+ def test_nao_usa_declaracao_ambigua_ou_de_outro_modelo(self):
35
+ columns = ['2022', 'AP2025']
36
+ reference = {'tipo': 'ano', 'anos_base': [2021], 'variaveis_ano': columns}
37
+ for change in [{'anos_base': [2020, 2021]}, {'anos_base': [2022]},
38
+ {'anos_base': [2026]}, {'anos_base': [True]},
39
+ {'anos_base': [2021.5]}, {'variaveis_ano': ['2022']}, {'tipo': 'data'}]:
40
+ with self.subTest(change=change):
41
+ self.assertIsNone(detectar_ano_base(pd.DataFrame(), columns, {**reference, **change}))
42
+
43
+ def test_loader_le_ano_base_explicitamente_salvo_no_dai(self):
44
+ columns = ['2022', 'AP2025']
45
+ package = {'referencia_temporal': {'tipo': 'ano', 'anos_base': [2021], 'variaveis_ano': columns},
46
+ 'dados': {'df': pd.DataFrame({'2022': [0, 1], 'AP2025': [0, 0]})},
47
+ 'modelo': {'coeficientes': pd.DataFrame({'coef': [1, 2, 3]}, index=['const', *columns])}}
48
+ with TemporaryDirectory() as folder:
49
+ path = Path(folder) / 'referencia.dai'
50
+ joblib.dump(package, path)
51
+ model = load_model(path)
52
+ self.assertEqual(model.ano_base, 2021)
53
+ doc = Document()
54
+ gerar_anexo_metodologia(doc, model)
55
+ text = '\n'.join(p.text for p in doc.paragraphs)
56
+ self.assertIn('2021 (ano-base do modelo)', text)
57
+ self.assertNotIn('Ano-base não identificado', text)
58
+
59
+ def test_reconhece_formatos_presentes_nos_modelos(self):
60
+ for nome, esperado in {
61
+ "2024": ((2024,), False), "a2024": ((2024,), False),
62
+ "ANO2024": ((2024,), False), "ANO_2024": ((2024,), False),
63
+ "AP2024": ((2024,), True), "2018/19": ((2018, 2019), False),
64
+ "ANOS_2025_2026": ((2025, 2026), False),
65
+ "ANO": None, "IDADE+1": None, "CONSTR_ANO": None,
66
+ }.items():
67
+ with self.subTest(nome=nome):
68
+ self.assertEqual(periodo_variavel_ano(nome), esperado)
69
+
70
+ def test_usa_ano_registrado_mesmo_com_lacuna(self):
71
+ df = pd.DataFrame({"ANO": [2012, 2015, 2017], "2015": [0, 1, 0], "2017": [0, 0, 1]})
72
+ self.assertEqual(detectar_ano_base(df, ["2015", "2017"]), 2012)
73
+ texto = texto_metodologia(["2015", "2017"], 2012)
74
+ self.assertIn("[2015] e [2017] = 0, se o dado é de 2012 (ano-base do modelo).", texto)
75
+ self.assertNotIn("2014", texto)
76
+
77
+ def test_aceita_ano_base_e_valores_salvos_como_texto(self):
78
+ df = pd.DataFrame({"ANO BASE": ["2010", "2015"], "a2015": ["0", "1"]})
79
+ self.assertEqual(detectar_ano_base(df, ["a2015"]), 2010)
80
+
81
+ def test_aceita_coluna_anual_com_nome_inteiro(self):
82
+ df = pd.DataFrame({"ANO": [2010, 2015], 2015: [0, 1]})
83
+ self.assertEqual(detectar_ano_base(df, ["2015"]), 2010)
84
+
85
+ def test_ap_sozinho_tem_referencia_explicita_e_nao_falha(self):
86
+ df = pd.DataFrame({"ANO": [2018, 2023, 2025], "AP2023": [0, 1, 1]})
87
+ self.assertEqual(detectar_ano_base(df, ["AP2023"]), 2018)
88
+ texto = texto_metodologia(["AP2023"], 2018)
89
+ self.assertIn("[AP2023] = 1, se o dado é a partir de 2023", texto)
90
+ self.assertIn("[AP2023] = 0, se o dado é de 2018", texto)
91
+
92
+ def test_anos_agrupados(self):
93
+ df = pd.DataFrame({"ANO": [2012, 2025, 2026], "ANOS_2025_2026": [0, 1, 1]})
94
+ self.assertEqual(detectar_ano_base(df, ["ANOS_2025_2026"]), 2012)
95
+ self.assertIn("se o dado é de 2025 ou 2026", texto_metodologia(["ANOS_2025_2026"], 2012))
96
+
97
+ def test_nao_presume_referencia_ausente_ambigua_ou_inconsistente(self):
98
+ casos = [
99
+ {"2015": [0, 1]},
100
+ {"ANO": [2012, 2013, 2015], "2015": [0, 0, 1]},
101
+ {"ANO": [2012, 2016], "2015": [0, 1]},
102
+ {"ANO": [2012, 2015], "2015": [0, 0]},
103
+ {"ANO": [2015], "2015": [1]},
104
+ {"ANO": [2012, None], "2015": [0, 1]},
105
+ {"ANO": [2012, 2015], "2015": [0, 0.5]},
106
+ {"ANO": [2012, 2015], "ANO BASE": [2013, 2015], "2015": [0, 1]},
107
+ ]
108
+ for dados in casos:
109
+ with self.subTest(dados=dados):
110
+ self.assertIsNone(detectar_ano_base(pd.DataFrame(dados), ["2015"]))
111
+ texto = texto_metodologia(["2015"])
112
+ self.assertIn("Ano-base não identificado no modelo", texto)
113
+ self.assertNotIn("2014", texto)
114
+
115
+ def test_carregamento_preserva_referencia_omitida_do_preview(self):
116
+ df = pd.DataFrame({"ANO": [2012, 2015], "2015": [0, 1]})
117
+ # Um banco amplo é reduzido pelo loader para X + y; ANO fica de fora.
118
+ for indice in range(45):
119
+ df[f"aux_{indice}"] = 0
120
+ pacote = {
121
+ "dados": {"df": df},
122
+ "transformacoes": {"X": df[["2015"]], "y": pd.Series([100, 200], name="VALOR")},
123
+ "modelo": {"coeficientes": pd.DataFrame({"coef": [1, 2]}, index=["const", "2015"])},
124
+ }
125
+ with TemporaryDirectory() as pasta:
126
+ caminho = Path(pasta) / "teste.dai"
127
+ joblib.dump(pacote, caminho)
128
+ modelo = load_model(caminho)
129
+ self.assertIsNotNone(modelo)
130
+ self.assertNotIn("ANO", modelo.xy_preview.columns)
131
+ self.assertEqual(modelo.ano_base, 2012)
132
+ doc = Document()
133
+ gerar_anexo_metodologia(doc, modelo)
134
+ self.assertIn("2012 (ano-base do modelo)", "\n".join(p.text for p in doc.paragraphs))
135
+
136
+
137
+ if __name__ == "__main__":
138
+ unittest.main()
tests/test_avaliacoes_import.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import json
3
+ import unittest
4
+ from unittest.mock import patch
5
+
6
+ from docx import Document
7
+ from fastapi import UploadFile
8
+ from backend.app.avaliacoes_import import parse_export, resolve_import
9
+ from backend.app.schemas import ImportacaoAvaliacoesInput, LaudoFormInput
10
+ from backend.app.services import _build_generator_payload, generate_laudo_document
11
+ from document.avaliacoes_importadas import conclusion_table, calculation_sections
12
+ from document.generator import LaudoGenerator
13
+
14
+
15
+ def ame(rows=None):
16
+ return {'formato': 'ame', 'versao': 1, 'avaliacoes': rows or [
17
+ {'id': 'base', 'titulo': 'Ano Base', 'modelo': 'MODELO_TER', 'avaliacao': {
18
+ 'tipo_y': 'unitario', 'valor_area': 100, 'estimado_unitario': 10.12, 'estimado_total': 1012,
19
+ 'valores_x': {'2024': 0, '2025': 0, 'FON': 1}}},
20
+ {'id': 'ano', 'titulo': '2025', 'modelo': 'MODELO_TER', 'avaliacao': {
21
+ 'tipo_y': 'unitario', 'valor_area': 100, 'estimado_unitario': 12.34, 'estimado_total': 1234,
22
+ 'valores_x': {'2024': 0, '2025': 1, 'FON': 1}}},
23
+ ]}
24
+
25
+
26
+ def evo():
27
+ return {'format': 'mesa-evolutivo', 'version': 2, 'anos': [{
28
+ 'identificacao': 'Imóvel', 'referencia': 'dezembro de 2025', 'concluida': True,
29
+ 'terreno': {'modelo': 'MODELO_TER', 'valorTotal': '1000', 'area': '100',
30
+ 'avaliacao': {'valores_x': {'2025': 1}, 'estimado_unitario': 10}},
31
+ 'construcao': {'valorTotal': '500', 'anoReferencia': '2025', 'construcoes': []},
32
+ 'resultadoComposicao': {'valorTerrenoPgv': 1000, 'valorConstrucao': 500,
33
+ 'valorTotal': 1215, 'fatorFcAplicado': .9, 'erros': []},
34
+ }]}
35
+
36
+
37
+ def parse(data, filename='teste.ame'):
38
+ return parse_export(json.dumps(data).encode(), filename)
39
+
40
+
41
+ class AvaliacoesImportTests(unittest.TestCase):
42
+ def test_importacao_sugere_motivos_registrados_sem_presumir_alegacao(self):
43
+ data = ame()
44
+ for row in data['avaliacoes']:
45
+ row['avaliacao']['ajuste_territorial'] = {'configuracao': {
46
+ 'art_88_i': {'condicoes': {'app': {'percentual_area': .2}, 'alta_tensao': {'percentual_area': 0}}},
47
+ 'art_88_ii': {'selecionados': ['sem_esgoto']},
48
+ 'topografia': {'relevo': 'declive_5_10'},
49
+ }}
50
+ parsed = parse(data)
51
+ reasons = parsed['sugestoes']['motivos_desvalorizantes']
52
+ self.assertEqual([reason['descricao'] for reason in reasons],
53
+ ['Área de Preservação Permanente', 'Sem disponibilidade do serviço público de esgoto', 'Declive de 5% até 10%'])
54
+ self.assertTrue(all(reason['confirmado'] and not reason['alegado'] for reason in reasons))
55
+ self.assertEqual(parsed['sugestoes']['modelo_avaliacao'], 'MODELO_TER')
56
+
57
+ def test_evo_usa_catalogo_salvo_para_sugestao_do_motivo(self):
58
+ data = evo()
59
+ year = data['anos'][0]
60
+ year['catalogoAjustes'] = {'art_88_ii': {'condicoes': [{'codigo': 'condicao_salva', 'rotulo': 'Condição registrada no trabalho'}]}}
61
+ year['terreno']['avaliacao']['ajuste_territorial'] = {'configuracao': {'art_88_ii': {'selecionados': ['condicao_salva']}}}
62
+ parsed = parse(data, 'teste.evo')
63
+ self.assertEqual(parsed['sugestoes']['motivos_desvalorizantes'][0]['descricao'], 'Condição registrada no trabalho')
64
+ self.assertEqual(parsed['sugestoes']['metodo_avaliacao'], 'Método Comparativo Dados de Mercado - ABNT NBR 14.653-2 e Método de Quantificação de Custo')
65
+
66
+ def test_sem_ajustes_nao_inventa_motivos(self):
67
+ self.assertEqual(parse(ame())['sugestoes']['motivos_desvalorizantes'], [])
68
+
69
+ def test_valor_editado_preserva_resultado_de_origem(self):
70
+ parsed = parse(evo(), 'teste.evo')
71
+ choices = ImportacaoAvaliacoesInput(valores_adotados={'evo-1': '1200'})
72
+ imported = resolve_import(parsed, choices)
73
+ row = imported['avaliacoes'][0]
74
+ self.assertEqual(row['valor_total'], '1200.00')
75
+ self.assertEqual(row['valor_total_origem'], '1215.00')
76
+ self.assertEqual(row['valor_terreno'], '1000.00')
77
+ self.assertEqual(parsed['avaliacoes'][0]['valor_total'], '1215.00')
78
+ dados, _ = _build_generator_payload(LaudoFormInput(importacao_avaliacoes=choices), None, imported)
79
+ self.assertIn('R$ 1.200,00', dados['valores_mercado'])
80
+ doc = Document()
81
+ calculation_sections(doc, imported)
82
+ text = '\n'.join(cell.text for table in doc.tables for r in table.rows for cell in r.cells)
83
+ self.assertIn('Valor total registrado na avaliação de origem', text)
84
+ self.assertIn('R$ 1.215,00', text)
85
+ self.assertIn('R$ 1.200,00', text)
86
+
87
+ def test_valores_editados_invalidos_ou_de_outro_arquivo_sao_rejeitados(self):
88
+ parsed = parse(evo(), 'teste.evo')
89
+ for overrides in ({'evo-1': ''}, {'evo-1': '-1'}, {'evo-1': 'NaN'}, {'ame-1': '10'}):
90
+ with self.subTest(overrides=overrides), self.assertRaises(ValueError):
91
+ resolve_import(parsed, ImportacaoAvaliacoesInput(valores_adotados=overrides))
92
+ self.assertEqual(resolve_import(parsed, ImportacaoAvaliacoesInput(valores_adotados={'evo-1': '0'}))['avaliacoes'][0]['valor_total'], '0.00')
93
+
94
+ def test_metadados_editados_e_motivos_manuais_chegam_ao_laudo(self):
95
+ choices = ImportacaoAvaliacoesInput(campos_editados=['modelo_avaliacao', 'metodo_avaliacao', 'datas_referencia'])
96
+ payload = LaudoFormInput(importacao_avaliacoes=choices, modelo_avaliacao='Nome revisado',
97
+ metodo_avaliacao='Método descrito pelo avaliador', datas_referencia='Referência revisada',
98
+ motivos_desvalorizantes=[{'descricao': 'Condição revisada', 'alegado': True, 'confirmado': False}])
99
+ imported = resolve_import(parse(evo(), 'teste.evo'), choices)
100
+ dados, motivos = _build_generator_payload(payload, None, imported)
101
+ self.assertEqual(dados['modelo_avaliacao'], 'Nome revisado')
102
+ self.assertEqual(dados['metodo_avaliacao'], 'Método descrito pelo avaliador')
103
+ self.assertEqual(dados['datas_referencia'], 'Referência revisada')
104
+ self.assertEqual(dados['avaliacoes_importadas']['avaliacoes'][0]['modelo'], 'MODELO_TER')
105
+ self.assertIn('Condição revisada', motivos['alegados_texto'])
106
+ self.assertEqual(motivos['confirmados_texto'], '')
107
+
108
+ def test_year_parameters_and_base_require_explicit_base_year(self):
109
+ parsed = parse(ame())
110
+ self.assertTrue(parsed['avaliacoes'][0]['cenario_base'])
111
+ self.assertEqual(parsed['avaliacoes'][1]['ano_sugerido'], '2025')
112
+ with self.assertRaisesRegex(ValueError, 'Ano Base'):
113
+ resolve_import(parsed, ImportacaoAvaliacoesInput())
114
+ resolved = resolve_import(parsed, ImportacaoAvaliacoesInput(anos={'ame-1': '2023'}))
115
+ self.assertEqual([row['ano'] for row in resolved['avaliacoes']], ['2024', '2026'])
116
+
117
+ def test_base_with_explicit_year_title_is_recognized_without_manual_mapping(self):
118
+ data = ame()
119
+ data['avaliacoes'][0]['titulo'] = '2023'
120
+ imported = resolve_import(parse(data), ImportacaoAvaliacoesInput())
121
+ self.assertEqual(imported['avaliacoes'][0]['ano_base'], '2023')
122
+ self.assertEqual(imported['avaliacoes'][0]['ano'], '2024')
123
+ self.assertEqual(imported['avaliacoes'][0]['titulo'], 'Exercício 2024 — Cenário base')
124
+
125
+ def test_model_year_2015_is_exercise_2016_in_the_document(self):
126
+ data = ame([{'titulo': '2015', 'modelo': 'MOD_V_TER_Z4_003F', 'avaliacao': {
127
+ 'valores_x': {'2015': 1, '2016': 0}, 'estimado_total': 34275500}}])
128
+ imported = resolve_import(parse(data), ImportacaoAvaliacoesInput())
129
+ row = imported['avaliacoes'][0]
130
+ self.assertEqual(row['ano_base'], '2015')
131
+ self.assertEqual(row['ano'], '2016')
132
+ self.assertEqual(row['titulo'], 'Exercício 2016')
133
+ self.assertEqual(row['titulo_origem'], '2015')
134
+ self.assertEqual(row['valor_total'], '34275500.00')
135
+ doc = Document()
136
+ calculation_sections(doc, imported)
137
+ self.assertIn('Exercício 2016 — Ano-base 2015', '\n'.join(p.text for p in doc.paragraphs))
138
+
139
+ def test_explicit_iptu_interpretation_and_duplicate_years_are_not_summed(self):
140
+ parsed = parse(ame())
141
+ resolved = resolve_import(parsed, ImportacaoAvaliacoesInput(referencia_anos='exercicio_iptu', anos={'ame-1': '2025'}))
142
+ self.assertEqual(len(resolved['avaliacoes']), 2)
143
+ self.assertEqual([row['ano_base'] for row in resolved['avaliacoes']], ['2024', '2024'])
144
+
145
+ def test_evo_preserves_final_total_instead_of_summing_components(self):
146
+ resolved = resolve_import(parse(evo(), 'teste.evo'), ImportacaoAvaliacoesInput())
147
+ row = resolved['avaliacoes'][0]
148
+ self.assertEqual(row['ano_base'], '2025')
149
+ self.assertEqual(row['valor_total'], '1215.00')
150
+ self.assertEqual(row['valor_terreno'], '1000.00')
151
+ self.assertEqual(row['valor_construcao'], '500.00')
152
+ doc = Document()
153
+ conclusion_table(doc, resolved)
154
+ calculation_sections(doc, resolved)
155
+ texts = '\n'.join(cell.text for table in doc.tables for row in table.rows for cell in row.cells)
156
+ self.assertIn('R$ 1.215,00', texts)
157
+ self.assertIn('R$ 500,00', texts)
158
+ self.assertIn('IPTU 2026', texts)
159
+ self.assertNotIn('2026 / 2025', texts)
160
+
161
+ def test_conflicting_evo_years_need_confirmation(self):
162
+ data = evo()
163
+ data['anos'][0]['construcao']['anoReferencia'] = '2024'
164
+ row = parse(data, 'teste.evo')['avaliacoes'][0]
165
+ self.assertIsNone(row['ano_sugerido'])
166
+ self.assertTrue(row['avisos'])
167
+
168
+ def test_evo_base_does_not_infer_terrain_year_from_cub_alone(self):
169
+ data = evo()
170
+ data['anos'][0]['referencia'] = ''
171
+ data['anos'][0]['terreno']['avaliacao']['valores_x'] = {'2025': 0}
172
+ row = parse(data, 'teste.evo')['avaliacoes'][0]
173
+ self.assertTrue(row['cenario_base'])
174
+ self.assertIsNone(row['ano_sugerido'])
175
+
176
+ def test_explicit_blank_year_cannot_silently_reuse_a_suggestion(self):
177
+ parsed = parse(ame())
178
+ with self.assertRaisesRegex(ValueError, '2025'):
179
+ resolve_import(parsed, ImportacaoAvaliacoesInput(anos={'ame-1': '2023', 'ame-2': ''}))
180
+
181
+ def test_adjusted_value_wins_over_raw_estimate(self):
182
+ data = ame()
183
+ data['avaliacoes'][1]['avaliacao']['ajuste_territorial'] = {'resultado': {'valor_total_territorial': 1000.50}}
184
+ self.assertEqual(parse(data)['avaliacoes'][1]['valor_total'], '1000.50')
185
+
186
+ def test_reject_unitary_only_instead_of_documenting_it_as_total(self):
187
+ data = ame()
188
+ del data['avaliacoes'][0]['avaliacao']['estimado_total']
189
+ with self.assertRaisesRegex(ValueError, 'valor total'):
190
+ parse(data)
191
+
192
+ def test_unrecognized_version_json_and_extension(self):
193
+ for content, name in [(b'{}', 'a.ame'), (b'NaN', 'a.evo'), (b'[]', 'a.ame'), (b'broken', 'a.ame'), (b'{}', 'a.docx')]:
194
+ with self.subTest(name=name, content=content), self.assertRaises(ValueError):
195
+ parse_export(content, name)
196
+
197
+ def test_missing_evo_result_is_rejected(self):
198
+ data = evo()
199
+ del data['anos'][0]['resultadoComposicao']
200
+ with self.assertRaisesRegex(ValueError, 'incompleta'):
201
+ parse(data, 'teste.evo')
202
+
203
+ def test_generator_payload_uses_imported_values_and_not_dummy_values(self):
204
+ imported = resolve_import(parse(ame()), ImportacaoAvaliacoesInput(anos={'ame-1': '2023'}))
205
+ payload = LaudoFormInput(modelo_avaliacao='Modelo dummy', valores_mercado=[{'ano': '2020', 'valor': '999999'}])
206
+ dados, _ = _build_generator_payload(payload, None, imported)
207
+ self.assertEqual(dados['modelo_avaliacao'], 'MODELO_TER')
208
+ self.assertIn('1.012,00', dados['valores_mercado'])
209
+ self.assertNotIn('999999', dados['valores_mercado'])
210
+ self.assertEqual(dados['valores_mercado_lista'], [])
211
+
212
+ def test_cabecalho_resgata_valores_por_extenso_e_referencias_separadas(self):
213
+ imported = resolve_import(parse(ame()), ImportacaoAvaliacoesInput(anos={'ame-1': '2023'}))
214
+ dados, _ = _build_generator_payload(LaudoFormInput(datas_referencia='Referência dummy'), None, imported)
215
+ self.assertEqual(dados['valores_mercado'],
216
+ '2024 - R$ 1.012,00 (mil e doze reais)\n'
217
+ '2026 - R$ 1.234,00 (mil e duzentos e trinta e quatro reais)')
218
+ self.assertEqual(dados['datas_referencia'], 'Dezembro/2023, Dezembro/2025')
219
+ doc = Document()
220
+ LaudoGenerator()._gerar_cabecalho(doc, dados)
221
+ celulas = {row.cells[0].text: row.cells[1].text for row in doc.tables[0].rows}
222
+ self.assertEqual(celulas['Valores de Mercado:'], dados['valores_mercado'])
223
+ self.assertEqual(celulas['Referências:'], dados['datas_referencia'])
224
+ self.assertNotIn('ano-base', celulas['Valores de Mercado:'])
225
+ for row in doc.tables[0].rows:
226
+ if row.cells[0].text in ('Valores de Mercado:', 'Referências:'):
227
+ self.assertTrue(row._tr.xpath('./w:trPr/w:cantSplit'))
228
+ if row.cells[0].text == 'CONCLUSÃO TÉCNICA':
229
+ self.assertTrue(row.cells[0].paragraphs[0].paragraph_format.keep_with_next)
230
+
231
+ def test_cabecalho_preserva_avaliacoes_distintas_no_mesmo_exercicio(self):
232
+ data = ame()
233
+ data['avaliacoes'][0]['titulo'] = 'Terreno A'
234
+ data['avaliacoes'][1]['titulo'] = 'Terreno B'
235
+ imported = resolve_import(parse(data), ImportacaoAvaliacoesInput(anos={'ame-1': '2025'}))
236
+ dados, _ = _build_generator_payload(LaudoFormInput(), None, imported)
237
+ self.assertIn('2026 (Terreno A) - R$ 1.012,00', dados['valores_mercado'])
238
+ self.assertIn('2026 (Terreno B) - R$ 1.234,00', dados['valores_mercado'])
239
+ self.assertEqual(dados['datas_referencia'], 'Dezembro/2025')
240
+
241
+ def test_cabecalho_evolutivo_usa_total_adotado_por_extenso(self):
242
+ imported = resolve_import(parse(evo(), 'teste.evo'), ImportacaoAvaliacoesInput())
243
+ dados, _ = _build_generator_payload(LaudoFormInput(), None, imported)
244
+ self.assertEqual(dados['valores_mercado'],
245
+ '2026 - R$ 1.215,00 (mil e duzentos e quinze reais)')
246
+ self.assertEqual(dados['datas_referencia'], 'Dezembro/2025')
247
+
248
+ def test_cabecalho_manual_preserva_formato_anterior(self):
249
+ payload = LaudoFormInput(valores_mercado=[{'ano': '2025', 'valor': '500.000,00'}],
250
+ datas_referencia='Dezembro/2024')
251
+ dados, _ = _build_generator_payload(payload, None)
252
+ self.assertEqual(dados['valores_mercado'], '2025 - 500.000,00 (quinhentos mil reais)')
253
+ self.assertEqual(dados['datas_referencia'], 'Dezembro/2024')
254
+
255
+ def test_generation_reparses_the_uploaded_file(self):
256
+ upload = UploadFile(filename='teste.ame', file=io.BytesIO(json.dumps(ame()).encode()))
257
+ payload = LaudoFormInput(importacao_avaliacoes={'anos': {'ame-1': '2023'}})
258
+ with patch('backend.app.services.LaudoGenerator') as factory:
259
+ factory.return_value.gerar.return_value = __import__('pathlib').Path('test.docx')
260
+ generate_laudo_document(payload, None, upload)
261
+ dados = factory.return_value.gerar.call_args.args[0]
262
+ self.assertEqual(len(dados['avaliacoes_importadas']['avaliacoes']), 2)
263
+ with self.assertRaisesRegex(ValueError, 'Envie o arquivo'):
264
+ generate_laudo_document(payload, None)
265
+
266
+
267
+ if __name__ == '__main__':
268
+ unittest.main()
tests/test_evo_adjustments.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import copy
2
+ import unittest
3
+ from decimal import Decimal
4
+ from unittest.mock import patch
5
+
6
+ from docx import Document
7
+ from document.evo_adjustments import applied_reducers, visible_memory
8
+ from document.evolutivo import gerar_corpo_evolutivo, condition_description
9
+ from document.avaliacoes_importadas import calculation_sections
10
+ from document.numbering import NumeradorSecoes
11
+ from test_evolutivo_documento import fixture, resolved, all_text
12
+
13
+
14
+ def body(imported):
15
+ doc=Document(); n=NumeradorSecoes(3); n.subsecao(); n.subsecao()
16
+ with patch('document.evolutivo.matching_local',return_value=(None,'')):
17
+ gerar_corpo_evolutivo(doc,imported,n)
18
+ return doc
19
+
20
+
21
+ class AppliedAdjustmentsTests(unittest.TestCase):
22
+ def test_neutral_execution_overrides_selected_conditions_and_reference_rates(self):
23
+ row=resolved(fixture((2021,)))['avaliacoes'][0]
24
+ row['evolutivo']['resultadoComposicao']['redutor88I']=.7
25
+ self.assertEqual(applied_reducers(row,True),[])
26
+ self.assertNotIn('redutores_terreno',[s['codigo'] for s in visible_memory(row)])
27
+ self.assertIn('redutores_imovel',[s['codigo'] for s in visible_memory(row)])
28
+
29
+ def test_legacy_unknown_effect_remains_visible_without_inventing_rate(self):
30
+ row={'evolutivo':{'resultadoComposicao':{'memoria':[{'codigo':'redutores_terreno','resultado':10}]}}}
31
+ self.assertEqual(applied_reducers(row,True),[('redutorTerreno','Redução territorial registrada',None)])
32
+ self.assertEqual(len(visible_memory(row)),1)
33
+
34
+ def test_component_fallback_uses_effective_saved_memory(self):
35
+ row={'evolutivo':{'resultadoComposicao':{'memoria':[{'codigo':'redutores_terreno','fator':.895,'componentes':[
36
+ {'rotulo':'Art. 88, inciso I','reducao':.105},{'rotulo':'Topografia','reducao':0}]}]}}}
37
+ self.assertEqual(applied_reducers(row,True),[('redutor88I','Art. 88, inciso I',Decimal('.105'))])
38
+
39
+ def test_only_effective_reducers_and_years_appear_in_body_and_annex(self):
40
+ data=fixture((2021,2022))
41
+ for year in data['anos']:
42
+ r=year['resultadoComposicao']; r['memoria'][4]['fator']=1
43
+ r.update(redutor88I=0,redutor6061=0,redutorTopografia=0,redutorAgua=0,redutor88II=0,redutorImovel=0,redutorApan=0,limiteArt88Aplicado=False)
44
+ r=data['anos'][0]['resultadoComposicao'];r['redutor88I']=.105;r['memoria'][0].update(fator=.895,componentes=[{'rotulo':'Art. 88, inciso I','reducao':.105},{'rotulo':'Topografia','reducao':0}])
45
+ imported=resolved(data); before=copy.deepcopy(imported)
46
+ doc=body(imported);text=all_text(doc)
47
+ for absent in ['Topografia:','Inundação — aplicado','Arts. 60 e 61:','Limite do art. 88','Art. 88, inciso II','APAN considerada no campo','Componentes de referência da MESA:']:
48
+ self.assertNotIn(absent,text)
49
+ t=next(t for t in doc.tables if any(c.text=='Art. 88, inciso I — redução efetiva' for r in t.rows for c in r.cells))
50
+ reduction=next(r for r in t.rows if r.cells[0].text=='Art. 88, inciso I — redução efetiva')
51
+ self.assertEqual([c.text for c in reduction.cells[1:]],['10,50%','—'])
52
+ ann=Document();calculation_sections(ann,imported)
53
+ self.assertNotIn('Redução do imóvel',all_text(ann));self.assertEqual(imported,before)
54
+
55
+ def test_inciso_i_excludes_areas_at_or_below_historical_threshold(self):
56
+ row=resolved(fixture((2021,)))['avaliacoes'][0]
57
+ row['evolutivo']['resultadoComposicao']['memoria'][0]['fator']=.895
58
+ row['evolutivo']['resultadoComposicao']['redutor88I']=.105
59
+ row['evolutivo']['catalogoAjustes']={'art_88_i':{'limite_minimo_area':.1,'condicoes':[
60
+ {'codigo':'app','rotulo':'APP aplicada','redutor':.7},{'codigo':'outra','rotulo':'Área não participante','redutor':.85}]}}
61
+ row['evolutivo']['terreno']['avaliacao']['ajuste_territorial']['configuracao']['art_88_i']={'condicoes':{'app':{'percentual_area':.15},'outra':{'percentual_area':.1}}}
62
+ text=condition_description(row,True)
63
+ self.assertIn('APP aplicada',text);self.assertIn('10,50%',text);self.assertNotIn('Área não participante',text)
64
+
65
+ def test_6061_describes_only_maximum_selected_condition(self):
66
+ row={'evolutivo':{'resultadoComposicao':{'redutor6061':.6},'catalogoAjustes':{'art_60_61':{'condicoes':[
67
+ {'codigo':'vila','rotulo':'Vila menor','redutor':.3},{'codigo':'encravado','rotulo':'Encravado aplicado','redutor':.6}]}},
68
+ 'terreno':{'avaliacao':{'ajuste_territorial':{'configuracao':{'art_60_61':{'selecionados':['vila','encravado']}}}}}}}
69
+ text=condition_description(row,True)
70
+ self.assertIn('Encravado aplicado',text);self.assertNotIn('Vila menor',text)
71
+
72
+ def test_applied_cap_is_documented_with_effective_not_gross_percentage(self):
73
+ imported=resolved(fixture((2021,)));r=imported['avaliacoes'][0]['evolutivo']['resultadoComposicao']
74
+ r.update(redutor88II=.2,redutor88IIBruto=.6,limiteArt88Aplicado=True,valorReferenciaArt88=1000,valorAposIncisoIArt88=187.5,valorAposArt88Bruto=75,valorAposArt88Aplicado=150)
75
+ r['memoria'][4]['fator']=.8
76
+ imported['avaliacoes'][0]['evolutivo']['catalogoAjustes']={'limite_reducao_art_88':.85}
77
+ text=all_text(body(imported))
78
+ self.assertIn('limite conjunto dos incisos I e II do art. 88, previsto em seu § 3º, foi efetivamente acionado',text)
79
+ self.assertIn('20,00%',text);self.assertNotIn('60,00%',text);self.assertIn('R$ 150,00',text)
80
+ self.assertIn('85,00%',text);self.assertIn('15,00%',text)
81
+
82
+ def test_composite_cub_and_bdi_tables_deduplicate_across_constructions(self):
83
+ data=fixture((2021,2022));
84
+ for y in data['anos']:
85
+ c=copy.deepcopy(y['construcao']['construcoes'][0]);c.update(id='segundo',seq=2,nome='Galpão B')
86
+ y['construcao']['construcoes'].append(c)
87
+ doc=body(resolved(data))
88
+ weighted=[t for t in doc.tables if t.rows[0].cells[1].text=='Projeto padrão']
89
+ self.assertEqual(len(weighted),1);self.assertEqual(len(weighted[0].rows),3)
90
+ calculations=[t for t in doc.tables if any(r.cells[0].text=='Valor da construção - VC (R$)' for r in t.rows)]
91
+ self.assertEqual(len(calculations),2)
92
+ for t in calculations:
93
+ for label in ['CUB histórico (R$/m²)','BDI (%)','Depreciação K','Fator remanescente Kd']:
94
+ self.assertTrue(any(r.cells[0].text==label for r in t.rows))
95
+
96
+ def test_changes_in_characteristics_and_compositions_keep_applicability(self):
97
+ data=fixture((2021,2022));data['anos'][1]['construcao']['construcoes'][0].update(areaCalculada=80,valorResidual=.2)
98
+ data['anos'][1]['construcao']['construcoes'][0]['cubComposicaoCalculada'][0]['peso']=50
99
+ doc=body(resolved(data))
100
+ t=next(t for t in doc.tables if t.rows[0].cells[1].text=='Tipo / ano')
101
+ self.assertEqual(len(t.rows),3)
102
+ self.assertIn('IPTU 2023',t.rows[2].cells[0].text)
103
+ t=next(t for t in doc.tables if t.rows[0].cells[1].text=='Projeto padrão')
104
+ self.assertEqual(len(t.rows),5)
105
+ self.assertIn('IPTU 2022',t.rows[1].cells[0].text);self.assertIn('IPTU 2023',t.rows[3].cells[0].text)
tests/test_evolutivo_documento.py ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import copy
2
+ import json
3
+ import io
4
+ import zipfile
5
+ from pathlib import Path
6
+ import subprocess
7
+ import unittest
8
+ from unittest.mock import patch
9
+
10
+ import pandas as pd
11
+ from docx import Document
12
+
13
+ from backend.app.avaliacoes_import import parse_export, resolve_import
14
+ from backend.app.schemas import ImportacaoAvaliacoesInput, LaudoFormInput
15
+ from backend.app.services import _build_generator_payload
16
+ from document.evolutivo import gerar_corpo_evolutivo, gerar_especificacao_evolutivo, currency, construction_groups, model_analysis
17
+ from document.fundamentacao import global_foundation, model_foundation, construction_foundation
18
+ from document.generator import LaudoGenerator
19
+ from document.mesa_content import current_content
20
+ from document.numbering import NumeradorSecoes
21
+ from models.imported_context import matching_local, snapshot_model
22
+ from models.model_data import ModelData
23
+
24
+ ROOT = Path(__file__).resolve().parents[1]
25
+ MESA = ROOT.parent / 'mesa-frame'
26
+
27
+
28
+ def fixture(years=(2021,2022,2023,2024)):
29
+ rows = []
30
+ for year in years:
31
+ construction = {'id':'construcao-a','seq':1,'nome':'Galpão A','tipoConstrucao':'GALPOES (DEPOSITOS)',
32
+ 'areaTotal':100,'areaCalculada':75,'areaEquivalente':75,'areaOrigem':'equivalente',
33
+ 'areasEquivalentes':[{'descricao':'Área coberta','area':100,'coeficiente':.75}],
34
+ 'cubTipo':'GI (Galpao Industrial)','cubTipoSecundario':'R1-N','cubCompostoAtivo':True,
35
+ 'cubComposicaoCalculada':[{'cubTipo':'GI','peso':60,'valorCub':1000},{'cubTipo':'R1-N','peso':40,'valorCub':2000}],
36
+ 'valorCub':1400,'percentualCubCalculado':80,'custoUnitario':1120,'bdi':'25',
37
+ 'anoConstrucao':1985,'vidaUtil':70,'percentualVidaUtilPadrao':50,'percentualVidaUtil':40,
38
+ 'percentualVidaUtilManualAtivo':True,'percentualVidaUtilJustificativa':'SEGREDO INTERNO',
39
+ 'percentualVidaUtilJustificativaInterna':True,'estadoConservacao':'A - novo','coefHeidecke':0,
40
+ 'depreciacao':.28,'valorResidual':.1,'kd':.748,'vcalcConstSeq':78540}
41
+ ev = {'valores_x':{'2021':int(year==2021),'ATTOTAL':50000},'tipo_y':'unitario','coluna_area':'ATTOTAL',
42
+ 'valor_area':50000,'estimado':100.123456,'estimado_unitario':100.12,'estimado_total':5006000,
43
+ 'ca_inf':85.105,'ca_sup':115.14,'ic_inf':90.11,'ic_sup':110.12,'precisao':'Grau III','amplitude':20,
44
+ 'fundamentacao':'Grau III','qtd_extrapolacoes':0,'ajuste_territorial':{'configuracao':{'campo_arbitrio':{'justificativa':'Escolha técnica salva','fator':.9}}}}
45
+ ctx = {'version':1,'name':'MODELO_TESTE','prediction':{'names':['const','2021','ATTOTAL'],'params':[1,.5,-.1]},
46
+ 'transformacoes':{'info':['VUNIT: ln(y)','2021: (x)','ATTOTAL: ln(x)'],'X':[{'2021':1,'ATTOTAL':10}], 'y':[4]},
47
+ 'dados':{'df':{'columns':['ANO','2021','ATTOTAL'],'data':[[2020,0,100],[2021,1,200]],'index':[0,1]}},
48
+ 'periodo_dados_mercado':{'data_inicial':'2020-01-01','data_final':'2021-12-31'},
49
+ 'normativeEvidence':{'n':100,'k':2,'regressorPValues':[{'name':'ATTOTAL','pValue':.05}],'fPValue':.001}}
50
+ rows.append({'id':f'origem-{year}','identificacao':str(year),'referencia':str(year),'concluida':True,
51
+ 'terreno':{'modelo':'MODELO_TESTE','areaModelo':'50000','areaAplicacao':'4126.73','area':'4126.73',
52
+ 'valorTotal':'400000','valorUnitarioOriginal':100.12,'valorUnitario':90.11,
53
+ 'parametrosAplicados':{'2021':int(year==2021),'ATTOTAL':50000},'avaliacao':ev,'contextoModelo':ctx,
54
+ 'fundamentacaoModelo':{'caracterizacao':{'grau':'Grau II','justificativa':'Caracterização salva'},'identificacaoDados':{'grau':'Grau II','justificativa':'Dados identificados'},
55
+ 'requisitosGrauIII':{'laudoCompleto':'sim','analiseModelo':'sim','enderecosCompletos':'sim','estimativaCentral':'nao'}}},
56
+ 'construcao':{'valorTotal':'78540','anoReferencia':str(year),'area':100,'construcoes':[construction],
57
+ 'avaliacao':{'cubData':f'{year}-10-01','referenciasCalculo':{}},
58
+ 'fundamentacaoMqc':{'definitions':{'formaCub':'Utilizacao CUB para projeto semelhante ao projeto padrao','tipoBdi':'Justificado','depreciacao':'Por metodos tecnicos consagrados, considerando-se idade, vida util e estado de conservacao'}}},
59
+ 'fundamentacaoGlobal':{'fatorComercializacao':'justificado','justificativa':'Condição comercial salva'},
60
+ 'resultadoComposicao':{'valorTerrenoPgv':400000,'valorConstrucao':78540,'valorAposConstrucao':478540,
61
+ 'valorAposRedutoresImovel':450000,'fatorFcAplicado':.95,'valorTotalCalculado':427500,'valorTotal':427500,
62
+ 'valorUnitarioAposRedutoresTerritoriais':100.12,'valorCa':90.11,'fatorCaFinal':.9,
63
+ 'memoria':[{'codigo':code,'rotulo':label,'fator':factor,'resultado':value,'unidade':unit} for code,label,factor,value,unit in [
64
+ ('redutores_terreno','Ajustes territoriais',1,100.12,'R$/m²'),('campo_arbitrio','Campo escolhido',.9,90.11,'R$/m²'),
65
+ ('conversao_vu_vt','Conversão territorial',4126.73,400000,'R$'),('adicao_construcao','Composição inicial',1,478540,'R$'),
66
+ ('redutores_imovel','Redução do imóvel',.94,450000,'R$'),('fator_comercializacao','Fc final',.95,427500,'R$')]]}})
67
+ return {'format':'mesa-evolutivo','version':2,'anos':rows}
68
+
69
+
70
+ def resolved(data=None, **choices):
71
+ data = data or fixture()
72
+ return resolve_import(parse_export(json.dumps(data).encode(),'teste.evo'),ImportacaoAvaliacoesInput(**choices))
73
+
74
+
75
+ def all_text(doc):
76
+ return '\n'.join([p.text for p in doc.paragraphs]+[c.text for t in doc.tables for r in t.rows for c in r.cells])
77
+
78
+
79
+ class EvolutivoDocumentTests(unittest.TestCase):
80
+ def test_paragrafo_estatistico_usa_coeficientes_reais_e_referencia_anexos_por_nome(self):
81
+ model = ModelData(nome='MODELO', path=Path('modelo.dai'), modelos_resumos={'r2': .9387**2})
82
+ doc = Document(); model_analysis(doc, model, 'unitario')
83
+ self.assertEqual(len(doc.paragraphs), 1)
84
+ text = doc.paragraphs[0].text
85
+ for value in ['0,9387', 'alto grau de correlação', '88%', 'caráter aleatório.',
86
+ 'Anexo I - Banco de Dados', 'Anexo II - Planilha de Cálculo e Resultados Estatísticos', 'Anexo III - Gráficos']:
87
+ self.assertIn(value, text)
88
+ for value in ['encontra-se dentro do previsto', 'distribuição dos resíduos', 'autocorrelação', 'teste F']:
89
+ self.assertNotIn(value, text)
90
+ doc = Document(); model_analysis(doc, model, 'total')
91
+ self.assertIn('variabilidade do valor total em reais', doc.paragraphs[0].text)
92
+ model.modelos_resumos = {}
93
+ doc = Document(); model_analysis(doc, model, 'total')
94
+ self.assertNotIn('coeficiente', doc.paragraphs[0].text)
95
+
96
+ def test_resultados_compactos_tem_notas_nativas_preservadas_ao_reabrir(self):
97
+ from lxml import etree
98
+ from docx.oxml.ns import qn
99
+ from document.footnotes import add_footnote
100
+ imported = resolved(fixture((2021, 2022, 2023, 2024, 2025, 2026)))
101
+ doc = Document(); num = NumeradorSecoes(3); num.subsecao(); num.subsecao()
102
+ gerar_corpo_evolutivo(doc, imported, num)
103
+ tables = [t for t in doc.tables if any(r.cells[0].text == 'IC 80% Inf.' for r in t.rows)]
104
+ self.assertEqual(len(tables), 2)
105
+ labels = [r.cells[0].text for r in tables[0].rows]
106
+ for label in ['Estimado Unitário', 'CA −15%', 'CA +15%', 'IC 80% Inf.', 'IC 80% Sup.']:
107
+ self.assertIn(label, labels)
108
+ stream = io.BytesIO(); doc.save(stream); stream.seek(0)
109
+ reopened = Document(stream)
110
+ add_footnote(reopened, reopened.add_paragraph('Nota posterior'), 'Nota posterior preserva as anteriores.')
111
+ stream = io.BytesIO(); reopened.save(stream)
112
+ with zipfile.ZipFile(stream) as archive:
113
+ root = etree.fromstring(archive.read('word/footnotes.xml'))
114
+ body = etree.fromstring(archive.read('word/document.xml'))
115
+ refs = body.findall('.//'+qn('w:footnoteReference'))
116
+ notes = {n.get(qn('w:id')): ''.join(n.itertext()) for n in root if int(n.get(qn('w:id'))) > 0}
117
+ self.assertEqual(len(refs), 7)
118
+ self.assertEqual(len(notes), 7)
119
+ self.assertEqual({r.get(qn('w:id')) for r in refs}, set(notes))
120
+ self.assertIn('CA: campo de arbítrio', notes['2'])
121
+ self.assertIn('intervalo de confiança de 80%', notes['3'])
122
+ self.assertIn('R$ no modelo total', notes['3'])
123
+
124
+ def test_restaura_pesquisa_padrao_e_identifica_modelo_somente_na_tabela_da_equacao(self):
125
+ imported = resolved()
126
+ doc = Document(); num = NumeradorSecoes(3); num.subsecao(); num.subsecao()
127
+ gerar_corpo_evolutivo(doc, imported, num)
128
+ paragraphs = '\n'.join(p.text for p in doc.paragraphs)
129
+ self.assertIn('Efetuou-se uma pesquisa junto às imobiliárias e às transações através das guias de ITBI', paragraphs)
130
+ self.assertIn('constituída por 100 imóveis', paragraphs)
131
+ self.assertIn('entre 1 de janeiro de 2020 e 31 de dezembro de 2021', paragraphs)
132
+ self.assertIn('empregou-se a regressão múltipla para a homogeneização dos dados', paragraphs)
133
+ self.assertEqual(paragraphs.count('Efetuou-se uma pesquisa'), 1)
134
+ self.assertNotIn('MODELO_TESTE', paragraphs)
135
+ self.assertNotIn('— contexto ', paragraphs)
136
+ tables = [t for t in doc.tables if [c.text for c in t.rows[0].cells] == ['Modelo', 'Nº de dados', 'Equação']]
137
+ self.assertEqual(len(tables), 1)
138
+ self.assertEqual(tables[0].rows[1].cells[0].text, 'MODELO_TESTE')
139
+ self.assertEqual(tables[0].rows[1].cells[1].text, '100')
140
+ self.assertIn('VUNIT', tables[0].rows[1].cells[2].text)
141
+
142
+ def test_pesquisas_diferentes_preservam_amostras_periodos_e_escopo_das_equacoes(self):
143
+ data = fixture()
144
+ context = data['anos'][1]['terreno']['contextoModelo']
145
+ context['normativeEvidence']['n'] = 75
146
+ context['periodo_dados_mercado'] = {'data_inicial': '2019-02-01', 'data_final': '2021-03-31'}
147
+ imported = resolved(data)
148
+ doc = Document(); num = NumeradorSecoes(3); num.subsecao(); num.subsecao()
149
+ gerar_corpo_evolutivo(doc, imported, num)
150
+ text = all_text(doc)
151
+ self.assertIn('constituída por 100 imóveis', text)
152
+ self.assertIn('constituída por 75 imóveis', text)
153
+ self.assertIn('entre 1 de fevereiro de 2019 e 31 de março de 2021', text)
154
+ self.assertIn('IPTU 2022 / base 2021', text)
155
+ self.assertIn('IPTU 2023 / base 2022', text)
156
+ self.assertEqual(text.count('Os elementos amostrais tomados como referência'), 1)
157
+
158
+ def test_texto_usa_referencia_explicita_do_contexto_sem_coluna_ano(self):
159
+ data = fixture((2021,))
160
+ context = data['anos'][0]['terreno']['contextoModelo']
161
+ context['referencia_temporal'] = {'tipo': 'ano', 'anos_base': [2020], 'variaveis_ano': ['2021']}
162
+ context['dados']['df'] = {'columns': ['2021', 'ATTOTAL'], 'data': [[0, 100], [1, 200]], 'index': [0, 1]}
163
+ imported = resolved(data)
164
+ self.assertEqual(snapshot_model(imported['avaliacoes'][0]).ano_base, 2020)
165
+ doc = Document(); num = NumeradorSecoes(3); num.subsecao(); num.subsecao()
166
+ with patch('document.evolutivo.matching_local', return_value=(None, '')):
167
+ gerar_corpo_evolutivo(doc, imported, num)
168
+ self.assertIn('2020 (ano-base do modelo)', all_text(doc))
169
+ self.assertNotIn('Ano-base não identificado', all_text(doc))
170
+
171
+ def test_contexto_antigo_recupera_referencia_apenas_de_versao_local_confirmada(self):
172
+ row = resolved(fixture((2021,)))['avaliacoes'][0]
173
+ row['evolutivo']['terreno']['contextoModelo']['dados']['df'] = {}
174
+ local = ModelData(nome='MODELO_TESTE', path=Path('modelo.dai'), ano_base=2020)
175
+ with patch('models.imported_context.matching_local', return_value=(local, '')) as match:
176
+ self.assertEqual(snapshot_model(row).ano_base, 2020)
177
+ match.assert_called_once_with(row)
178
+ with patch('models.imported_context.matching_local', return_value=(None, 'Versão divergente')):
179
+ self.assertIsNone(snapshot_model(row).ano_base)
180
+
181
+ def test_annual_construction_uuids_do_not_repeat_the_same_methodology(self):
182
+ data=fixture()
183
+ for i,y in enumerate(data['anos']):
184
+ y['construcao']['construcoes'][0]['id']=f'copia-anual-{i}'
185
+ y['construcao']['construcoes'][0]['areasEquivalentes'][0]['id']=f'area-{i}'
186
+ imported=resolved(data)
187
+ self.assertEqual(len(construction_groups(imported['avaliacoes'])),1)
188
+ doc=Document();num=NumeradorSecoes(3);num.subsecao();num.subsecao()
189
+ with patch('document.evolutivo.matching_local',return_value=(None,'')):gerar_corpo_evolutivo(doc,imported,num)
190
+ self.assertEqual(sum(p.text.startswith('Composição das áreas') for p in doc.paragraphs),1)
191
+
192
+ def test_duplicate_exercises_keep_separate_labels_and_results(self):
193
+ data=fixture((2021,2021));data['anos'][1]['identificacao']='Outra unidade';data['anos'][1]['resultadoComposicao']['valorTotal']=500000
194
+ imported=resolved(data)
195
+ self.assertEqual(len(imported['avaliacoes']),2)
196
+ self.assertTrue(all(row['referencia_duplicada'] for row in imported['avaliacoes']))
197
+ doc=Document();num=NumeradorSecoes(3);num.subsecao();num.subsecao()
198
+ with patch('document.evolutivo.matching_local',return_value=(None,'')):gerar_corpo_evolutivo(doc,imported,num)
199
+ self.assertIn('[evo-1]',all_text(doc));self.assertIn('[evo-2]',all_text(doc))
200
+ self.assertEqual(imported['avaliacoes'][1]['valor_total'],'500000.00')
201
+
202
+ def test_inconsistent_saved_results_are_reported_not_repaired(self):
203
+ imported=resolved(fixture((2021,)))
204
+ row=imported['avaliacoes'][0]
205
+ self.assertTrue(row['revisao_documental']['conferencias'])
206
+ self.assertEqual(row['valor_terreno'],'400000.00')
207
+ self.assertEqual(row['valor_total'],'427500.00')
208
+
209
+ def test_import_keeps_all_evidence_and_original_untouched(self):
210
+ data = fixture()
211
+ before = copy.deepcopy(data)
212
+ parsed = resolved(data)
213
+ self.assertEqual(data,before)
214
+ row = parsed['avaliacoes'][0]
215
+ self.assertEqual(row['evolutivo']['terreno']['areaModelo'],'50000')
216
+ self.assertEqual(row['evolutivo']['terreno']['areaAplicacao'],'4126.73')
217
+ self.assertEqual(row['construcoes'][0]['custoUnitario'],1120)
218
+ self.assertEqual(row['id_origem'],'origem-2021')
219
+ self.assertEqual(row['complementos']['justificativa_vu'],'Escolha técnica salva')
220
+
221
+ def test_full_body_has_hierarchy_shared_notes_and_saved_operations(self):
222
+ imported = resolved(complementos={'evo-1':{'justificativa_area':'Unidade autônoma da gleba','bdi':{'construcao-a':'Composição justificada do caso'}}},valores_adotados={'evo-1':'420000'})
223
+ doc = Document()
224
+ num = NumeradorSecoes(2); num.secao(); num.subsecao(); num.subsecao()
225
+ with patch('document.evolutivo.matching_local',return_value=(None,'Modelo sem correspondência confirmada.')):
226
+ refs = gerar_corpo_evolutivo(doc,imported,num)
227
+ gerar_especificacao_evolutivo(doc,imported,refs)
228
+ from document.avaliacoes_importadas import conclusion_table
229
+ conclusion_table(doc,imported)
230
+ text = all_text(doc)
231
+ for part in ['3.3 AVALIAÇÃO DO TERRENO','3.3.1.1 Variáveis','3.4 AVALIAÇÃO DAS CONSTRUÇÕES','3.4.1.3 BDI','3.5 AVALIAÇÃO DO IMÓVEL OBJETO',
232
+ 'Ross-Heidecke','CUNIT = CUB','Kd = R +','Unidade autônoma da gleba','Composição justificada do caso','50.000,00','4.126,73','10/2021','R$ 427.500,00','R$ 420.000,00','quatrocentos e vinte mil reais','Critério da adoção','CUB composto','Caracterização salva']:
233
+ self.assertIn(part,text)
234
+ self.assertNotIn('SEGREDO INTERNO',text)
235
+ self.assertNotIn('11/2021',text)
236
+ self.assertEqual(text.count('3.3.1.1 Variáveis'),1)
237
+ self.assertIn(current_content()['notas']['bdi'],text)
238
+ self.assertEqual(refs,{'terreno':'3.3','construcoes':'3.4','composicao':'3.5'})
239
+ composition = next(t for t in doc.tables if any(c.text=='Base antes dos redutores do imóvel' for r in t.rows for c in r.cells))
240
+ labels = [r.cells[0].text for r in composition.rows]
241
+ self.assertLess(labels.index('Base antes dos redutores do imóvel'),labels.index('Fc aplicado'))
242
+ # The complete ordered evidence belongs to the supporting annex.
243
+ annex=Document(); from document.avaliacoes_importadas import calculation_sections
244
+ calculation_sections(annex,imported)
245
+ memory = next(t for t in annex.tables if any(c.text=='Composição inicial' for r in t.rows for c in r.cells))
246
+ labels=[r.cells[0].text for r in memory.rows]
247
+ self.assertLess(labels.index('Composição inicial'),labels.index('Fc final'))
248
+
249
+
250
+ def test_applied_parameters_win_over_evaluation_old_values(self):
251
+ data = fixture((2021,)); data['anos'][0]['terreno']['avaliacao']['valores_x']['ATTOTAL'] = 999999
252
+ imported = resolved(data)
253
+ from document.avaliacoes_importadas import calculation_sections
254
+ doc = Document(); calculation_sections(doc,imported)
255
+ self.assertIn('50000',all_text(doc)); self.assertNotIn('999999',all_text(doc))
256
+
257
+ def test_manual_foundation_and_case_reasons_can_be_overridden_without_recalculation(self):
258
+ row = resolved(fixture((2021,)),complementos={'evo-1':{'justificativa_vu':'Novo fundamento','caracterizacao_grau':'Grau I','fc_criterio':'arbitrado'}})['avaliacoes'][0]
259
+ self.assertEqual(row['complementos']['justificativa_vu'],'Novo fundamento')
260
+ self.assertEqual(model_foundation(row['evolutivo'])['grade'],'Grau II')
261
+ self.assertEqual(global_foundation(row['evolutivo'])['items'][2]['grade'],'Grau I')
262
+ self.assertEqual(row['valor_total'],'427500.00')
263
+
264
+ def test_does_not_use_local_model_by_name_without_version_evidence(self):
265
+ row = resolved(fixture((2021,)))['avaliacoes'][0]
266
+ fake = type('Local',(),{'modelos_sm':None})()
267
+ with patch('models.imported_context.list_available_models',return_value=['MODELO_TESTE']),patch('models.imported_context.get_model',return_value=fake):
268
+ model,warning = matching_local(row)
269
+ self.assertIsNone(model); self.assertIn('não foi confirmada',warning)
270
+
271
+ def test_legacy_evo_generates_pending_notes_without_inventing_values(self):
272
+ data = fixture((2021,)); y = data['anos'][0]
273
+ y['terreno'].pop('contextoModelo'); y['terreno'].pop('fundamentacaoModelo')
274
+ y['construcao'].pop('fundamentacaoMqc'); y['construcao']['construcoes']=[]
275
+ y['resultadoComposicao'].pop('memoria'); y['fundamentacaoGlobal']={}
276
+ imported = resolved(data)
277
+ doc=Document(); num=NumeradorSecoes(3); num.subsecao();num.subsecao()
278
+ refs=gerar_corpo_evolutivo(doc,imported,num);gerar_especificacao_evolutivo(doc,imported,refs)
279
+ self.assertIn('não preserva o contexto',all_text(doc))
280
+ self.assertIn('Pendente',all_text(doc))
281
+
282
+ def test_header_methods_and_total_agree_with_conclusion(self):
283
+ imported=resolved(fixture((2021,)),valores_adotados={'evo-1':'420000'})
284
+ dados,_ = _build_generator_payload(LaudoFormInput(),None,imported)
285
+ self.assertEqual(dados['metodo_avaliacao'],'Método Comparativo Dados de Mercado - ABNT NBR 14.653-2 e Método de Quantificação de Custo')
286
+ self.assertIn('R$ 420.000,00',dados['valores_mercado'])
287
+ self.assertEqual(currency('999999999999.995'),'R$ 1.000.000.000.000,00')
288
+
289
+ def test_saved_package_takes_priority_and_sync_catalogs_match(self):
290
+ saved=copy.deepcopy(current_content()); saved['notas']['bdi']='BDI: referência preservada no trabalho.';saved['identificador']='historico'
291
+ data=fixture((2021,2022)); data['anos'][0]['documentacaoTecnica']=saved
292
+ imported=resolved(data);doc=Document();num=NumeradorSecoes(3);num.subsecao();num.subsecao()
293
+ with patch('document.evolutivo.matching_local',return_value=(None,'')):gerar_corpo_evolutivo(doc,imported,num)
294
+ self.assertIn('BDI: referência preservada no trabalho.',all_text(doc))
295
+ self.assertIn(current_content()['notas']['bdi'],all_text(doc))
296
+ self.assertEqual(current_content(),json.loads((MESA/'frontend/src/evolutivo/documentacaoTecnica.json').read_text()))
297
+
298
+ def test_invalid_complements_cannot_target_another_file_or_claim_an_unknown_grade(self):
299
+ for choices in [{'complementos':{'evo-100':{'justificativa_fc':'x'}}},{'complementos':{'evo-1':{'fc_criterio':'inventado'}}},{'complementos':{'evo-1':{'caracterizacao_grau':'Grau IV'}}}]:
300
+ with self.subTest(choices=choices),self.assertRaises(ValueError):resolved(fixture((2021,)),**choices)
301
+
302
+ def test_foundation_scoring_matches_actual_mesa_algorithms(self):
303
+ cases=[]
304
+ for pg in [None,.05,.15,.25,.4]:
305
+ for manual in ['', 'Grau I','Grau III']:
306
+ y=fixture((2021,))['anos'][0]
307
+ y['terreno']['contextoModelo']['normativeEvidence']['regressorPValues']=[{'name':'x','pValue':pg}]
308
+ y['terreno']['fundamentacaoModelo']['caracterizacao']['grau']=manual
309
+ cases.append(y)
310
+ for count,impact in [(None,None),(0,None),(1,None),(1,14),(1,16),(2,21)]:
311
+ y=fixture((2021,))['anos'][0]; y['terreno']['avaliacao'].pop('fundamentacao')
312
+ y['terreno']['avaliacao'].update(qtd_extrapolacoes=count,perc_ext=impact);cases.append(y)
313
+ for bdi in ['','Arbitrado','Calculado']:
314
+ y=fixture((2021,))['anos'][0];y['construcao']['fundamentacaoMqc']['definitions']['tipoBdi']=bdi;cases.append(y)
315
+ script="""import {createServer} from 'vite';
316
+ const s=await createServer({server:{middlewareMode:true},appType:'custom',logLevel:'silent'});
317
+ try { const m=await s.ssrLoadModule('/src/globalFoundation.js'); let raw=''; for await(const c of process.stdin)raw+=c;
318
+ process.stdout.write(JSON.stringify(JSON.parse(raw).map(y=>m.calculateGlobalFoundation(y)))); } finally {await s.close();}"""
319
+ actual=json.loads(subprocess.run(['node','--input-type=module','-e',script],input=json.dumps(cases),text=True,capture_output=True,cwd=MESA/'frontend',check=True).stdout)
320
+ for y,js in zip(cases,actual):
321
+ py=global_foundation(y)
322
+ with self.subTest(manual=y['terreno']['fundamentacaoModelo'],ev=y['terreno']['avaliacao']):
323
+ self.assertEqual(py['grade'],js['grade'])
324
+ self.assertEqual(py['totalPoints'],js['totalPoints'])
325
+ self.assertEqual(py['terrain']['grade'],js['terrainFoundation']['grade'])
326
+ self.assertEqual(py['construction']['grade'],js['constructionFoundation']['enquadramento'])
327
+ self.assertEqual({i['key']:i['points'] for i in py['terrain']['items']},{i['key']:i['points'] for i in js['terrainFoundation']['items']})
328
+
329
+
330
+ if __name__=='__main__':unittest.main()
tests/test_hf_model_repository.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import unittest
4
+ from pathlib import Path
5
+ from tempfile import TemporaryDirectory
6
+ from types import SimpleNamespace
7
+ from unittest.mock import Mock, patch
8
+
9
+ import joblib
10
+ import pandas as pd
11
+ from fastapi.testclient import TestClient
12
+ from backend.app.main import app
13
+ from models import registry
14
+ from models.hf_repository import HfModelRegistry, ModelRepositoryError
15
+
16
+
17
+ def info(sha, *files):
18
+ return SimpleNamespace(sha=sha, siblings=[SimpleNamespace(rfilename=f) for f in files])
19
+
20
+
21
+ class HfModelRepositoryTests(unittest.TestCase):
22
+ def setUp(self):
23
+ self.environment = patch.dict(os.environ, {}, clear=True)
24
+ self.environment.start()
25
+ self.saved_registry = registry._registry
26
+ registry._registry = None
27
+ self.api = Mock()
28
+ self.download = Mock(return_value='/cache/sha1/modelos_dai/MODELO.dai')
29
+ self.hub = SimpleNamespace(HfApi=Mock(return_value=self.api), hf_hub_download=self.download)
30
+ self.modules = patch.dict(sys.modules, {'huggingface_hub': self.hub})
31
+ self.modules.start()
32
+
33
+ def tearDown(self):
34
+ registry._registry = self.saved_registry
35
+ self.modules.stop()
36
+ self.environment.stop()
37
+
38
+ def test_spaces_always_selects_dataset_even_with_local_override(self):
39
+ for variable in ['SPACE_ID', 'SPACE_AUTHOR_NAME', 'HF_SPACE_ID']:
40
+ with self.subTest(variable=variable), patch.dict(os.environ, {variable: 'space', 'MODELOS_REPOSITORIO_PROVIDER': 'local'}):
41
+ registry._registry = None
42
+ self.assertIsInstance(registry.get_registry(Path('/local/models')), HfModelRegistry)
43
+ self.assertIsInstance(registry.get_registry(), HfModelRegistry)
44
+
45
+ def test_local_development_retains_local_models(self):
46
+ with TemporaryDirectory() as folder:
47
+ (Path(folder)/'LOCAL.dai').touch()
48
+ self.assertEqual(registry.get_registry(Path(folder)).list_models(), ['LOCAL'])
49
+ self.hub.HfApi.assert_not_called()
50
+
51
+ def test_remote_catalog_filters_backups_nested_paths_and_non_models(self):
52
+ self.api.dataset_info.return_value = info('sha1', 'modelos_dai/MODELO.dai',
53
+ 'modelos_dai/OUTRO.dai', 'modelos_dai/README.md', 'backups/ANTIGO.dai',
54
+ 'modelos_dai/backups/ANTIGO.dai', 'modelos_dai/../ESCAPE.dai')
55
+ repository = HfModelRegistry()
56
+ self.assertEqual(repository.list_models(), ['MODELO', 'OUTRO'])
57
+ self.assertEqual(repository.discover_models(), ['MODELO', 'OUTRO'])
58
+ self.api.dataset_info.assert_called_once_with('gui-sparim/repositorio_mesa', revision='main', token=None)
59
+ self.download.assert_not_called()
60
+ self.assertIsNone(repository.get_model('../ESCAPE'))
61
+
62
+ def test_selected_model_uses_pinned_revision_and_authenticated_backend_download(self):
63
+ os.environ.update(HF_TOKEN='test-secret', MODELOS_REPOSITORIO_HF_REPO_ID='org/mesa',
64
+ MODELOS_REPOSITORIO_HF_REVISION='producao', MODELOS_REPOSITORIO_HF_SUBDIR='arquivos')
65
+ self.api.dataset_info.return_value = info('commit-1', 'arquivos/MODELO.dai')
66
+ model = object()
67
+ repository = HfModelRegistry()
68
+ with patch('models.hf_repository.load_model', return_value=model) as load:
69
+ self.assertIs(repository.get_model('MODELO'), model)
70
+ self.assertIs(repository.get_model('MODELO'), model)
71
+ load.assert_called_once_with(Path(self.download.return_value))
72
+ self.download.assert_called_once_with(repo_id='org/mesa', repo_type='dataset', filename='arquivos/MODELO.dai', revision='commit-1', token='test-secret')
73
+
74
+ def test_new_commit_invalidates_model_cache_and_deleted_models(self):
75
+ os.environ['MODELOS_REPOSITORIO_HF_CACHE_TTL_SECONDS'] = '0'
76
+ self.api.dataset_info.side_effect = [info('sha1', 'modelos_dai/MODELO.dai'),
77
+ info('sha2', 'modelos_dai/MODELO.dai'), info('sha3', 'modelos_dai/NOVO.dai')]
78
+ repository = HfModelRegistry()
79
+ first, second = object(), object()
80
+ with patch('models.hf_repository.load_model', side_effect=[first, second]):
81
+ self.assertIs(repository.get_model('MODELO'), first)
82
+ self.assertIs(repository.get_model('MODELO'), second)
83
+ self.assertIsNone(repository.get_model('MODELO'))
84
+ self.assertEqual([c.kwargs['revision'] for c in self.download.call_args_list], ['sha1', 'sha2'])
85
+
86
+ def test_clear_cache_refreshes_catalog(self):
87
+ self.api.dataset_info.return_value = info('sha1', 'modelos_dai/MODELO.dai')
88
+ repository = HfModelRegistry()
89
+ repository.list_models(); repository.clear_cache(); repository.list_models()
90
+ self.assertEqual(self.api.dataset_info.call_count, 2)
91
+
92
+ def test_access_failure_does_not_use_local_or_stale_models_or_leak_secret(self):
93
+ os.environ['MODELOS_REPOSITORIO_HF_CACHE_TTL_SECONDS'] = '0'
94
+ self.api.dataset_info.side_effect = [info('sha1', 'modelos_dai/MODELO.dai'), RuntimeError('test-secret')]
95
+ repository = HfModelRegistry()
96
+ self.assertEqual(repository.list_models(), ['MODELO'])
97
+ with self.assertRaises(ModelRepositoryError) as failure:
98
+ repository.list_models()
99
+ self.assertNotIn('test-secret', str(failure.exception))
100
+ self.assertIn('HF_TOKEN', str(failure.exception))
101
+
102
+ def test_download_failure_does_not_load_bundled_model(self):
103
+ self.api.dataset_info.return_value = info('sha1', 'modelos_dai/MODELO.dai')
104
+ self.download.side_effect = RuntimeError('test-secret')
105
+ with patch('models.hf_repository.load_model') as load, self.assertRaises(ModelRepositoryError):
106
+ HfModelRegistry().get_model('MODELO')
107
+ load.assert_not_called()
108
+
109
+ def test_api_returns_service_unavailable_for_repository_failure(self):
110
+ os.environ['SPACE_ID'] = 'org/gerador'
111
+ self.api.dataset_info.side_effect = RuntimeError('test-secret')
112
+ with TestClient(app) as client:
113
+ response = client.get('/api/options')
114
+ self.assertEqual(response.status_code, 503)
115
+ self.assertIn('repositório de modelos da MESA', response.json()['detail'])
116
+ self.assertNotIn('test-secret', response.text)
117
+
118
+ def test_api_uses_remote_catalog_and_loads_downloaded_dai(self):
119
+ os.environ['SPACE_ID'] = 'org/gerador'
120
+ self.api.dataset_info.return_value = info('sha1', 'modelos_dai/REMOTO.dai')
121
+ with TemporaryDirectory() as folder:
122
+ path = Path(folder) / 'REMOTO.dai'
123
+ joblib.dump({'dados': {'df': pd.DataFrame({'AREA': [200], 'VU': [300]})}}, path)
124
+ self.download.return_value = str(path)
125
+ with TestClient(app) as client:
126
+ catalog = client.get('/api/models')
127
+ columns = client.get('/api/models/REMOTO/columns')
128
+ self.assertEqual(catalog.status_code, 200)
129
+ self.assertEqual(catalog.json(), {'models': ['REMOTO']})
130
+ self.assertEqual(columns.status_code, 200)
131
+ self.assertEqual(columns.json(), {'modelo': 'REMOTO', 'columns': ['AREA', 'VU']})
132
+ self.assertEqual(registry.get_model('REMOTO').path, path)
133
+ self.assertEqual(self.download.call_count, 1)
134
+
135
+
136
+ if __name__ == '__main__':
137
+ unittest.main()