diff --git a/.dockerignore b/.dockerignore index a9a89ae6d7ac3cb257740bd146283116758bb962..97878533a03134754ac8a9594a64550bd234cd0c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,6 @@ node_modules frontend/node_modules output/laudos_gerados output/tmp_uploads +models/files +models/backups +tmp diff --git a/Dockerfile b/Dockerfile index 69763cd4ee4032851d1e7298e4f3dce6a1e40920..d3d565aae28d3ae71c73e280a552937b6f5977e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,9 @@ FROM python:3.11-slim AS runtime ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 ENV PORT=7860 +ENV MODELOS_REPOSITORIO_PROVIDER=hf_dataset +ENV MODELOS_REPOSITORIO_HF_REPO_ID=gui-sparim/repositorio_mesa +ENV MODELOS_REPOSITORIO_HF_SUBDIR=modelos_dai WORKDIR /app COPY requirements.txt ./ diff --git a/README.md b/README.md index bac01546a1d0f4fe1883182a4de7726678a5ff66..91abc56881ec0bfb8938bc20423d8072e1585bfc 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,19 @@ Aplicação para geração de laudos de avaliação imobiliária com backend em ## Rodando em desenvolvimento local +Para iniciar backend e frontend juntos: + +```bash +./run_dev.sh +``` + +O script procura as próximas portas livres a partir de `8000` para o backend e +`5173` para o frontend, configura a conexão com a API e as origens CORS, e exibe +os endereços escolhidos. Para mudar as portas iniciais, use, por exemplo, +`PORT=8100 FRONTEND_PORT=5200 ./run_dev.sh`. `Ctrl+C` encerra os dois servidores. + +Também é possível iniciar cada serviço separadamente: + ### 1) Backend ```bash @@ -69,9 +82,76 @@ Este repositório inclui `Dockerfile` para Spaces com Docker SDK. - Builda o frontend em estágio Node - Sobe API FastAPI na porta `7860` (ou `PORT` informado pelo ambiente) +- Consulta os modelos no dataset da MESA, `gui-sparim/repositorio_mesa`, pasta `modelos_dai`. + Os arquivos `.dai` locais e seus backups não entram na imagem Docker. + +Cadastre nos **Settings → Secrets** do Space o `HF_TOKEN` com acesso de leitura +ao dataset da MESA, caso ele seja privado. O token permanece no backend. +Em Spaces, a fonte é sempre o dataset; falhas de acesso são informadas e não +substituídas pelos modelos locais. + +Configuração opcional em **Settings → Variables** (mesmos nomes da MESA): + +- `MODELOS_REPOSITORIO_HF_REPO_ID`: padrão `gui-sparim/repositorio_mesa` +- `MODELOS_REPOSITORIO_HF_REVISION`: padrão `main` +- `MODELOS_REPOSITORIO_HF_SUBDIR`: padrão `modelos_dai` +- `MODELOS_REPOSITORIO_HF_CACHE_TTL_SECONDS`: padrão `300` segundos + +O catálogo é consultado novamente após esse intervalo. Cada modelo é baixado +somente quando utilizado e a versão fica vinculada ao commit do dataset. +Uma nova revisão invalida os modelos carregados em memória, incluindo modelos +alterados ou excluídos. O cache de downloads do Hub evita transferências repetidas. + +No desenvolvimento fora do Docker/Spaces, o padrão continua sendo `models/files`. +Para testar a mesma fonte de produção localmente, defina +`MODELOS_REPOSITORIO_PROVIDER=hf_dataset` e configure `HF_TOKEN` no ambiente. Comando de execução no container: ```bash uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-7860} ``` + +## Importação de avaliações do MESA + +A aplicação abre na etapa **Início · Importar avaliação**. A importação sugere os +motivos desvalorizantes e preenche método, modelos, referências e valores na +seção **5. Avaliação**. Os dados continuam editáveis; revisar anos ou trocar o +arquivo atualiza os campos automáticos e preserva as alterações manuais. Os +valores adotados são reiniciados ao trocar de arquivo para não reutilizar valores +de outra avaliação. + +As condições registradas na MESA são sugeridas como confirmadas, sem presumir +que tenham sido alegadas pelo contribuinte. Revise essas marcações na seção de +motivos. O catálogo de condições tem origem e versão da MESA; para sincronizá-lo, +execute `python scripts/sync_mesa_redutores.py /caminho/mesa-frame`. + +Um valor de mercado editado é usado no cabeçalho e na conclusão; a memória do +laudo conserva também o valor registrado na avaliação de origem. Método, nome do +modelo e referências revisados são textos documentais; a identificação original +do modelo permanece disponível para a metodologia e os anexos. + +A importação preenche modelo e valores de mercado a partir de um arquivo JSON +exportado pelo MESA: + +- **`.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. +- **`.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. + +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. + +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. + +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. + +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. + +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. + +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`. + +Endpoints adicionais: + +- `POST /api/avaliacoes/importar`: multipart com `arquivo`; retorna a prévia. +- `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`). + +Validação: `python -m unittest discover -s tests -v` e `npm run build --prefix frontend`. diff --git a/backend/app/avaliacoes_import.py b/backend/app/avaliacoes_import.py new file mode 100644 index 0000000000000000000000000000000000000000..4801d85534e53ad48c4244cdfc8dff2ffac87c70 --- /dev/null +++ b/backend/app/avaliacoes_import.py @@ -0,0 +1,247 @@ +"""Read MESA JSON exports without loading executable models or recalculating results.""" +from __future__ import annotations + +import json +import re +from copy import deepcopy +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +from pathlib import Path + +from .import_defaults import motivos_da_avaliacao, sugestoes_documentais +from .evo_documentation import saved_complements, apply_complements, review_for +from document.mesa_content import current_content + +MAX_FILE_BYTES = 50 * 1024 * 1024 +YEAR = re.compile(r"(? 1: + year = None + warnings.append('As referências do terreno, construção ou composição divergem; confirme o ano adotado.') + else: + year = next(iter(inferred), None) + if base and not reference: + year = None + 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.') + if not item.get('concluida'): + + warnings.append('A composição foi salva sem a marca de conclusão no MESA.') + yield { + 'id': f'evo-{index + 1}', 'titulo': title, 'modelo': str(terrain.get('modelo') or ''), + 'ano_sugerido': year, 'cenario_base': base and not reference, + 'avisos': warnings, 'valor_total': total, 'valor_terreno': terrain_total, + 'valor_construcao': construction_total, 'valor_unitario': first_money(result.get('valorUnitarioTerreno'), terrain.get('valorUnitario')), + 'id_origem': item.get('id'), + 'evolutivo': deepcopy(item), + 'complementos': saved_complements(item), + 'revisao_documental': review_for(item, saved_complements(item)), + 'area': terrain.get('areaAplicacao') if terrain.get('areaAplicacao') is not None else terrain.get('area'), + 'area_construida': construction.get('area'), 'avaliacao': compact_evaluation(evaluation), + 'construcoes': deepcopy([row for row in construction.get('construcoes', []) if isinstance(row, dict)]), + 'fator_comercializacao': result.get('fatorFcAplicado', item.get('fatorComercializacao')), + 'valor_antes_redutores_imovel': result.get('valorAposConstrucao'), + 'valor_apos_redutores_imovel': result.get('valorAposRedutoresImovel'), + 'observacoes': str(item.get('observacoes') or ''), + 'motivos_desvalorizantes': motivos_da_avaliacao(item, evaluation, item.get('catalogoAjustes')), + } + + +def parse_export(content: bytes, filename: str): + extension = Path(filename).suffix.lower() + if extension not in ('.ame', '.evo'): + raise ValueError('Envie um arquivo .ame ou .evo.') + if len(content) > MAX_FILE_BYTES: + raise ValueError('O arquivo excede o limite de 50 MB.') + try: + data = json.loads(content.decode('utf-8-sig'), parse_constant=lambda value: (_ for _ in ()).throw(ValueError(value))) + except (UnicodeError, ValueError, RecursionError) as exc: + raise ValueError('O arquivo não contém JSON válido.') from exc + if not isinstance(data, dict): + raise ValueError('Estrutura de arquivo inválida.') + if extension == '.ame': + if data.get('formato') != 'ame' or data.get('versao') != 1: + raise ValueError('Formato ou versão AME não suportado.') + rows, kind = list(ame_rows(data)), 'modelo' + else: + if data.get('format') != 'mesa-evolutivo' or data.get('version') not in (1, 2): + raise ValueError('Formato ou versão EVO não suportado.') + rows, kind = list(evo_rows(data)), 'evolutivo' + return {'arquivo': Path(filename).name, 'tipo': kind, 'avaliacoes': rows, + 'conteudo_tecnico': {'identificador': current_content()['identificador'], 'versao': current_content()['versao']}, + 'sugestoes': sugestoes_documentais(rows, kind)} + + +def resolve_import(parsed, choices): + if choices.referencia_anos not in ('ano_base', 'exercicio_iptu'): + raise ValueError('Selecione a interpretação dos anos.') + known_ids = {source['id'] for source in parsed['avaliacoes']} + if set(choices.valores_adotados) - known_ids: + raise ValueError('Há valores adotados para avaliações que não pertencem ao arquivo.') + if set(choices.complementos) - known_ids: + raise ValueError('Há complementos para avaliações que não pertencem ao arquivo.') + rows = [] + for source in parsed['avaliacoes']: + selected = choices.anos[source['id']] if source['id'] in choices.anos else source['ano_sugerido'] + year = str(selected or '').strip() + if not re.fullmatch(r'(?:19|20|21)\d{2}', year): + raise ValueError(f"Informe o ano de referência para {source['titulo']}.") + base = int(year) if choices.referencia_anos == 'ano_base' else int(year) - 1 + warnings = [warning for warning in source['avisos'] + if not any(word in warning.lower() for word in ('informe', 'confira', 'confirme'))] + if not source['ano_sugerido']: + warnings.append('Referência temporal definida pelo usuário durante a importação.') + original_title = source['titulo'] + title = f'Exercício {base + 1}' + if source['cenario_base']: + title += ' — Cenário base' + elif not re.fullmatch(r'(?:19|20|21)\d{2}', original_title.strip()): + title += f' — {original_title}' + rows.append({**source, 'titulo_origem': original_title, 'titulo': title, + 'avisos': warnings, 'ano_base': str(base), 'ano': str(base + 1), + 'objeto': 'Terreno e construção' if parsed['tipo'] == 'evolutivo' else choices.objeto_modelo.strip() or 'Objeto avaliado por modelo'}) + if parsed['tipo'] == 'evolutivo': + complements = deepcopy(source['complementos']) + if source['id'] in choices.complementos: + override = choices.complementos[source['id']] + changes = override.model_dump(exclude_unset=True) + for key, value in changes.items(): + if key in ('bdi', 'requisitos_grau_iii'): + complements[key] = {**complements.get(key, {}), **value} + else: + complements[key] = value + rows[-1]['complementos'] = complements + rows[-1]['evolutivo'] = apply_complements(source['evolutivo'], complements) + rows[-1]['revisao_documental'] = review_for(rows[-1]['evolutivo'], complements) + if source['id'] in choices.valores_adotados: + adopted = money(choices.valores_adotados[source['id']]) + if adopted is None: + raise ValueError(f"Informe um valor adotado válido para {source['titulo']}.") + if adopted != source['valor_total']: + rows[-1]['valor_total_origem'] = source['valor_total'] + rows[-1]['valor_total'] = adopted + rows.sort(key=lambda item: (int(item['ano_base']), item['titulo'])) + from collections import Counter + occurrences = Counter(item['ano_base'] for item in rows) + for row in rows: + row['referencia_duplicada'] = occurrences[row['ano_base']] > 1 + return {**parsed, 'avaliacoes': rows} diff --git a/backend/app/evo_documentation.py b/backend/app/evo_documentation.py new file mode 100644 index 0000000000000000000000000000000000000000..6fabdcb3adfe567458e6901e19f884ca027b29b5 --- /dev/null +++ b/backend/app/evo_documentation.py @@ -0,0 +1,123 @@ +"""Saved case-specific reasons and missing documentary evidence for EVO review.""" +from copy import deepcopy +from document.mesa_content import content_for +from document.fundamentacao import global_foundation, POINTS, number +from decimal import Decimal, ROUND_HALF_UP +from models.construction_identity import construction_identity + + +def construction_key(row, index): + return str(row.get('id') or row.get('seq') or index+1) + + +def public_reason(obj, *keys): + for key in keys: + if obj.get(key + 'Interna') or obj.get(key + '_interna'): + continue + value = obj.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return '' + + +def saved_complements(source): + terrain = source.get('terreno') or {} + evaluation = terrain.get('avaliacao') or {} + ca = (evaluation.get('ajuste_territorial') or {}).get('configuracao', {}).get('campo_arbitrio') or {} + manual = terrain.get('fundamentacaoModelo') or {} + construction = source.get('construcao') or {} + global_state = source.get('fundamentacaoGlobal') or {} + result = { + 'justificativa_vu': public_reason(ca, 'justificativa') or public_reason(terrain, 'justificativaVu', 'justificativaValorUnitario'), + 'justificativa_area': public_reason(terrain, 'justificativaArea', 'areaAplicacaoJustificativa'), + 'justificativa_fc': public_reason(global_state, 'justificativa', 'justificativaFc') or public_reason(source, 'justificativaFc', 'fatorComercializacaoJustificativa'), + 'ajustes_nao_aplicados': public_reason(source, 'ajustesNaoAplicados'), + 'criterio_adocao': public_reason(source, 'criterioAdocao'), + 'fc_criterio': global_state.get('fatorComercializacao') or '', + 'bdi': {construction_key(row, i): public_reason(row, 'bdiJustificativa', 'justificativaBdi') for i, row in enumerate(construction.get('construcoes') or [])}, + 'caracterizacao_grau': (manual.get('caracterizacao') or {}).get('grau',''), + 'caracterizacao_justificativa': public_reason(manual.get('caracterizacao') or {}, 'justificativa'), + 'identificacao_grau': (manual.get('identificacaoDados') or {}).get('grau',''), + 'identificacao_justificativa': public_reason(manual.get('identificacaoDados') or {}, 'justificativa'), + 'requisitos_grau_iii': manual.get('requisitosGrauIII') or {}, + } + # Future EVO files may preserve these exact documentary fields as a package. + saved = source.get('complementosDocumentais') or {} + for key in result: + if saved.get(key): result[key] = saved[key] + return result + + +def apply_complements(source, values): + source = deepcopy(source) + terrain = source.setdefault('terreno', {}) + state = terrain.setdefault('fundamentacaoModelo', {}) + for prefix, key in [('caracterizacao','caracterizacao'),('identificacao','identificacaoDados')]: + selected = values.get(prefix+'_grau', '') + if selected and selected not in POINTS: + raise ValueError('Grau de fundamentação inválido.') + state[key] = {'grau':selected,'justificativa':values.get(prefix+'_justificativa','')} + allowed = {item['value'] for item in content_for(source)['criterios_fc']} + if values.get('fc_criterio') and values['fc_criterio'] not in allowed: + raise ValueError('Critério do fator de comercialização inválido.') + reqs = values.get('requisitos_grau_iii') or {} + allowed_reqs = {item['key'] for item in content_for(source)['requisitos_modelo']} + if set(reqs) - allowed_reqs or any(v not in ('','sim','nao',True,False) for v in reqs.values()): + raise ValueError('Requisitos do Grau III inválidos.') + state['requisitosGrauIII'] = reqs + source.setdefault('fundamentacaoGlobal', {})['fatorComercializacao'] = values.get('fc_criterio','') + source['complementosDocumentais'] = deepcopy(values) + return source + + +def review_for(source, complements): + terrain = source.get('terreno') or {} + area_model = terrain.get('areaModelo', (terrain.get('avaliacao') or {}).get('valor_area')) + area_apply = terrain.get('areaAplicacao', terrain.get('area')) + pending = [] + for key, label, required in [ + ('justificativa_vu','Escolha do valor unitário / campo de arbítrio',True), + ('justificativa_area','Diferença entre área do modelo e área de aplicação',number(area_model) != number(area_apply)), + ('justificativa_fc','Escolha do fator de comercialização',True), + ]: + if required and not complements.get(key): pending.append(label) + constructions = [] + construction = source.get('construcao') or {} + for index, row in enumerate(construction.get('construcoes') or []): + key = construction_key(row,index) + name = row.get('nome') or f"Construção {row.get('seq') or index+1} — {row.get('tipoConstrucao') or 'tipo não informado'}" + constructions.append({'id':key,'nome':name,'bdi':row.get('bdi'),'grupo':construction_identity(source,row,index)}) + if not complements.get('bdi',{}).get(key): pending.append(f'BDI: {name}') + foundation = global_foundation(source) + for label, item in [('Terreno',foundation['terrain']),('Construções',foundation['construction']),('Global',foundation)]: + if item['grade'] == 'Pendente': pending.append(f'Fundamentação {label.lower()}: há critérios não informados') + return {'pendencias':pending,'construcoes':constructions,'area_modelo':area_model,'area_aplicacao':area_apply, + 'conferencias': check_saved_results(source), + 'fundamentacao': {k:foundation[k]['grade'] for k in ['terrain','construction']} | {'global':foundation['grade']}, + 'criterios_fc':content_for(source)['criterios_fc'],'requisitos_modelo':content_for(source)['requisitos_modelo']} + + +def check_saved_results(source): + """Report inconsistencies using saved precision; never repair source values.""" + warnings = [] + result = source.get('resultadoComposicao') or {} + def check(label, expected, actual): + actual = number(actual) + if expected is not None and actual is not None: + expected = expected.quantize(Decimal('.01'),rounding=ROUND_HALF_UP) + if abs(expected-actual) > Decimal('.02'): + warnings.append(f'{label}: os dados salvos não reproduzem o resultado registrado (conferência {expected}; registro {actual}). O resultado de origem foi preservado.') + terrain = source.get('terreno') or {} + vu = number(result.get('valorUnitarioTerreno',result.get('valorCa',terrain.get('valorUnitario')))) + area = number(terrain.get('areaAplicacao',terrain.get('area'))) + check('Valor territorial',vu*area if vu is not None and area is not None else None,result.get('valorTerrenoPgv')) + construction_values = [] + for i, item in enumerate((source.get('construcao') or {}).get('construcoes') or []): + a,c,bdi,kd = [number(item.get(key)) for key in ['areaCalculada','custoUnitario','bdi','kd']] + 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')) + construction_values.append(number(item.get('vcalcConstSeq'))) + if construction_values and all(v is not None for v in construction_values): + check('Total das construções',sum(construction_values),result.get('valorConstrucao')) + base,fc = number(result.get('valorAposRedutoresImovel')),number(result.get('fatorFcAplicado')) + check('Aplicação final do Fc',base*fc if base is not None and fc is not None else None,result.get('valorTotalCalculado')) + return warnings diff --git a/backend/app/import_defaults.py b/backend/app/import_defaults.py new file mode 100644 index 0000000000000000000000000000000000000000..269f896638e7b485cb6227e5e69f7ec0a6f9672c --- /dev/null +++ b/backend/app/import_defaults.py @@ -0,0 +1,90 @@ +"""Sugestões documentais a partir das condições registradas nas avaliações.""" +from functools import lru_cache +import json +from pathlib import Path +from config.constants import METODO_COMPARATIVO, METODO_EVOLUTIVO + + +def record(value): + return value if isinstance(value, dict) else {} + + +@lru_cache(maxsize=1) +def catalogo_mesa(): + path = Path(__file__).resolve().parents[2] / "config/mesa_redutores.json" + return json.loads(path.read_text(encoding="utf-8"))["catalogo"] + + +def positivo(value): + try: + return float(value) > 0 + except (TypeError, ValueError): + return False + + +def motivos_da_avaliacao(item, evaluation, catalog=None): + catalog = record(catalog) or catalogo_mesa() + ajuste = record(record(evaluation).get("ajuste_territorial")) + config = record(ajuste.get("configuracao")) or ajuste + redutores = record(ajuste.get("redutores")) + reasons = {} + + def add(description, alleged=False, confirmed=True): + description = str(description or "").strip() + if not description: + return + key = description.casefold() + previous = reasons.get(key, {}) + reasons[key] = {"descricao": description, + "alegado": bool(alleged or previous.get("alegado")), + "confirmado": bool(confirmed or previous.get("confirmado"))} + + for reason in item.get("motivos_desvalorizantes", []) if isinstance(item.get("motivos_desvalorizantes"), list) else []: + if isinstance(reason, dict): + add(reason.get("descricao"), reason.get("alegado", False), reason.get("confirmado", False)) + + def selected(group, codes, subkey="condicoes"): + if not isinstance(codes, (list, tuple, set)): + return + for condition in record(catalog.get(group)).get(subkey, []): + if condition.get("codigo") in codes: + add(condition.get("rotulo")) + + conditions = record(record(config.get("art_88_i")).get("condicoes")) + details = record(record(redutores.get("art_88")).get("condicoes_inciso_i")) + for condition in record(catalog.get("art_88_i")).get("condicoes", []): + code = condition["codigo"] + values = record(conditions.get(code)) + detail = record(details.get(code)) + if any(positivo(value) for value in [values.get("percentual_area"), + values.get("percentual_area_considerada"), values.get("percentual_area_bruta"), + detail.get("percentual_area_informado")]): + add(condition.get("rotulo")) + selected("art_88_ii", record(config.get("art_88_ii")).get("selecionados", + record(redutores.get("art_88")).get("selecionados_inciso_ii_informados", []))) + selected("art_60_61", record(config.get("art_60_61")).get("selecionados", + record(redutores.get("art_60_61")).get("selecionados", []))) + topography = record(config.get("topografia")) or record(redutores.get("topografia")) + for key in ("relevo", "nivel_rua"): + selected("topografia", [topography.get(key)], key) + water = record(config.get("agua")) or record(redutores.get("agua")) + selected("agua", [water.get("situacao")]) + apan = record(config.get("apan")) or record(redutores.get("apan")) + if positivo(apan.get("percentual_area")): + add("APAN") + return list(reasons.values()) + + +def sugestoes_documentais(rows, kind): + reasons = {} + for row in rows: + for reason in row.get("motivos_desvalorizantes", []): + key = reason["descricao"].casefold() + previous = reasons.get(key, {}) + reasons[key] = {**reason, "alegado": reason["alegado"] or previous.get("alegado", False), + "confirmado": reason["confirmado"] or previous.get("confirmado", False)} + return { + "motivos_desvalorizantes": list(reasons.values()), + "metodo_avaliacao": METODO_EVOLUTIVO if kind == "evolutivo" else METODO_COMPARATIVO, + "modelo_avaliacao": ", ".join(dict.fromkeys(row["modelo"] for row in rows if row["modelo"])), + } diff --git a/backend/app/main.py b/backend/app/main.py index 4a3363433bd27c6cf0958347d4a8ad95ea089374..1cc7213a8db3b26487402446cadbd3f4e1f81f25 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,6 +10,9 @@ from fastapi.staticfiles import StaticFiles from pydantic import ValidationError from config.settings import OUTPUT_DIR +from models.hf_repository import ModelRepositoryError + +from .avaliacoes_import import MAX_FILE_BYTES, parse_export from .runtime import CORS_ORIGINS, FRONTEND_DIST_DIR, HOST, PORT from .schemas import ( @@ -44,6 +47,11 @@ if CORS_ORIGINS: ) +@app.exception_handler(ModelRepositoryError) +async def model_repository_error(request, exc): + return JSONResponse(status_code=503, content={'detail': str(exc)}) + + @app.get("/api/health") def health() -> dict: return {"status": "ok"} @@ -64,6 +72,8 @@ def models() -> dict: def model_columns(modelo_nome: str) -> dict: try: return get_model_columns_payload(modelo_nome) + except ModelRepositoryError: + raise except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: @@ -93,10 +103,19 @@ def market_values_generate(payload: MarketValuesRangeRequest) -> dict: raise HTTPException(status_code=400, detail=f"Intervalo de anos inválido: {exc}") from exc +@app.post("/api/avaliacoes/importar") +async def avaliacoes_importar(arquivo: UploadFile = File(...)) -> dict: + try: + return parse_export(await arquivo.read(MAX_FILE_BYTES + 1), arquivo.filename or "") + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/laudos/generate", response_model=LaudoGenerateResponse) async def laudos_generate( payload: str = Form(...), registro_fotografico: UploadFile | None = File(default=None), + arquivo_avaliacoes: UploadFile | None = File(default=None), ) -> dict: try: payload_obj = LaudoFormInput.model_validate_json(payload) @@ -111,7 +130,9 @@ async def laudos_generate( ) try: - output_path = generate_laudo_document(payload_obj, registro_fotografico) + output_path = generate_laudo_document(payload_obj, registro_fotografico, arquivo_avaliacoes) + except ModelRepositoryError: + raise except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 126da26e7573c12e126eb99284eeb112e220debc..a50f1b519ea2d58774a1e077ee877c5533cf48bc 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -17,7 +17,33 @@ class ValorMercadoInput(BaseModel): valor: str = "" +class ComplementosAvaliacaoInput(BaseModel): + justificativa_vu: str = "" + justificativa_area: str = "" + justificativa_fc: str = "" + ajustes_nao_aplicados: str = "" + criterio_adocao: str = "" + bdi: dict[str, str] = Field(default_factory=dict) + caracterizacao_grau: str = "" + caracterizacao_justificativa: str = "" + identificacao_grau: str = "" + identificacao_justificativa: str = "" + requisitos_grau_iii: dict[str, str | bool] = Field(default_factory=dict) + fc_criterio: str = "" + + +class ImportacaoAvaliacoesInput(BaseModel): + referencia_anos: str = "ano_base" + anos: dict[str, str] = Field(default_factory=dict) + objeto_modelo: str = "Objeto avaliado por modelo" + valores_adotados: dict[str, str] = Field(default_factory=dict) + campos_editados: List[str] = Field(default_factory=list) + complementos: dict[str, ComplementosAvaliacaoInput] = Field(default_factory=dict) + + class LaudoFormInput(BaseModel): + importacao_avaliacoes: Optional[ImportacaoAvaliacoesInput] = None + numero_laudo: str = "" numero_processo: str = "" data_laudo: str = "" diff --git a/backend/app/services.py b/backend/app/services.py index 60754bd746194c3ff8b7e6897b8da0d427b0dc7a..abd6871c1c03f143f50a92c21291aa8f3c857f66 100644 --- a/backend/app/services.py +++ b/backend/app/services.py @@ -1,6 +1,7 @@ """Serviços de negócio do backend.""" from __future__ import annotations +from collections import Counter from datetime import datetime from pathlib import Path from tempfile import NamedTemporaryFile @@ -14,6 +15,8 @@ from config.constants import ( MAX_MOTIVOS_DESVALORIZANTES, MAX_VALORES_MERCADO, METODOS_AVALIACAO, + METODO_COMPARATIVO, + METODO_EVOLUTIVO, TECNICOS_RESPONSAVEIS, TIPOS_REQUERIMENTO, UNIDADES_DEMANDANTES, @@ -29,6 +32,7 @@ from utils.formatters import ( formatar_valores_mercado_para_documento, ) +from .avaliacoes_import import MAX_FILE_BYTES, parse_export, resolve_import from .runtime import DEFAULT_FILL_DUMMY, ENABLE_DUMMY_DATA from .schemas import LaudoFormInput @@ -236,6 +240,7 @@ def _normalizar_lista_strings(values: List[str]) -> List[str]: def _build_generator_payload( payload: LaudoFormInput, registro_fotografico_path: str | None, + importacao: dict | None = None, ) -> Tuple[Dict[str, Any], Dict[str, Any]]: valor_formatado, valor_extenso = formatar_valor_monetario(payload.valor_proposto) valor_proposto_completo = ( @@ -271,7 +276,7 @@ def _build_generator_payload( colunas_banco_dados = ( _validar_colunas_banco_dados(payload.modelo_avaliacao, payload.banco_dados_colunas) - if payload.banco_dados_colunas is not None + if payload.banco_dados_colunas is not None and importacao is None else [] ) @@ -322,12 +327,36 @@ def _build_generator_payload( "registro_fotografico_path": registro_fotografico_path, } + if importacao: + rows = importacao['avaliacoes'] + models = list(dict.fromkeys(row['modelo'] for row in rows if row['modelo'])) + dados['avaliacoes_importadas'] = importacao + dados['modelo_avaliacao'] = ', '.join(models) + dados['metodo_avaliacao'] = METODO_EVOLUTIVO if importacao['tipo'] == 'evolutivo' else METODO_COMPARATIVO + dados['datas_referencia'] = ', '.join(dict.fromkeys( + f"Dezembro/{row['ano_base']}" for row in rows)) + dados['valores_mercado_lista'] = [] + exercicios = Counter(row['ano'] for row in rows) + anos = [row['ano'] if exercicios[row['ano']] == 1 + else f"{row['ano']} ({row['titulo_origem']})" for row in rows] + valores = [formatar_valor_monetario(row['valor_total'].replace('.', ','))[0] + for row in rows] + dados['valores_mercado'] = formatar_valores_mercado_para_documento(anos, valores) + # Apenas as alterações explícitas da revisão substituem os metadados + # documentais. O modelo e os resultados de origem continuam no arquivo. + for campo in ('metodo_avaliacao', 'modelo_avaliacao', 'datas_referencia'): + if payload.importacao_avaliacoes and campo in payload.importacao_avaliacoes.campos_editados: + revisado = _resolver_dropdown(payload.metodo_avaliacao, payload.metodo_avaliacao_custom) if campo == 'metodo_avaliacao' else getattr(payload, campo) + if campo != 'metodo_avaliacao' or revisado not in ('Método evolutivo', 'Método comparativo direto de dados de mercado por modelo estatístico'): + dados[campo] = revisado + return dados, motivos_formatados def generate_laudo_document( payload: LaudoFormInput, registro_fotografico: UploadFile | None, + arquivo_avaliacoes: UploadFile | None = None, ) -> Path: temp_registro_path: str | None = None try: @@ -338,7 +367,16 @@ def generate_laudo_document( temp_file.write(registro_fotografico.file.read()) temp_registro_path = temp_file.name - dados, motivos_formatados = _build_generator_payload(payload, temp_registro_path) + importacao = None + if arquivo_avaliacoes and arquivo_avaliacoes.filename: + if payload.importacao_avaliacoes is None: + raise ValueError('Informe a correspondência dos anos da avaliação importada.') + arquivo_avaliacoes.file.seek(0) + parsed = parse_export(arquivo_avaliacoes.file.read(MAX_FILE_BYTES + 1), arquivo_avaliacoes.filename) + importacao = resolve_import(parsed, payload.importacao_avaliacoes) + elif payload.importacao_avaliacoes is not None: + raise ValueError('Envie o arquivo .ame ou .evo da avaliação.') + dados, motivos_formatados = _build_generator_payload(payload, temp_registro_path, importacao) output_path = LaudoGenerator().gerar(dados, motivos_formatados) if output_path is None: diff --git a/config/constants.py b/config/constants.py index f1572beafbe1652b09e17652fe7c271f3ab5f3fa..3f3bb97bce173affc555935569bd1b238c91e01e 100644 --- a/config/constants.py +++ b/config/constants.py @@ -50,6 +50,9 @@ METODOS_AVALIACAO = [ # --- Textos padrão --- +METODO_COMPARATIVO = "Método Comparativo Dados de Mercado - ABNT NBR 14.653-2" +METODO_EVOLUTIVO = METODO_COMPARATIVO + " e Método de Quantificação de Custo" + TEXTO_OBSERVACOES_COMPLEMENTARES = """- Pressupõe-se que todas as informações fornecidas por terceiros merecem credibilidade; - Considerou-se que os documentos e/ou títulos de propriedade do avaliando espelham a realidade; diff --git a/config/mesa_documentacao.json b/config/mesa_documentacao.json new file mode 100644 index 0000000000000000000000000000000000000000..87b6abd04c0a09dd8da9b89441d92ba591566aac --- /dev/null +++ b/config/mesa_documentacao.json @@ -0,0 +1,818 @@ +{ + "versao": 1, + "fontes": { + "frontend/src/mqc/data/referenceData.js": "b57ca605c1505d28d895142704b81893d369ee67fca702d7a042e9aa74310616", + "frontend/src/mqc/wordExportOptions.js": "66210e6d0d8f99b776551050890f4e7159f37eb2fa916341f6d21bff93f09e71", + "frontend/src/components/NormativeReference.jsx": "8fe15de448046093efa83ea3c1f8fc7a0f4147c8d3f95eaef52af0882338b4f5", + "frontend/src/components/RedutoresAvaliacaoModal.jsx": "40f2a918ccdf5282f13e7e25e4141e6153e2d60eb670dda8733b1572d790454c", + "frontend/src/mqc/calculations.js": "ab4cabf7243e4cf52ace67e2280545b399b465d9ed7104ed989d31ad08839e4e", + "frontend/src/modelFoundation.js": "69e8893a8506c6c7c33277a5183c8f22369318cd10e512799ee3ac2bef1c187a", + "frontend/src/globalFoundation.js": "24b630bce2a805e8506b714d627d7cdddd7aa4c3b0d34978496e3b3d52fb6972", + "frontend/src/mqc/exporters.js": "467251f8372625f54980f8424184eb250ec65c724936cad9f9567f925658cfbf" + }, + "notas": { + "sequence": "Const.: número da sequência construtiva.", + "year": "Ano (base): ano de construção.", + "cubGroup": "CUB (R$ / m²): grupo dos dados relacionados ao custo unitário básico por metro quadrado.", + "cubType": "CUB: tipo ou composição de CUB adotada.", + "cubValue": "CUB (R$): valor do CUB por metro quadrado na DataCUB; quando houver ponderação, uma anotação específica apresenta sua composição.", + "cubPercent": "% do CUB: percentual aplicado ao valor do CUB.", + "unitCost": "CUNIT: custo unitário após a aplicação do percentual do CUB.", + "bdi": "BDI (%): percentual de benefícios e despesas indiretas.", + "conservation": "Est. Cons.: estado de conservação adotado, identificado pelo respectivo código.", + "depreciation": "Depr. (K): coeficiente de depreciação calculado pelo método de Ross-Heidecke.", + "residual": "R (%): valor residual percentual.", + "kd": "kd: fator de depreciação com consideração do valor residual.", + "constructionValue": "Valor Construção (R$): valor final depreciado da sequência construtiva." + }, + "notas_area": [ + "Á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.", + "Área (m²): área da construção considerada diretamente no cálculo da sequência construtiva." + ], + "referencias": { + "bdiAdicionais": [ + { + "item": "a)", + "descricao": "projetos detalhados e especificacoes (5% a 10%)", + "perc1": 0.05, + "perc2": 0.05, + "perc3": 0.05, + "escolha": 0.05 + }, + { + "item": "b)", + "descricao": "copias, licenca da obra, agua, luz e forca (1,5% a 3%)", + "perc1": 0.03, + "perc2": 0.015, + "perc3": 0.03, + "escolha": 0.03 + }, + { + "item": "c)", + "descricao": "adm. local - mestre, apontador geral e engenheiro de obras (3% a 7%)", + "perc1": 0.05, + "perc2": 0.03, + "perc3": 0.05, + "escolha": 0.05 + }, + { + "item": "d)", + "descricao": "escritorio - pessoal, material, despesas de locacao ou amortizacao (4% a 8%)", + "perc1": 0.04, + "perc2": 0.04, + "perc3": 0.04, + "escolha": 0.04 + }, + { + "item": "e)", + "descricao": "eventuais, para atender omissoes de orcamento e elevacoes de precos nao previstos (minimo)", + "perc1": 0.05, + "perc2": 0.05, + "perc3": 0.05, + "escolha": 0.05 + }, + { + "item": "f)", + "descricao": "administracao central, lucro da firma construtora e custos financeiros, oscilando entre 10% e 25% conforme condicoes de servico da mesma e de mercado", + "perc1": null, + "perc2": 0.1, + "perc3": 0.1, + "escolha": 0.1 + } + ], + "bdiTiposConstrucao": [ + { + "tipo": "TELHEIRO SIMPLES (SEM BDI)", + "bdi": "0%" + }, + { + "tipo": "TELHEIRO MEDIO (5%)", + "bdi": "5%" + }, + { + "tipo": "TELHEIRO ALUMINIO (12%)", + "bdi": "12%" + }, + { + "tipo": "MADEIRA SIMPLES (SEM BDI)", + "bdi": "0%" + }, + { + "tipo": "MADEIRA MEDIA (5%)", + "bdi": "5%" + }, + { + "tipo": "MADEIRA SUPERIOR (10%)", + "bdi": "10%" + }, + { + "tipo": "MISTA SIMPLES (SEM BDI)", + "bdi": "0%" + }, + { + "tipo": "MISTA MEDIA (5%)", + "bdi": "5%" + }, + { + "tipo": "MISTA SUPERIOR (10%)", + "bdi": "10%" + }, + { + "tipo": "ALVENARIA PRECARIA (SEM BDI)", + "bdi": "0%" + }, + { + "tipo": "ALVENARIA POPULAR (8%)", + "bdi": "8 - 10%" + }, + { + "tipo": "ALVENARIA POPULAR (10%)", + "bdi": "8 - 10%" + }, + { + "tipo": "ALVENARIA MEDIA", + "bdi": "12 - 25%" + }, + { + "tipo": "ALVENARIA FINA", + "bdi": "25 - 32%" + }, + { + "tipo": "ALVENARIA LUXO", + "bdi": "32 - 35%" + }, + { + "tipo": "PAVILHAO ALVENARIA POPULAR", + "bdi": "5%" + }, + { + "tipo": "PAVILHAO ALVENARIA MEDIA", + "bdi": "10 - 12%" + } + ], + "estadosConservacao": [ + { + "estado": "A - novo", + "coeficiente": 0, + "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." + }, + { + "estado": "B - entre novo e regular", + "coeficiente": 0.0032, + "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." + }, + { + "estado": "C - regular", + "coeficiente": 0.0252, + "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." + }, + { + "estado": "D - entre regular e reparos simples", + "coeficiente": 0.0809, + "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." + }, + { + "estado": "E - reparos simples", + "coeficiente": 0.181, + "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." + }, + { + "estado": "F - entre reparos simples e importantes", + "coeficiente": 0.332, + "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." + }, + { + "estado": "G - reparos importantes", + "coeficiente": 0.526, + "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." + }, + { + "estado": "H - entre reparos importantes e sem valor", + "coeficiente": 0.752, + "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." + }, + { + "estado": "I - sem valor", + "coeficiente": 1 + } + ], + "vidaUtilTipos": [ + { + "tipo": "APARTAMENTOS", + "vidaUtil": 60 + }, + { + "tipo": "BANCOS", + "vidaUtil": 70 + }, + { + "tipo": "CASAS DE ALVENARIA", + "vidaUtil": 65 + }, + { + "tipo": "CASAS DE MADEIRA", + "vidaUtil": 45 + }, + { + "tipo": "HOTEIS", + "vidaUtil": 50 + }, + { + "tipo": "LOJAS", + "vidaUtil": 70 + }, + { + "tipo": "TEATROS", + "vidaUtil": 50 + }, + { + "tipo": "ARMAZENS", + "vidaUtil": 75 + }, + { + "tipo": "FABRICAS", + "vidaUtil": 50 + }, + { + "tipo": "CONST. RURAIS", + "vidaUtil": 60 + }, + { + "tipo": "GARAGENS", + "vidaUtil": 60 + }, + { + "tipo": "EDIFICIOS DE ESCRITORIOS", + "vidaUtil": 70 + }, + { + "tipo": "GALPOES (DEPOSITOS)", + "vidaUtil": 70 + }, + { + "tipo": "SILOS", + "vidaUtil": 75 + } + ], + "valorResidualReferencias": [ + { + "classe": "Residencial", + "tipo": "Barraco", + "padrao": "Rústico", + "vidaReferencialIr": 5, + "valorResidual": 0 + }, + { + "classe": "Residencial", + "tipo": "Barraco", + "padrao": "Simples", + "vidaReferencialIr": 10, + "valorResidual": 0 + }, + { + "classe": "Residencial", + "tipo": "Casa", + "padrao": "Rústico", + "vidaReferencialIr": 60, + "valorResidual": 20 + }, + { + "classe": "Residencial", + "tipo": "Casa", + "padrao": "Proletário", + "vidaReferencialIr": 60, + "valorResidual": 20 + }, + { + "classe": "Residencial", + "tipo": "Casa", + "padrao": "Econômico", + "vidaReferencialIr": 70, + "valorResidual": 20 + }, + { + "classe": "Residencial", + "tipo": "Casa", + "padrao": "Simples", + "vidaReferencialIr": 70, + "valorResidual": 20 + }, + { + "classe": "Residencial", + "tipo": "Casa", + "padrao": "Médio", + "vidaReferencialIr": 70, + "valorResidual": 20 + }, + { + "classe": "Residencial", + "tipo": "Casa", + "padrao": "Superior", + "vidaReferencialIr": 70, + "valorResidual": 20 + }, + { + "classe": "Residencial", + "tipo": "Casa", + "padrao": "Fino", + "vidaReferencialIr": 60, + "valorResidual": 20 + }, + { + "classe": "Residencial", + "tipo": "Casa", + "padrao": "Luxo", + "vidaReferencialIr": 60, + "valorResidual": 20 + }, + { + "classe": "Residencial", + "tipo": "Apartamento", + "padrao": "Econômico", + "vidaReferencialIr": 60, + "valorResidual": 20 + }, + { + "classe": "Residencial", + "tipo": "Apartamento", + "padrao": "Simples", + "vidaReferencialIr": 60, + "valorResidual": 20 + }, + { + "classe": "Residencial", + "tipo": "Apartamento", + "padrao": "Médio", + "vidaReferencialIr": 60, + "valorResidual": 20 + }, + { + "classe": "Residencial", + "tipo": "Apartamento", + "padrao": "Superior", + "vidaReferencialIr": 60, + "valorResidual": 20 + }, + { + "classe": "Residencial", + "tipo": "Apartamento", + "padrao": "Fino", + "vidaReferencialIr": 50, + "valorResidual": 20 + }, + { + "classe": "Residencial", + "tipo": "Apartamento", + "padrao": "Luxo", + "vidaReferencialIr": 50, + "valorResidual": 20 + }, + { + "classe": "Comercial", + "tipo": "Escritório", + "padrao": "Econômico", + "vidaReferencialIr": 70, + "valorResidual": 20 + }, + { + "classe": "Comercial", + "tipo": "Escritório", + "padrao": "Simples", + "vidaReferencialIr": 70, + "valorResidual": 20 + }, + { + "classe": "Comercial", + "tipo": "Escritório", + "padrao": "Médio", + "vidaReferencialIr": 60, + "valorResidual": 20 + }, + { + "classe": "Comercial", + "tipo": "Escritório", + "padrao": "Superior", + "vidaReferencialIr": 60, + "valorResidual": 20 + }, + { + "classe": "Comercial", + "tipo": "Escritório", + "padrao": "Fino", + "vidaReferencialIr": 50, + "valorResidual": 20 + }, + { + "classe": "Comercial", + "tipo": "Escritório", + "padrao": "Luxo", + "vidaReferencialIr": 50, + "valorResidual": 20 + }, + { + "classe": "Comercial", + "tipo": "Galpões", + "padrao": "Rústico", + "vidaReferencialIr": 60, + "valorResidual": 20 + }, + { + "classe": "Comercial", + "tipo": "Galpões", + "padrao": "Simples", + "vidaReferencialIr": 60, + "valorResidual": 20 + }, + { + "classe": "Comercial", + "tipo": "Galpões", + "padrao": "Médio", + "vidaReferencialIr": 80, + "valorResidual": 20 + }, + { + "classe": "Comercial", + "tipo": "Galpões", + "padrao": "Superior", + "vidaReferencialIr": 80, + "valorResidual": 20 + }, + { + "classe": "Comercial", + "tipo": "Coberturas", + "padrao": "Rústico", + "vidaReferencialIr": 20, + "valorResidual": 10 + }, + { + "classe": "Comercial", + "tipo": "Coberturas", + "padrao": "Simples", + "vidaReferencialIr": 20, + "valorResidual": 10 + }, + { + "classe": "Comercial", + "tipo": "Coberturas", + "padrao": "Superior", + "vidaReferencialIr": 30, + "valorResidual": 10 + } + ], + "areaEquivalenteCoeficientes": [ + { + "item": "a", + "descricao": "garagem (subsolo)", + "coeficiente": "0,50 a 0,75" + }, + { + "item": "b", + "descricao": "área privativa (unidade autônoma padrão)", + "coeficiente": "1,00" + }, + { + "item": "c", + "descricao": "área privativa (salas com acabamento)", + "coeficiente": "1,00" + }, + { + "item": "d", + "descricao": "área privativa (salas sem acabamento)", + "coeficiente": "0,75 a 0,90" + }, + { + "item": "e", + "descricao": "área de loja sem acabamento", + "coeficiente": "0,40 a 0,60" + }, + { + "item": "f", + "descricao": "varandas", + "coeficiente": "0,75 a 1,00" + }, + { + "item": "g", + "descricao": "terraços ou áreas descobertas sobre lajes", + "coeficiente": "0,30 a 0,60" + }, + { + "item": "h", + "descricao": "estacionamento sobre terreno", + "coeficiente": "0,05 a 0,10" + }, + { + "item": "i", + "descricao": "área de projeção do terreno sem benfeitoria", + "coeficiente": "0,00" + }, + { + "item": "j", + "descricao": "área de serviço - residência unifamiliar padrão baixo", + "coeficiente": "0,5" + }, + { + "item": "k", + "descricao": "barrilete", + "coeficiente": "0,50 a 0,75" + }, + { + "item": "l", + "descricao": "caixa d'água", + "coeficiente": "0,50 a 0,75" + }, + { + "item": "m", + "descricao": "casa de máquinas", + "coeficiente": "0,50 a 0,75" + }, + { + "item": "n", + "descricao": "piscinas", + "coeficiente": "0,50 a 0,75" + }, + { + "item": "o", + "descricao": "quintais, calçadas, jardins, etc.", + "coeficiente": "0,10 a 0,30" + } + ], + "siglasCub": [ + { + "sigla": "R1-B", + "descricao": "Residencia unifamiliar padrao baixo", + "dormitorios": 2, + "areaReal": 58.64, + "areaEquivalente": 51.94 + }, + { + "sigla": "R1-N", + "descricao": "Residencia unifamiliar padrao normal", + "dormitorios": 3, + "areaReal": 106.44, + "areaEquivalente": 99.47 + }, + { + "sigla": "R1-A", + "descricao": "Residencia unifamiliar padrao alto", + "dormitorios": 4, + "areaReal": 224.82, + "areaEquivalente": 210.44 + }, + { + "sigla": "RP1Q", + "descricao": "Residencia unifamiliar popular", + "dormitorios": 1, + "areaReal": 39.56, + "areaEquivalente": 39.56 + }, + { + "sigla": "PIS", + "descricao": "Residencia multifamiliar - projeto de interesse social", + "dormitorios": 2, + "areaReal": 991.45, + "areaEquivalente": 978.09 + }, + { + "sigla": "PP4-B", + "descricao": "Predio popular padrao baixo", + "dormitorios": 2, + "areaReal": 1415.07, + "areaEquivalente": 927.08 + }, + { + "sigla": "PP4-N", + "descricao": "Predio popular padrao normal", + "dormitorios": 3, + "areaReal": 2521.96, + "areaEquivalente": 1991.05 + }, + { + "sigla": "R8-N", + "descricao": "Residencia multifamiliar padrao normal", + "dormitorios": 3, + "areaReal": 5998.73, + "areaEquivalente": 5137.54 + }, + { + "sigla": "CAL8-N", + "descricao": "Comercial andares livres padrao normal", + "dormitorios": "-", + "areaReal": 5917.79, + "areaEquivalente": 5093.82 + }, + { + "sigla": "GI", + "descricao": "Galpao industrial", + "dormitorios": "-", + "areaReal": 1000, + "areaEquivalente": 1000 + } + ] + }, + "normas": { + "terreno": { + "title": "Fundamentação da avaliação do terreno", + "source": "ABNT NBR 14653-2:2011 - itens 9.2.1 e 9.2.1.6; Tabelas 1 e 2.", + "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.", + "columns": [ + "Critério", + "Grau III", + "Grau II", + "Grau I" + ], + "rows": [ + [ + "Caracterização", + "Completa para todas as variáveis analisadas", + "Completa para as variáveis do modelo", + "Situação paradigma" + ], + [ + "Dados de mercado", + "Ao menos 6 × (k + 1)", + "Ao menos 4 × (k + 1)", + "Ao menos 3 × (k + 1)" + ], + [ + "Identificação", + "Todos os dados e variáveis, com registros de campo", + "Todos os dados e variáveis analisados", + "Dados e variáveis efetivamente usados" + ], + [ + "Extrapolação", + "Não admitida", + "Uma variável, dentro dos limites e impacto de até 15%", + "Dentro dos limites e impacto de até 20%" + ], + [ + "Regressores", + "p-valor máximo de 10%", + "p-valor máximo de 20%", + "p-valor máximo de 30%" + ], + [ + "Teste F", + "p-valor máximo de 1%", + "p-valor máximo de 2%", + "p-valor máximo de 5%" + ] + ], + "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." + }, + "construcoes": { + "title": "Fundamentação da avaliação das construções", + "source": "ABNT NBR 14653-2:2011 - item 9.3; Tabelas 6 e 7.", + "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.", + "columns": [ + "Critério", + "Grau III", + "Grau II", + "Grau I" + ], + "rows": [ + [ + "Custo direto", + "Orçamento ao menos sintético", + "CUB de projeto semelhante ao padrão", + "CUB de projeto diferente, com ajustes" + ], + [ + "BDI", + "Calculado", + "Justificado", + "Arbitrado" + ], + [ + "Depreciação física", + "Levantamento do custo de recuperação ou bem novo/hipotético", + "Métodos técnicos, com idade, vida útil e conservação", + "Arbitrada" + ] + ], + "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." + }, + "global": { + "title": "Fundamentação global pelo método evolutivo", + "source": "ABNT NBR 14653-2:2011 - item 9.5; Tabelas 10 e 11.", + "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.", + "columns": [ + "Item", + "Grau III", + "Grau II", + "Grau I" + ], + "rows": [ + [ + "Valor do terreno", + "Grau III no método comparativo ou involutivo", + "Grau II no método comparativo ou involutivo", + "Grau I no método comparativo ou involutivo" + ], + [ + "Custos de reedição", + "Grau III no método da quantificação do custo", + "Grau II no método da quantificação do custo", + "Grau I no método da quantificação do custo" + ], + [ + "Fator de comercialização", + "Inferido em mercado semelhante", + "Justificado", + "Arbitrado" + ] + ], + "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." + } + }, + "redutores_termos": { + "VU central": "valor unitário estimado pelo modelo territorial antes de qualquer redutor ou campo de arbítrio", + "Área territorial": "área multiplicada pelo valor unitário para obter o valor territorial", + "Art. 88, inciso I — total": "soma das parcelas ponderadas válidas do inciso I; o fator é 1 menos essa redução", + "Art. 60 e 61 — aplicado": "maior redutor entre as condições selecionadas; o fator é 1 menos essa redução", + "Topografia — total": "soma dos redutores de relevo e desnível; o fator é 1 menos essa soma", + "Inundação — aplicado": "percentual da situação selecionada; o fator é 1 menos essa redução", + "Área atingida por APAN": "parcela territorial informada como APAN", + "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", + "VU após redutores territoriais": "valor unitário que chega ao campo de arbítrio", + "Fator CA final escolhido": "fator definido pelo avaliador após considerar APAN, quando existente, e os demais fundamentos técnicos", + "VU após o CA": "valor unitário territorial adotado", + "Valor territorial": "resultado do VU após o CA multiplicado pela área", + "Valor das construções": "resultado trazido da avaliação MQC selecionada", + "Imóvel composto": "soma do terreno e das construções antes dos redutores de imóvel", + "Valor de referência do Art. 88": "valor do terreno com o CA somado às construções, antes dos incisos I e II", + "Redução do inciso I": "redutor territorial ponderado pelas áreas atingidas", + "Inciso II selecionado": "soma bruta das condições do inciso II antes da verificação do limite", + "Piso legal": "15% do valor de referência; o resultado combinado dos incisos não pode ficar abaixo deste valor", + "Resultado bruto dos incisos": "resultado que seria obtido antes de aplicar o limite de 85%", + "Redução total do imóvel": "percentual efetivamente aplicado do inciso II após a verificação do limite do Art. 88", + "Fator dos redutores do imóvel": "multiplicador equivalente a 1 menos a redução total do imóvel", + "Valor após redutores do imóvel": "base que seguirá para a aplicação final do FC", + "Base antes do FC": "valor do imóvel depois dos redutores de imóvel", + "FC": "fator de comercialização aplicado como última operação", + "Valor final": "resultado final da avaliação após todas as etapas" + }, + "funcoes_mqc": { + "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}", + "calcularKd": "function calcularKd(valorResidual, depreciacao) {\n const residual = toNumber(valorResidual)\n return round(residual + (1 - toNumber(depreciacao)) * (1 - residual), 3)\n}", + "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}" + }, + "anotacoes_mqc": { + "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}", + "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}", + "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}" + }, + "criterios_fc": [ + { + "value": "mercado_semelhante", + "label": "Grau III - Inferido em mercado semelhante", + "grade": "Grau III", + "points": 3 + }, + { + "value": "justificado", + "label": "Grau II - Justificado", + "grade": "Grau II", + "points": 2 + }, + { + "value": "arbitrado", + "label": "Grau I - Arbitrado", + "grade": "Grau I", + "points": 1 + } + ], + "descricoes_modelo": { + "caracterizacao": { + "Grau III": "Caracterização completa quanto a todas as variáveis analisadas.", + "Grau II": "Caracterização completa quanto às variáveis utilizadas no modelo.", + "Grau I": "Adoção de situação paradigma.", + "Sem enquadramento": "O critério não foi atendido." + }, + "identificacaoDados": { + "Grau III": "Informações de todos os dados e variáveis analisados, com fotos e características observadas no local pelo autor do laudo.", + "Grau II": "Informações de todos os dados e variáveis analisados na modelagem.", + "Grau I": "Informações dos dados e variáveis efetivamente utilizados no modelo.", + "Sem enquadramento": "O critério não foi atendido." + } + }, + "requisitos_modelo": [ + { + "key": "laudoCompleto", + "label": "Apresentação do laudo na modalidade completa" + }, + { + "key": "analiseModelo", + "label": "Análise do modelo no laudo, com coerência do comportamento das variáveis e suas elasticidades no ponto de estimação" + }, + { + "key": "enderecosCompletos", + "label": "Identificação completa dos endereços dos dados de mercado e das fontes de informação" + }, + { + "key": "estimativaCentral", + "label": "Adoção da estimativa central" + } + ], + "identificador": "0d96271df5c3c68e" +} diff --git a/config/mesa_redutores.json b/config/mesa_redutores.json new file mode 100644 index 0000000000000000000000000000000000000000..133c3b81b52873ac0404584651b88f903d7df2fe --- /dev/null +++ b/config/mesa_redutores.json @@ -0,0 +1,192 @@ +{ + "origem": "mesa-frame/backend/app/core/redutores_territoriais.py", + "catalogo": { + "versao": "redutores_padrao_v5", + "campo_arbitrio": { + "minimo": 0.85, + "maximo": 1.15, + "padrao": 1.0 + }, + "limite_reducao_art_88": 0.85, + "art_88_i": { + "tipo": "terreno", + "limite_minimo_area": 0.1, + "condicoes": [ + { + "codigo": "app", + "rotulo": "Área de Preservação Permanente", + "redutor": 0.7 + }, + { + "codigo": "diretriz_logradouro", + "rotulo": "Diretriz para implantação de logradouro público", + "redutor": 0.35 + }, + { + "codigo": "faixa_redes", + "rotulo": "Faixa não edificável para passagens de redes", + "redutor": 0.35 + }, + { + "codigo": "alta_tensao", + "rotulo": "Redes de alta tensão", + "redutor": 0.85 + }, + { + "codigo": "ocupacoes_irregulares", + "rotulo": "Ocupações irregulares", + "redutor": 0.6 + } + ] + }, + "art_88_ii": { + "tipo": "imovel", + "redutor_por_item": 0.15, + "limite_combinado_art_88": 0.85, + "condicoes": [ + { + "codigo": "sem_agua", + "rotulo": "Sem disponibilidade do serviço público de água", + "redutor": 0.15 + }, + { + "codigo": "sem_luz", + "rotulo": "Sem disponibilidade do serviço público de luz", + "redutor": 0.15 + }, + { + "codigo": "sem_esgoto", + "rotulo": "Sem disponibilidade do serviço público de esgoto", + "redutor": 0.15 + }, + { + "codigo": "sem_pavimentacao", + "rotulo": "Logradouro público sem pavimentação", + "redutor": 0.15 + } + ] + }, + "art_60_61": { + "tipo": "terreno", + "condicoes": [ + { + "codigo": "vila", + "rotulo": "Imóvel em vila", + "redutor": 0.3 + }, + { + "codigo": "encravado", + "rotulo": "Terreno encravado", + "redutor": 0.6 + }, + { + "codigo": "triangular_ate_1000", + "rotulo": "Terreno interno triangular de até 1.000 m²", + "redutor": 0.3 + } + ] + }, + "apan": { + "tipo": "terreno", + "faixas": [ + { + "minimo": 0.0, + "maximo_exclusivo": 0.1, + "redutor": 0.0 + }, + { + "minimo": 0.1, + "maximo_exclusivo": 0.37, + "redutor": 0.05 + }, + { + "minimo": 0.37, + "maximo_exclusivo": 0.67, + "redutor": 0.1 + }, + { + "minimo": 0.67, + "maximo_exclusivo": null, + "redutor": 0.15 + } + ] + }, + "topografia": { + "tipo": "terreno", + "relevo": [ + { + "codigo": "declive_ate_5", + "rotulo": "Declive até 5%", + "redutor": 0.05 + }, + { + "codigo": "declive_5_10", + "rotulo": "Declive de 5% até 10%", + "redutor": 0.1 + }, + { + "codigo": "declive_10_20", + "rotulo": "Declive de 10% até 20%", + "redutor": 0.2 + }, + { + "codigo": "declive_acima_20", + "rotulo": "Declive acima de 20%", + "redutor": 0.3 + }, + { + "codigo": "aclive_ate_10", + "rotulo": "Aclive até 10%", + "redutor": 0.05 + }, + { + "codigo": "aclive_ate_20", + "rotulo": "Aclive até 20%", + "redutor": 0.1 + }, + { + "codigo": "aclive_acima_20", + "rotulo": "Aclive acima de 20%", + "redutor": 0.15 + } + ], + "nivel_rua": [ + { + "codigo": "abaixo_1_2_5", + "rotulo": "Abaixo do nível da rua de 1,00 m até 2,50 m", + "redutor": 0.1 + }, + { + "codigo": "abaixo_2_5_4", + "rotulo": "Abaixo do nível da rua de 2,50 m até 4,00 m", + "redutor": 0.2 + }, + { + "codigo": "acima_2_4", + "rotulo": "Acima do nível da rua de 2,00 m até 4,00 m", + "redutor": 0.1 + } + ] + }, + "agua": { + "tipo": "terreno", + "condicoes": [ + { + "codigo": "acesso_inundavel", + "rotulo": "Região inundável dificulta o acesso, sem atingir o terreno", + "redutor": 0.1 + }, + { + "codigo": "inundacao_periodica", + "rotulo": "Terreno atingido ou afetado periodicamente por inundação", + "redutor": 0.3 + }, + { + "codigo": "alagado_permanente", + "rotulo": "Terreno permanentemente alagado", + "redutor": 0.4 + } + ] + } + } +} diff --git a/docs/plano-metodo-evolutivo.md b/docs/plano-metodo-evolutivo.md new file mode 100644 index 0000000000000000000000000000000000000000..5690a9113bc42e64f1431afaaeebe2b2a9eebf3b --- /dev/null +++ b/docs/plano-metodo-evolutivo.md @@ -0,0 +1,221 @@ +# Plano da geração textual pelo método evolutivo + +Data: 20 de outubro de 2026. + +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. + +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. + +## Objetivo e princípio de organização + +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. + +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. + +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. + +## Reaproveitamento obrigatório das explicações técnicas da MESA + +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. + +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. + +Fontes já identificadas no projeto `mesa-frame`, em `/Users/guilhermesilberfarbcosta/Downloads/mesa-frame`: + +| Tema | Fonte na MESA | Conteúdo a reaproveitar | +| --- | --- | --- | +| 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. | +| 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. | +| 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. | +| Á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. | +| 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. | +| 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. | + +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. + +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. + +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. + +## O que o laudo de referência demonstra + +| Parte | Páginas | Estrutura e conteúdo observados | +| --- | --- | --- | +| 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. | +| 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. | +| 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. | +| 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. | +| Especificação | 16 | Métodos, graus de fundamentação por componente, precisão do terreno e explicações para os enquadramentos. | +| Conclusão | 16–17 | Tratamento dos motivos desvalorizantes e valores finais adotados por exercício, com representação por extenso. | +| Anexo de cálculo | 44–45 | Parâmetros e limites amostrais, intervalos de confiança, campo de arbítrio e valores territoriais. | + +Há duas distinções essenciais no exemplo: + +- 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. +- 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. + +## Estrutura proposta para o corpo do laudo + +```text +3 ANÁLISE DO VALOR DE MERCADO + 3.1 Diagnóstico de mercado + 3.2 Metodologia de avaliação + Definição do método evolutivo e dos métodos dos componentes + 3.3 Avaliação do terreno + 3.3.1 Aplicação do método comparativo + 3.3.1.1 Variáveis + 3.3.1.2 Equação e análise do modelo estatístico + 3.3.2 Cálculo do valor do terreno + 3.3.2.1 Parâmetros e valor estimado pelo modelo + 3.3.2.2 Ajustes aplicados e valor territorial adotado + Condições de incidência e justificativas do VU e da área + 3.4 Avaliação das construções + 3.4.1 Aplicação do método da quantificação de custo + 3.4.1.1 Características das construções e áreas + 3.4.1.2 CUB, referência temporal e adequações + 3.4.1.3 BDI e justificativa + 3.4.1.4 Depreciação + 3.4.1.5 Cálculo por construção e exercício + 3.5 Avaliação do imóvel objeto + Composição dos componentes, ajustes e Fc + Tabela por exercício e justificativas +4 ESPECIFICAÇÃO DA AVALIAÇÃO + Terreno, construções e enquadramento global +5 CONCLUSÃO TÉCNICA + Motivos desvalorizantes + Valores adotados por exercício e por extenso + Observações e referências temporais +``` + +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. + +## Conteúdo e tabelas de cada etapa + +### Terreno + +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. + +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. + +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. + +### Construções + +Explicar o custo de reedição e a equação, com unidades e convenção de percentuais coerentes com os dados salvos: + +```text +CUNIT = CUB × percentual de adequação em forma decimal +VC = área de cálculo × CUNIT × (1 + BDI em forma decimal) × Kd +Kd = R + (1 − K) × (1 − R) +``` + +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. + +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. + +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. + +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. + +### Composição do imóvel + +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. + +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. + +### Especificação e conclusão + +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. + +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. + +## Lacunas verificadas no código atual + +| Componente | Situação atual | Mudança planejada | +| --- | --- | --- | +| `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. | +| `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. | +| `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. | +| `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. | +| `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. | + +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. + +## Dados a preservar na importação + +| Bloco | Dados necessários | +| --- | --- | +| 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. | +| 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. | +| 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`. | +| Composição | Memória ordenada e resultados intermediários, redutores e seus componentes, Fc aplicado, resultado final salvo e fundamentação global. | +| 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. | + +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. + +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. + +## Complementos no gerador + +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. + +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. + +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. + +## Sequência de implementação + +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. +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. +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. +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. +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. +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. +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. + +## Critérios de aceitação + +- O corpo contém a demonstração territorial, construtiva e de composição por exercício, com os fundamentos técnicos correspondentes. +- 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. +- 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. +- Um caso com área da gleba e área da unidade distintas mostra ambas corretamente e conserva os parâmetros realmente aplicados. +- Não se repetem textos de metodologia para cada ano quando o método e o modelo são os mesmos. +- Há suporte a várias construções, áreas equivalentes, CUB composto, alterações por exercício e justificativas diferentes. +- BDI em percentual e multiplicador, K, R e Kd têm identificação inequívoca. +- 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. +- 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. +- Precisão territorial, fundamentação dos componentes e global não são confundidas nem presumidas. +- Cabeçalho, conclusão e referências temporais permanecem consistentes. Valores calculados e adotados podem diferir, mas a diferença está registrada. +- Avaliações distintas do mesmo exercício não são somadas ou fundidas automaticamente. +- A ausência de justificativa, modelo local ou dados históricos é visível e não produz texto técnico inventado. +- Tabelas largas e quebras de página são verificadas no documento renderizado; títulos e remissões são automáticos. + +## Inconsistências do exemplo a evitar + +O PDF servirá como referência de estrutura, não como base numérica sem conferência. Foram observadas diferenças internas: + +- 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. +- A área do modelo aparece como 70.478 na tabela do corpo e 74.478 no Anexo IV. +- 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. +- As observações finais citam dezembro de 2022 a 2024, enquanto o cabeçalho inclui dezembro de 2021 a 2024. +- Existem remissões de itens e anexos que não correspondem à numeração apresentada. + +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. + +## Revisão crítica da apresentação + +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. + +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. + +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. + +## Narrativa dos ajustes e remissões à caracterização + +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. + +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. + +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. + +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. diff --git a/document/anexos/metodologia.py b/document/anexos/metodologia.py index 5e44b4df3823f6de450bb8e8ce6f716f1fb6c796..cbbeeb7671898ea009d8a5535e2b34d29039a229 100644 --- a/document/anexos/metodologia.py +++ b/document/anexos/metodologia.py @@ -3,7 +3,6 @@ Gerador da seção de Metodologia de Avaliação. Gera as sub-seções de Aplicação, Variáveis e Equação a partir dos dados do modelo (.dai). """ import json -import re from datetime import date, datetime from typing import Optional, List, Tuple, Dict @@ -14,13 +13,16 @@ from docx.oxml import OxmlElement from docx.oxml.ns import qn from models.model_data import ModelData +from models.anos import periodo_variavel_ano from document.numbering import NumeradorSecoes from document.formatters import ( add_body_text, add_placeholder_text, add_subsubsection_title, + add_subsubsubsection_title, add_bullet_text, criar_paragrafo_formatado, + add_simple_table, ) from config.settings import BASE_DIR @@ -76,10 +78,7 @@ _TEXTO_VARIAVEIS_INTRO = ( "dos imóveis e valorização imobiliária da zona onde se localizam os mesmos." ) -_TEXTO_VARIAVEIS_LISTA = ( - "As variáveis independentes que se mostraram mais significativas, a nível " - "estatístico, para a formação do valor do imóvel em análise foram:" -) +_TEXTO_VARIAVEIS_LISTA = "As variáveis independentes utilizadas no modelo são:" # --------------------------------------------------------------------------- @@ -153,41 +152,27 @@ def _obter_periodo_amostra(modelo: ModelData) -> Tuple[str, str]: return inicio or "data inicial não informada", fim or "data final não informada" -def _extrair_anos_variaveis(variaveis: List[str]) -> Tuple[List[int], Optional[int], List[str]]: - """ - Separa variáveis de ano das demais. - - Returns: - (anos, ap_ano, outras) onde: - - anos: lista ordenada de anos (de variáveis aYYYY) - - ap_ano: ano de variável APYYYY (ou None) - - outras: variáveis que não são de ano - """ +def _extrair_anos_variaveis(variaveis: List[str]) -> Tuple[List[str], List[str]]: + """Separa dicotômicas de ano, preservando os nomes usados no modelo.""" anos = [] - ap_ano = None outras = [] - for v in variaveis: - if re.fullmatch(r"a(20\d{2}|2100)", v): - anos.append(int(v[1:])) - elif re.fullmatch(r"AP(20\d{2}|2100)", v): - ap_ano = int(v[2:]) + if periodo_variavel_ano(str(v)) is not None: + anos.append(str(v)) else: outras.append(v) + return sorted(anos, key=lambda nome: periodo_variavel_ano(nome)[0]), outras - return sorted(anos), ap_ano, outras - -def _gerar_bloco_ano(doc: Document, anos: List[int], ap_ano: Optional[int]) -> None: +def _gerar_bloco_ano(doc: Document, anos: List[str], ano_base: Optional[int]) -> None: """ Gera o bloco especial da variável ANO com título bold e bullets por ano. """ colchetes = [f"[{ano}]" for ano in anos] - if ap_ano: - colchetes.append(f"[AP{ap_ano}]") + lista = " e ".join([", ".join(colchetes[:-1]), colchetes[-1]]) if len(colchetes) > 1 else colchetes[0] # Título + descrição no mesmo parágrafo (bold + non-bold) - titulo = f"Ano {', '.join(colchetes[:-1])} e {colchetes[-1]}:" + titulo = f"Ano {lista}:" descricao = ( " variáveis do tipo dicotômicas que identificam o ano em que cada " "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 _adicionar_paragrafo_variavel(doc, titulo, descricao) # Bullets individuais por ano - for ano in anos: - add_bullet_text(doc, f"[{ano}] = 1, se o dado é de {ano};") - - if ap_ano: - add_bullet_text(doc, f"[AP{ap_ano}] = 1, se o dado é a partir de {ap_ano};") + for nome in anos: + periodo, a_partir = periodo_variavel_ano(nome) + if a_partir: + condicao = f"a partir de {periodo[0]}" + elif len(periodo) > 1: + condicao = "de " + " ou ".join(str(ano) for ano in periodo) + else: + condicao = f"de {periodo[0]}" + add_bullet_text(doc, f"[{nome}] = 1, se o dado é {condicao}; e 0, caso contrário;") # Linha final com ano base - ano_base = min(anos) - 1 - add_bullet_text( - doc, - f"{', '.join(colchetes[:-1])} e {colchetes[-1]} = 0, se o dado é de {ano_base}." - ) + if ano_base is not None: + add_bullet_text(doc, f"{lista} = 0, se o dado é de {ano_base} (ano-base do modelo).") + else: + add_placeholder_text(doc, "[Ano-base não identificado no modelo - conferir a variável de ano dos dados]") 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) -> run_desc.font.color.rgb = RGBColor(255, 0, 0) -def _adicionar_equacao_destacada(doc: Document, equacao: str) -> None: - """Apresenta a equação como um bloco centralizado e visualmente destacado.""" - p = doc.add_paragraph() - p.alignment = WD_ALIGN_PARAGRAPH.CENTER - p.paragraph_format.space_before = Pt(8) - p.paragraph_format.space_after = Pt(10) - p.paragraph_format.left_indent = Cm(0) - p.paragraph_format.right_indent = Cm(0) - - p_pr = p._p.get_or_add_pPr() - shading = OxmlElement("w:shd") - shading.set(qn("w:fill"), "F2F5F7") - p_pr.append(shading) - - bordas = OxmlElement("w:pBdr") - for lado in ("top", "left", "bottom", "right"): - borda = OxmlElement(f"w:{lado}") - borda.set(qn("w:val"), "single") - borda.set(qn("w:sz"), "6") - borda.set(qn("w:space"), "6") - borda.set(qn("w:color"), "7F8C8D") - bordas.append(borda) - p_pr.append(bordas) - - run = p.add_run(equacao) - run.bold = True - run.font.name = "Arial" - run.font.size = Pt(11) +def _adicionar_tabela_equacao(doc: Document, modelo: ModelData): + """Tabela de identificação e equação no padrão do laudo LA_107.""" + section = doc.sections[-1] + table = add_simple_table( + doc, [['Modelo', 'Nº de dados', 'Equação'], + [modelo.nome, str(modelo.n_amostras), modelo.equacao or 'Equação não informada']], + largura_colunas=[23, 14, 63], + largura_total=int(section.page_width-section.left_margin-section.right_margin), + ) + borders = OxmlElement('w:tblBorders') + for side in ('top', 'left', 'bottom', 'right', 'insideH', 'insideV'): + border = OxmlElement('w:'+side) + for key, value in [('val', 'single'), ('sz', '4'), ('color', '000000')]: + border.set(qn('w:'+key), value) + borders.append(border) + table._tbl.tblPr.append(borders) + for index, row in enumerate(table.rows): + row._tr.get_or_add_trPr().append(OxmlElement('w:cantSplit')) + for column, cell in enumerate(row.cells): + props = cell._tc.get_or_add_tcPr() + for node in list(props.findall(qn('w:shd'))) + list(props.findall(qn('w:noWrap'))): + props.remove(node) + shading = OxmlElement('w:shd') + shading.set(qn('w:fill'), 'FFFFFF' if index == 0 else 'F2F2F2') + props.append(shading) + margins = OxmlElement('w:tcMar') + for side, value in [('top', '100'), ('bottom', '100'), ('left', '80'), ('right', '80')]: + margin = OxmlElement('w:'+side) + margin.set(qn('w:w'), value) + margin.set(qn('w:type'), 'dxa') + margins.append(margin) + props.append(margins) + for paragraph in cell.paragraphs: + paragraph.paragraph_format.line_spacing = 1 + paragraph.paragraph_format.keep_with_next = index == 0 + for run in paragraph.runs: + run.font.size = Pt(9 if index == 1 and column == 0 else 10) + return table # --------------------------------------------------------------------------- @@ -272,6 +270,13 @@ def _gerar_aplicacao(doc: Document, modelo: ModelData, num: Optional[NumeradorSe numero = num.subsubsecao() if num else "" add_subsubsection_title(doc, numero, "Aplicação Método Comparativo") + _adicionar_pesquisa(doc, modelo) + _adicionar_fundamentos_comparativos(doc) + + +def _adicionar_pesquisa(doc: Document, modelo: ModelData) -> None: + """Reutiliza o texto padrão com a quantidade e o período da amostra.""" + data_inicial, data_final = _obter_periodo_amostra(modelo) texto = _TEXTO_APLICACAO_P1.format( n_amostras=modelo.n_amostras, @@ -288,24 +293,27 @@ def _gerar_aplicacao(doc: Document, modelo: ModelData, num: Optional[NumeradorSe recuo_primeira_linha=1.25, ) + +def _adicionar_fundamentos_comparativos(doc: Document) -> None: add_body_text(doc, _TEXTO_APLICACAO_P2) add_body_text(doc, _TEXTO_APLICACAO_P3) -def _gerar_variaveis(doc: Document, modelo: ModelData, num: Optional[NumeradorSecoes]) -> None: +def _gerar_variaveis(doc: Document, modelo: ModelData, num: Optional[NumeradorSecoes], nivel=3, tipo_y='unitario') -> None: """Gera sub-seção 3.2.2 - Variáveis.""" - numero = num.subsubsecao() if num else "" - add_subsubsection_title(doc, numero, "Variáveis") + numero = (num.subsubsubsecao() if nivel == 4 else num.subsubsecao()) if num else "" + (add_subsubsubsection_title if nivel == 4 else add_subsubsection_title)(doc, numero, "Variáveis") - add_body_text(doc, _TEXTO_VARIAVEIS_INTRO) + add_body_text(doc, _TEXTO_VARIAVEIS_INTRO if tipo_y == 'unitario' else + "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.") add_body_text(doc, _TEXTO_VARIAVEIS_LISTA) descricoes = _carregar_descricoes_variaveis() - anos, ap_ano, outras_variaveis = _extrair_anos_variaveis(modelo.variaveis) + anos, outras_variaveis = _extrair_anos_variaveis(modelo.variaveis) # Bloco ANO (se houver variáveis de ano) - if anos or ap_ano: - _gerar_bloco_ano(doc, anos, ap_ano) + if anos: + _gerar_bloco_ano(doc, anos, modelo.ano_base) # Demais variáveis for var in outras_variaveis: @@ -322,9 +330,9 @@ def _gerar_equacao(doc: Document, modelo: ModelData, num: Optional[NumeradorSeco add_subsubsection_title(doc, numero, "Equação Modelo Estatístico") add_body_text(doc, "O modelo de regressão que melhor se ajustou aos dados da amostra é transcrito a seguir:") + doc.paragraphs[-1].paragraph_format.keep_with_next = True - if modelo.equacao: - _adicionar_equacao_destacada(doc, modelo.equacao) + _adicionar_tabela_equacao(doc, modelo) add_body_text(doc, "Os resultados estatísticos e a planilha de cálculo são exibidos no Anexo II.") diff --git a/document/avaliacoes_importadas.py b/document/avaliacoes_importadas.py new file mode 100644 index 0000000000000000000000000000000000000000..f5e6991b9bfd4b304d2bf723d3ab388e330908f3 --- /dev/null +++ b/document/avaliacoes_importadas.py @@ -0,0 +1,104 @@ +"""Document sections based on the adopted results saved in AME/EVO.""" +from .formatters import add_body_text, add_simple_table, add_subsection_title +from .formatters.table import set_table_font_size +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP + + +def currency(value): + if value is None or value == '': + return '—' + try: + number = Decimal(str(value)).quantize(Decimal('.01'),rounding=ROUND_HALF_UP) + if not number.is_finite(): return '—' + return 'R$ ' + f'{number:,.2f}'.replace(',','#').replace('.',',').replace('#','.') + except (InvalidOperation,ValueError): + return '—' + + +def conclusion_table(doc, imported): + evo = imported['tipo'] == 'evolutivo' + header = ['IPTU', 'Objeto / Avaliação', 'Valor adotado'] + rows = [header] + for item in imported['avaliacoes']: + reference = f"IPTU {item['ano']}" + rows.append([reference, f"{item['objeto']} — {item['titulo']} [{item['id']}]", currency(item['valor_total'])]) + if evo: + from .evolutivo import table + table(doc,rows,[1.3,3.4,1.6],keep_together=len(rows)<=9,font_size=7) + else: + set_table_font_size(add_simple_table(doc, rows, header_row=True, largura_colunas=[1.4, 3.6, 2.5]),7) + from utils.formatters import numero_para_extenso + for item in imported['avaliacoes']: + extenso = numero_para_extenso(Decimal(item['valor_total'])) + paragraph = add_body_text(doc,f"{item['titulo']} [{item['id']}] — valor adotado por extenso: {extenso}.") + paragraph.paragraph_format.keep_together = True + calculated = (item.get('evolutivo') or {}).get('resultadoComposicao',{}).get('valorTotalCalculado') + different_calculated = calculated is not None and Decimal(str(calculated)).quantize(Decimal('.01')) != Decimal(item['valor_total']) + if item.get('valor_total_origem') is not None or different_calculated: + paragraph.paragraph_format.keep_with_next = True + origin = item.get('valor_total_origem',calculated) + 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 + +def calculation_sections(doc, imported): + for item in imported['avaliacoes']: + add_subsection_title(doc, '', f"{item['titulo']} — Ano-base {item['ano_base']}") + add_body_text(doc, f"Modelo: {item['modelo'] or 'Não identificado no arquivo'}. Objeto: {item['objeto']}.") + evaluation = item['avaliacao'] + if evaluation.get('precisao'): + add_body_text(doc, f"Precisão da estimativa do modelo: {evaluation['precisao']}.") + foundation = item.get('fundamentacao') or {} + if foundation.get('grade'): + add_body_text(doc, f"Fundamentação do modelo: {foundation['grade']}.") + for warning in item.get('avisos', []): + add_body_text(doc, f"Observação da avaliação: {warning}") + parameters = (item.get('evolutivo') or {}).get('terreno',{}).get('parametrosAplicados') or evaluation.get('valores_x') or {} + if parameters: + 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]) + values = [['Resultado', 'Valor']] + if item.get('area') is not None: + values.append(['Área de aplicação (m²)', str(item['area'])]) + 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')]: + if evaluation.get(key) is not None: + values.append([label, currency(evaluation[key])]) + if imported['tipo'] == 'evolutivo': + from .evo_adjustments import applied_reducers + 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', '—'))]]) + if applied_reducers(item,False): + values.append(['Valor após redutores do imóvel', currency(item.get('valor_apos_redutores_imovel'))]) + if item.get('valor_total_origem') is not None: + values.append(['Valor total registrado na avaliação de origem', currency(item['valor_total_origem'])]) + values.append(['Valor total adotado', currency(item['valor_total'])]) + add_simple_table(doc, values, header_row=True, largura_colunas=[4.5, 3]) + if imported['tipo'] == 'evolutivo' and item.get('construcoes'): + add_body_text(doc, 'Construções incorporadas à composição') + constructions = [['Construção / Tipo', 'Área (m²)', 'CUB / Ano construção', 'Valor calculado']] + for index, construction in enumerate(item['construcoes']): + constructions.append([ + str(construction.get('nome') or construction.get('tipoConstrucao') or f'Construção {index + 1}'), + str(construction.get('areaCalculada', construction.get('areaTotal', '—'))), + f"{construction.get('cubTipo', '—')} / {construction.get('anoConstrucao', '—')}", + currency(construction.get('vcalcConstSeq')), + ]) + add_simple_table(doc, constructions, header_row=True, largura_colunas=[2.5, 1.2, 2.3, 1.5]) + if imported['tipo'] == 'evolutivo': + from .evolutivo import table, fmt, source + from .evo_adjustments import visible_memory + memory = visible_memory(item) + if memory: + add_body_text(doc,'Memória das etapas efetivas na ordem de origem') + 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) + if item.get('observacoes'): + add_body_text(doc, item['observacoes']) + adjustment = item.get('ajustes') or {} + memory = adjustment.get('memoria_calculo') + if memory: + add_body_text(doc, 'Memória dos ajustes adotados') + # Preserve saved adjustment evidence in readable paragraphs instead of a raw JSON dump. + entries = memory if isinstance(memory, list) else [memory] + for entry in entries: + if isinstance(entry, dict): + text = '; '.join(f'{key}: {value}' for key, value in entry.items() if not isinstance(value, (list, dict))) + if text: + add_body_text(doc, text) + else: + add_body_text(doc, str(entry)) diff --git a/document/evo_adjustments.py b/document/evo_adjustments.py new file mode 100644 index 0000000000000000000000000000000000000000..b7a86fba90b1826fc11578a5bce8e24abb222f06 --- /dev/null +++ b/document/evo_adjustments.py @@ -0,0 +1,69 @@ +"""Select adjustments from saved effects, never from a checkbox alone.""" +from .fundamentacao import number + +REDUCERS = { + 'redutor88I': ('Art. 88, inciso I', 'Art. 88, inciso I — total'), + 'redutor6061': ('Art. 60 e 61', 'Art. 60 e 61 — aplicado'), + 'redutorTopografia': ('Topografia', 'Topografia — total'), + 'redutorAgua': ('Inundação e ação da água', 'Inundação — aplicado'), + 'redutor88II': ('Art. 88, inciso II', 'Redução total do imóvel'), +} + + +def saved_result(row): + return (row.get('evolutivo') or {}).get('resultadoComposicao') or {} + + +def saved_step(row, code): + return next((s for s in saved_result(row).get('memoria') or [] + if isinstance(s, dict) and s.get('codigo') == code), {}) + + +def step_has_effect(step): + # The applied factor takes precedence over a selected/reference percentage. + factor = number(step.get('fator')) + if factor is not None: + return factor != 1 + rate = number(step.get('percentualReducao')) + return rate != 0 if rate is not None else True # Unknown is not zero. + + +def visible_memory(row): + return [s for s in saved_result(row).get('memoria') or [] if isinstance(s, dict) + and (s.get('codigo') not in ('redutores_terreno', 'redutores_imovel') or step_has_effect(s))] + + +def reducer_rate(row, key): + values = saved_result(row) + if key in values: + return number(values[key]) + if key == 'redutor88II' and 'redutorImovel' in values: + return number(values['redutorImovel']) + code = 'redutores_imovel' if key == 'redutor88II' else 'redutores_terreno' + for component in saved_step(row, code).get('componentes') or []: + if isinstance(component,dict) and component.get('rotulo') == REDUCERS[key][0]: + return number(component.get('reducao')) + return None + + +def applied_reducers(row, territorial): + code = 'redutores_terreno' if territorial else 'redutores_imovel' + step = saved_step(row, code) + # Explicitly neutral execution wins over selected components, including caps. + if step and not step_has_effect(step): + return [] + keys = list(REDUCERS)[:4] if territorial else ['redutor88II'] + items = [(key, REDUCERS[key][0], reducer_rate(row, key)) for key in keys + if reducer_rate(row, key) is not None and reducer_rate(row, key) > 0] + if items: + return items + aggregate_key = 'redutorTerreno' if territorial else 'redutorImovel' + rate = number(saved_result(row).get(aggregate_key)) + if rate is None and step: + factor = number(step.get('fator')) + rate = 1-factor if factor is not None else number(step.get('percentualReducao')) + if rate is not None and rate <= 0: + return [] + if (rate is not None and rate > 0) or (step and step_has_effect(step)): + return [(aggregate_key, 'Redução territorial registrada' if territorial else 'Redução do imóvel registrada', rate)] + return [] diff --git a/document/evolutivo.py b/document/evolutivo.py new file mode 100644 index 0000000000000000000000000000000000000000..ced362f6ef11c60b1434b287e8355fa5f469bb81 --- /dev/null +++ b/document/evolutivo.py @@ -0,0 +1,545 @@ +"""EVO narrative: explain saved evidence in the body, with annexes as support.""" +from collections import OrderedDict +from decimal import Decimal, ROUND_HALF_UP +import json + +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml import OxmlElement +from docx.oxml.ns import qn + +from .formatters import (add_body_text, add_simple_table, add_subsection_title, + add_subsubsection_title, add_subsubsubsection_title, add_subsubsubsubsection_title) +from .avaliacoes_importadas import currency +from .mesa_content import content_for +from .evo_adjustments import REDUCERS, applied_reducers +from .fundamentacao import number, global_foundation +from .footnotes import add_footnote +from .formatters.table import set_table_font_size +from .anexos.metodologia import (_gerar_variaveis, _adicionar_pesquisa, + _adicionar_fundamentos_comparativos, _adicionar_tabela_equacao) +from models.imported_context import context_key, snapshot_model, matching_local +from models.construction_identity import construction_identity + + +def fmt(value, digits=2): + value = number(value) + if value is None: + return 'Não informado' + text = f'{value.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP):,.{digits}f}' + return text.replace(',', '#').replace('.', ',').replace('#', '.') + + +def pct(value, fraction=False): + value = number(value) + return 'Não informado' if value is None else fmt(value * (100 if fraction else 1)) + '%' + + +def ref(row, show_base=True): + value = f"IPTU {row['ano']}" + if show_base: + value += f" / base {row['ano_base']}" + return value + (f" — {row['titulo_origem']} [{row['id']}]" if row.get('referencia_duplicada') else '') + + +def source(row): + return row.get('evolutivo') or {} + + +def terrain(row): + return source(row).get('terreno') or {} + + +def result(row): + return source(row).get('resultadoComposicao') or {} + + +def complement(row, key, fallback='Não informado no arquivo; complemento técnico pendente.'): + return (row.get('complementos') or {}).get(key) or fallback + + +def table(doc, rows, widths=None, compact=False, keep_together=False, font_size=None): + section = doc.sections[-1] + if doc.paragraphs: + previous = doc.paragraphs[-1] + if previous.text.startswith(('Construção ', 'Composição das áreas', 'IPTU ', 'Memória integral', 'Memória das etapas')): + previous.paragraph_format.keep_with_next = True + obj = add_simple_table(doc, rows, largura_colunas=widths, + largura_total=int(section.page_width-section.left_margin-section.right_margin)) + for row_index,row in enumerate(obj.rows): + row._tr.get_or_add_trPr().append(OxmlElement('w:cantSplit')) + for index, cell in enumerate(row.cells): + props = cell._tc.get_or_add_tcPr() + for no_wrap in list(props.findall(qn('w:noWrap'))): props.remove(no_wrap) + margins = OxmlElement('w:tcMar') + padding = 25 if compact else 65 + for side, size in [('top',padding),('bottom',padding),('left',80),('right',80)]: + node = OxmlElement('w:'+side); node.set(qn('w:w'), str(size)); node.set(qn('w:type'),'dxa'); margins.append(node) + props.append(margins) + for para in cell.paragraphs: + para.paragraph_format.line_spacing = 1.05 + para.paragraph_format.keep_with_next = keep_together and row_index= Decimal('.9') else '.') + text = (f'O coeficiente de correlação linear do modelo é de {fmt(correlation,4)}{interpretation} ' + f'O modelo de regressão obtido explica aproximadamente {fmt(r2*100,0)}% da variabilidade do {value}, ' + 'sendo o restante da variabilidade atribuída a outras variáveis não consideradas ou a oscilações de caráter aleatório. ') + text += ('As especificações e os resultados detalhados encontram-se no Anexo I - Banco de Dados, ' + 'no Anexo II - Planilha de Cálculo e Resultados Estatísticos e no Anexo III - Gráficos.') + add_body_text(doc, text) + + + +def grouped_notes(doc, rows, getter, prefix, always_refs=False): + groups = OrderedDict() + for row in rows: + text = getter(row) + if text: groups.setdefault(str(text), []).append(ref(row)) + for text, references in groups.items(): + scope = f" ({'; '.join(references)})" if always_refs or len(references)!=len(rows) else "" + add_body_text(doc, f"{prefix}{scope}: {text}") + + +def definitions_for(doc, rows, keys): + """Different historical packages retain their own wording and applicability.""" + for key in keys: + variants = OrderedDict() + for row in rows: + text = content_for(source(row)).get('notas',{}).get(key) + if text: variants.setdefault(text,[]).append(ref(row)) + for text,references in variants.items(): + add_body_text(doc,text + (f" Aplicável a {'; '.join(references)}." if len(variants)>1 else '')) + + +def gerar_corpo_evolutivo(doc, imported, num, caracteristicas_refs=None): + rows = imported['avaliacoes'] + 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).') + 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.') + refs = {} + refs['terreno'] = heading(doc,num,'AVALIAÇÃO DO TERRENO',2) + heading(doc,num,'Aplicação Método Comparativo',3) + models = OrderedDict() + for row in rows: models.setdefault(context_key(row),[]).append(row) + groups = [(snapshot_model(group[0]), group) for group in models.values()] + for model, group in groups: + if model: + if len(groups) > 1: + add_body_text(doc, f"Pesquisa utilizada nas avaliações: {'; '.join(ref(row) for row in group)}.") + _adicionar_pesquisa(doc, model) + if any(model for model, _ in groups): + _adicionar_fundamentos_comparativos(doc) + for model, group in groups: + if model: + if len(groups) > 1: + 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)}.") + _gerar_variaveis(doc, model, num, nivel=4, tipo_y=(terrain(group[0]).get('avaliacao') or {}).get('tipo_y','unitario')) + heading(doc,num,'Equação Modelo Estatístico',4) + add_body_text(doc, 'O modelo de regressão que melhor se ajustou aos dados da amostra é transcrito a seguir:') + doc.paragraphs[-1].paragraph_format.keep_with_next = True + _adicionar_tabela_equacao(doc, model) + local, _ = matching_local(group[0]) + model_analysis(doc, local or model, (terrain(group[0]).get('avaliacao') or {}).get('tipo_y')) + else: + 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.') + heading(doc,num,'Cálculo do valor do terreno',3) + heading(doc,num,'Parâmetros e valor estimado pelo modelo',4) + keys = list(dict.fromkeys(key for row in rows for key in (terrain(row).get('parametrosAplicados') or row['avaliacao'].get('valores_x') or {}))) + 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] + compared(doc,rows,metrics + [ + ('Estimado Unitário', lambda row: currency(row['avaliacao'].get('estimado_unitario'))), + ('CA −15%', lambda row: currency(row['avaliacao'].get('ca_inf'))), + ('CA +15%', lambda row: currency(row['avaliacao'].get('ca_sup'))), + ('IC 80% Inf.', lambda row: currency(row['avaliacao'].get('ic_inf'))), + ('IC 80% Sup.', lambda row: currency(row['avaliacao'].get('ic_sup'))), + ], notes={ + '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.', + '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.', + '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.', + }, batch_size=5) + 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.') + heading(doc,num,'Ajustes aplicados e valor territorial adotado',4) + # Load after shared formatters are available to the narrative helper. + from .territorial_narrative import gerar_ajustes_territoriais, justificar_area + gerar_ajustes_territoriais(doc,rows,num,caracteristicas_refs or []) + heading(doc,num,'Síntese do valor territorial adotado',5) + 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 + metrics = [('VU antes dos ajustes (R$/m²)',lambda row:currency(terrain(row).get('valorUnitarioOriginal',row['avaliacao'].get('estimado_unitario'))))] + metrics += reducer_metrics(rows,True) + if any(applied_reducers(row,True) for row in rows): + metrics.append(('VU após redutores territoriais (R$/m²)',lambda row:currency(result(row).get('valorUnitarioAposRedutoresTerritoriais')))) + metrics += [ + ('Fator do campo de arbítrio',lambda row:fmt(result(row).get('fatorCaFinal'),4)), + ('VU territorial adotado (R$/m²)',lambda row:currency(row.get('valor_unitario'))), + ('Área utilizada no modelo (m²)',lambda row:fmt(terrain(row).get('areaModelo',row['avaliacao'].get('valor_area')))), + ('Área de aplicação do VU (m²)',lambda row:fmt(row.get('area'))), + ('Valor do terreno (R$)',lambda row:currency(row.get('valor_terreno'))), + ] + compared(doc,rows,metrics,show_base=False) + justificar_area(doc,rows) + refs['construcoes'] = heading(doc,num,'AVALIAÇÃO DAS CONSTRUÇÕES',2) + heading(doc,num,'Aplicação do método da quantificação de custo',3) + 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.') + equation(doc,'CUNIT = CUB × (% CUB / 100)') + equation(doc,'VC = área de cálculo × CUNIT × (1 + BDI / 100) × Kd') + content = content_for(source(rows[0])) + definitions_for(doc,rows,['constructionValue','unitCost']) + heading(doc,num,'Características das construções e áreas',4) + for note in content.get('notas_area',[]): add_body_text(doc,note) + groups = construction_groups(rows) + if not groups: add_body_text(doc,'Não há sequências construtivas detalhadas preservadas; o total importado permanece identificado na composição.') + construction_characteristics(doc,groups) + heading(doc,num,'CUB histórico, referência temporal e adequações',4) + definitions_for(doc,rows,['cubGroup','cubType','cubValue','cubPercent']) + construction_cub_notes(doc,groups) + heading(doc,num,'BDI e justificativa',4) + definitions_for(doc,rows,['bdi']) + 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.') + construction_bdi_notes(doc,groups) + heading(doc,num,'Depreciação e conservação',4) + definitions_for(doc,rows,['depreciation','residual','kd','conservation']) + 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.') + equation(doc,'q = percentual de vida útil / 100; Ross = q/2 + q²/2') + equation(doc,'K = 1 − (1 − Ross) × (1 − H); Kd = R + (1 − K) × (1 − R)') + 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.') + construction_depreciation_notes(doc,groups) + heading(doc,num,'Cálculo por construção e exercício',4) + for entries in groups.values(): + add_body_text(doc,construction_name(entries[0][1],entries[0][2])) + by_id = {row['id']: c for row,c,i in entries} + # One comparative calculation table per effective construction, with all + # annual inputs together; characteristics above are grouped by changes. + compared(doc,[row for row,c,i in entries],[ + ('Data do CUB',lambda row:cub_date(row)), + ('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')), + ('CUB histórico (R$/m²)',lambda row:currency(by_id[row['id']].get('valorCub'))), + ('Adequação do CUB (%)',lambda row:pct(by_id[row['id']].get('percentualCubCalculado',by_id[row['id']].get('percentualCub')))), + ('CUNIT (R$/m²)',lambda row:currency(by_id[row['id']].get('custoUnitario'))), + ('BDI (%)',lambda row:pct(by_id[row['id']].get('bdi'))), + ('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'), + ('Vida útil consumida (%)',lambda row:pct(by_id[row['id']].get('percentualVidaUtil'))), + ('Depreciação K',lambda row:fmt(by_id[row['id']].get('depreciacao'),4)), + ('Fator remanescente Kd',lambda row:fmt(by_id[row['id']].get('kd'),3)), + ('Valor da construção - VC (R$)',lambda row:currency(by_id[row['id']].get('vcalcConstSeq'))), + ]) + refs['composicao'] = heading(doc,num,'AVALIAÇÃO DO IMÓVEL OBJETO',2) + 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 + gerar_ajustes(doc,rows,territorial=False) + metrics = [ + ('Valor do terreno',lambda row:currency(row['valor_terreno'])), + ('Valor das construções',lambda row:currency(row['valor_construcao'])), + ] + if any(applied_reducers(row,False) for row in rows): + metrics += [('Base antes dos redutores do imóvel',lambda row:currency(result(row).get('valorAposConstrucao')))] + metrics += reducer_metrics(rows,False) + metrics += [('Base após redutores / antes do Fc',lambda row:currency(result(row).get('valorAposRedutoresImovel')))] + metrics += [ + ('Fc aplicado',lambda row:fmt(row.get('fator_comercializacao'),4)), + ('Valor total calculado',lambda row:currency(result(row).get('valorTotalCalculado',result(row).get('valorTotal')))), + ] + compared(doc,rows,metrics) + grouped_notes(doc,rows,lambda row:complement(row,'justificativa_fc'),'Justificativa do fator de comercialização') + grouped_notes(doc,rows,lambda row:complement(row,'ajustes_nao_aplicados',''),'Ajustes expressamente não aplicados e justificativa') + for row in rows: + for warning in row.get('revisao_documental',{}).get('conferencias',[]): + add_body_text(doc,f"Conferência — {ref(row)}: {warning}") + return refs + + +def construction_groups(rows): + groups = OrderedDict() + for row in rows: + for index,c in enumerate(row.get('construcoes') or []): + key = construction_identity(source(row),c,index) + groups.setdefault(key,[]).append((row,c,index)) + return groups + + +def construction_name(c,index): + return f"Construção {c.get('seq') or index+1}" + (f" — {c['nome']}" if c.get('nome') else '') + + +def cub_date(row): + construction = source(row).get('construcao') or {} + value = (construction.get('avaliacao') or {}).get('cubData') or construction.get('cubData') + if not value: return 'Não informada' + text = str(value) + return f'{text[5:7]}/{text[:4]}' if len(text) >= 7 and text[4] == '-' else text + + +def entry_scope(entries, groups, show_base=True): + """Compact applicability while keeping changes and duplicate years explicit.""" + selected = {(row['id'],i) for row,c,i in entries} + labels = [] + for full in groups.values(): + subset = [(row,c,i) for row,c,i in full if (row['id'],i) in selected] + if not subset: continue + label = construction_name(full[0][1],full[0][2]) + if len(subset) != len(full): label += ' (' + '; '.join(ref(row, show_base=show_base) for row,c,i in subset) + ')' + labels.append(label) + return '; '.join(labels) + + +def construction_notes(doc, notes, groups): + for text,entries in notes.items(): + add_body_text(doc,f"{text} Aplicável a {entry_scope(entries,groups)}.") + + +def construction_characteristics(doc, groups): + records = [['Construção / referência','Tipo / ano','Área real / de cálculo (m²)','Vida útil / residual R','Conservação / H']] + for entries in groups.values(): + variants = OrderedDict() + for row,c,i in entries: + values = (f"{c.get('tipoConstrucao') or 'Não informado'} / {c.get('anoConstrucao') or 'Não informado'}", + f"{fmt(c.get('areaTotal'))} / {fmt(c.get('areaCalculada'))}", + f"{fmt(c.get('vidaUtil'),0)} anos / {pct(c.get('valorResidual'),True)}", + f"{c.get('estadoConservacao') or 'Não informado'} / {fmt(c.get('coefHeidecke'),4)}") + variants.setdefault(values,[]).append((row,c,i)) + for values,subset in variants.items(): + records.append([entry_scope(subset,groups,show_base=False)]+list(values)) + if len(records)>1: table(doc,records,[1.6,1.6,1.2,1.1,1.5],font_size=7) + areas = OrderedDict() + for entries in groups.values(): + for row,c,i in entries: + details = [{key:a.get(key) for key in ('descricao','area','coeficiente')} for a in c.get('areasEquivalentes') or []] + # A single area at coefficient 1 contributes no additional explanation. + if not details or (len(details)==1 and number(details[0].get('coeficiente'))==1 + and number(c.get('areaTotal'))==number(c.get('areaCalculada'))==number(details[0].get('area'))): continue + areas.setdefault(json.dumps(details,sort_keys=True,ensure_ascii=False),[]).append((row,c,i)) + for identity,entries in areas.items(): + add_body_text(doc,'Composição das áreas — '+entry_scope(entries,groups)+'.') + table(doc,[['Tipo de área','Área real (m²)','Coeficiente','Parcela equivalente (m²)']]+[ + [a.get('descricao') or 'Não informado',fmt(a.get('area')),fmt(a.get('coeficiente'),4), + 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]) + + +def construction_cub_notes(doc, groups): + notes,compositions = OrderedDict(),OrderedDict() + for entries in groups.values(): + for row,c,i in entries: + catalog = content_for(source(row))['referencias'].get('siglasCub',[]) + short = str(c.get('cubTipo') or '').split(' (',1)[0] + found = next((item for item in catalog if item.get('sigla')==short),{}) + if found: notes.setdefault(f"{short}: {found['descricao']}.",[]).append((row,c,i)) + if not c.get('cubCompostoAtivo'): continue + composition = c.get('cubComposicaoCalculada') or c.get('cubComposicao') or [ + {'cubTipo':c.get('cubTipo'),'peso':c.get('cubPesoPrincipalCalculado'),'valorCub':c.get('valorCubPrincipal')}, + {'cubTipo':c.get('cubTipoSecundario'),'peso':c.get('cubPesoSecundarioCalculado'),'valorCub':c.get('valorCubSecundario')}] + details = [{key:item.get(key) for key in ('cubTipo','peso','valorCub')} for item in composition] + # Historical values/weights distinguish compositions, incidental IDs do not. + compositions.setdefault(json.dumps(details,sort_keys=True,ensure_ascii=False),[]).append((row,c,i)) + construction_notes(doc,notes,groups) + if compositions: + 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.') + records = [['Construção / referência','Projeto padrão','Peso','CUB histórico (R$/m²)']] + for identity,entries in compositions.items(): + for item in json.loads(identity): + 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'))]) + table(doc,records,[2.5,1.8,.8,1.4],font_size=7) + + +def construction_bdi_notes(doc, groups): + notes,compositions = OrderedDict(),OrderedDict() + for entries in groups.values(): + for row,c,i in entries: + key = str(c.get('id') or c.get('seq') or i+1) + note = (row.get('complementos') or {}).get('bdi',{}).get(key) or 'não preservada no arquivo; complemento técnico pendente.' + notes.setdefault('Justificativa do BDI: '+note,[]).append((row,c,i)) + reference = next((item for item in content_for(source(row))['referencias'].get('bdiTiposConstrucao',[]) if item.get('tipo')==c.get('tipoConstrucao')),None) + if reference: + 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)) + composition = c.get('bdiComposicaoAdotada') + if isinstance(composition,list) and composition: + details = [{key:item.get(key) for key in ('descricao','percentual')} for item in composition] + compositions.setdefault(json.dumps(details,sort_keys=True,ensure_ascii=False),[]).append((row,c,i)) + construction_notes(doc,notes,groups) + if compositions: + records = [['Construção / referência','Componente de BDI adotado','Percentual']] + for identity,entries in compositions.items(): + records.extend([[entry_scope(entries,groups,show_base=False),str(item.get('descricao') or ''),pct(item.get('percentual'))] for item in json.loads(identity)]) + table(doc,records,[2.3,3.2,1],font_size=7) + + +def construction_depreciation_notes(doc,groups): + from backend.app.evo_documentation import public_reason + notes = OrderedDict() + for entries in groups.values(): + for row,c,i in entries: + saved = ((source(row).get('construcao') or {}).get('avaliacao') or {}).get('referenciasCalculo') or content_for(source(row))['referencias'] + life = next((item for item in saved.get('vidaUtilTipos',[]) if item.get('tipo')==c.get('tipoConstrucao')),None) + if life: + 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)) + state = next((item for item in saved.get('estadosConservacao',[]) if item.get('estado')==c.get('estadoConservacao')),None) + if state and state.get('descricao'): notes.setdefault(f"{c['estadoConservacao']}: {state['descricao']}",[]).append((row,c,i)) + if c.get('percentualVidaUtilManualAtivo') or c.get('percentualVidaUtilAjusteManual'): + reason = public_reason(c,'percentualVidaUtilJustificativa') + 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.') + notes.setdefault(text,[]).append((row,c,i)) + construction_notes(doc,notes,groups) + + +def reducer_metrics(rows,territorial): + keys = OrderedDict((key,label) for row in rows for key,label,rate in applied_reducers(row,territorial)) + def display(row,key): + active = {k:rate for k,label,rate in applied_reducers(row,territorial)} + return pct(active[key],True) if key in active else '—' + return [(label+' — redução efetiva',lambda row,k=key:display(row,k)) for key,label in keys.items()] + + +def condition_description(row,territorial): + active = {key:rate for key,label,rate in applied_reducers(row,territorial)} + if not active: return '' + config = ((terrain(row).get('avaliacao') or {}).get('ajuste_territorial') or {}).get('configuracao') or {} + saved = source(row).get('catalogoAjustes') or {} + lines = [] + if 'redutor88I' in active: + catalog = saved.get('art_88_i') or {} + minimum = number(catalog.get('limite_minimo_area')) + if minimum is not None: + 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.") + for item in catalog.get('condicoes') or []: + area = number(((config.get('art_88_i') or {}).get('condicoes',{}).get(item.get('codigo')) or {}).get('percentual_area')) + rate = number(item.get('redutor',item.get('percentual_reducao'))) + if area is not None and area>minimum and rate is not None and rate>0: + 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)}.") + for key,group in [('redutor6061','art_60_61'),('redutor88II','art_88_ii')]: + if key not in active: continue + selected = (config.get(group) or {}).get('selecionados') or [] + choices = [item for item in (saved.get(group) or {}).get('condicoes',[]) if item.get('codigo') in selected] + if key=='redutor6061': + choices = [item for item in choices if number(item.get('redutor'))==active[key]] + lines.append('Arts. 60 e 61: aplica-se somente a maior redução selecionada.') + if choices: + from .territorial_narrative import legal_reference, ARTICLE_88_DECREE + if key == 'redutor88II': + lines.append('Condições de incidência: '+ '; '.join( + f"{item.get('rotulo') or item['codigo']} ({legal_reference(key, [item['codigo']])})" for item in choices)+'.') + lines.append(f'Os dispositivos referidos integram o {ARTICLE_88_DECREE}.') + else: + lines.append('Condição de incidência: '+ '; '.join(item.get('rotulo') or item['codigo'] for item in choices)+'.') + if 'redutorTopografia' in active: + for part,rate_key in [('relevo','redutorRelevo'),('nivel_rua','redutorNivel')]: + code = (config.get('topografia') or {}).get(part) + item = next((item for item in (saved.get('topografia') or {}).get(part,[]) if item.get('codigo')==code),{}) + rate = number(result(row).get(rate_key)) if rate_key in result(row) else number(item.get('redutor')) + if item and rate is not None and rate>0: lines.append(f"{item.get('rotulo') or code}: {pct(rate,True)}.") + if 'redutorAgua' in active: + code = (config.get('agua') or {}).get('situacao') + item = next((item for item in (saved.get('agua') or {}).get('condicoes',[]) if item.get('codigo')==code),{}) + if item: lines.append((item.get('rotulo') or code)+'.') + 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.') + return ' '.join(lines) + + +def gerar_ajustes(doc,rows,territorial): + active_rows = [row for row in rows if applied_reducers(row,territorial)] + descriptions = OrderedDict() + for row in active_rows: + terms = content_for(source(row)).get('redutores_termos',{}) + for key,label,rate in applied_reducers(row,territorial): + desc = terms.get(REDUCERS.get(key,('',''))[1]) + if desc: descriptions.setdefault(label+': '+desc+'.',[]).append(row) + for text,applicable in descriptions.items(): + add_body_text(doc,text + (f" Aplicável a {'; '.join(ref(row) for row in applicable)}." if len(applicable)!=len(rows) else '')) + if territorial and active_rows: + 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.') + grouped_notes(doc,rows,lambda row:condition_description(row,territorial),'Condições dos redutores aplicados') + if territorial: + apan_rows = [row for row in rows if (number(result(row).get('redutorApan')) or 0)>0 + and number(result(row).get('fatorCaFinal')) not in (None,1)] + if apan_rows: + 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.') + 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) + else: + limited = [row for row in rows if result(row).get('limiteArt88Aplicado')] + if limited: + 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.') + def cap_note(row): + catalog = source(row).get('catalogoAjustes') or {} + limit = number(catalog.get('limite_reducao_art_88',(catalog.get('art_88_ii') or {}).get('limite_combinado_art_88'))) + if limit is None or not 0 <= limit <= 1: + return 'Percentual do limite não preservado no catálogo histórico; mantidos os valores de verificação salvos.' + return f'Limite conjunto de redução: {pct(limit,True)}; piso correspondente: {pct(1-limit,True)} do valor de referência.' + grouped_notes(doc,limited,cap_note,'Limite do art. 88',always_refs=True) + compared(doc,limited,[ + ('Referência do art. 88',lambda row:currency(result(row).get('valorReferenciaArt88'))), + ('Resultado após inciso I',lambda row:currency(result(row).get('valorAposIncisoIArt88'))), + ('Resultado bruto dos incisos',lambda row:currency(result(row).get('valorAposArt88Bruto'))), + ('Resultado após limite do art. 88',lambda row:currency(result(row).get('valorAposArt88Aplicado'))), + ]) + + +def gerar_especificacao_evolutivo(doc,imported,refs): + rows = imported['avaliacoes'] + foundations = {row['id']:global_foundation(source(row)) for row in rows} + if doc.paragraphs: doc.paragraphs[-1].paragraph_format.keep_with_next = True + table(doc,[['IPTU','Terreno','Construções','Global','Precisão / amplitude do terreno']]+[ + [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) + 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.") + content = content_for(source(rows[0])) + for section,key in [('terreno','terrain'),('construcoes','construction'),('global',None)]: + norm = content['normas'][section] + add_subsubsection_title(doc,'',norm['title']).paragraph_format.keep_with_next = True + add_body_text(doc,norm['source'] + ' ' + norm['framing']).paragraph_format.keep_with_next = True + first = foundations[rows[0]['id']][key] if key else foundations[rows[0]['id']] + 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'])]) + 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') + content_versions = OrderedDict() + for row in rows: + f = foundations[row['id']]['terrain'] + 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.") + catalog = content_for(source(row)) + saved = source(row).get('documentacaoTecnica') + 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.') + content_versions.setdefault(note,[]).append(ref(row)) + for note,references in content_versions.items(): + paragraph = add_body_text(doc,note + (f" Aplicável a {', '.join(references)}." if len(content_versions)>1 else '')) + paragraph.paragraph_format.keep_together = True diff --git a/document/footnotes.py b/document/footnotes.py new file mode 100644 index 0000000000000000000000000000000000000000..6fc3ffc5ab6e4af68b7a62cbe18d0880a5c68926 --- /dev/null +++ b/document/footnotes.py @@ -0,0 +1,49 @@ +"""Notas de rodapé nativas, inclusive quando o documento já contém notas.""" +from lxml import etree +from docx.oxml import OxmlElement, parse_xml +from docx.oxml.ns import qn +from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT +from docx.opc.packuri import PackURI +from docx.opc.part import Part, XmlPart +from docx.shared import Pt + + +def add_footnote(doc, paragraph, text): + try: + part = doc.part.part_related_by(RT.FOOTNOTES) + root = parse_xml(part.blob) + except KeyError: + root = OxmlElement('w:footnotes') + for identifier, kind in [(-1, 'separator'), (0, 'continuationSeparator')]: + note = OxmlElement('w:footnote') + note.set(qn('w:id'), str(identifier)); note.set(qn('w:type'), kind) + p = OxmlElement('w:p'); r = OxmlElement('w:r') + r.append(OxmlElement('w:'+kind)); p.append(r); note.append(p); root.append(note) + part = Part(PackURI('/word/footnotes.xml'), CT.WML_FOOTNOTES, b'', doc.part.package) + doc.part.relate_to(part, RT.FOOTNOTES) + identifier = max([0] + [int(n.get(qn('w:id'))) for n in root]) + 1 + note = OxmlElement('w:footnote'); note.set(qn('w:id'), str(identifier)) + p = OxmlElement('w:p'); props = OxmlElement('w:pPr') + spacing = OxmlElement('w:spacing'); spacing.set(qn('w:after'), '60') + props.append(spacing); p.append(props) + for marker, value in [('footnoteRef', None), ('t', ' '+text)]: + r = OxmlElement('w:r'); rp = OxmlElement('w:rPr') + font = OxmlElement('w:rFonts') + font.set(qn('w:ascii'), 'Arial'); font.set(qn('w:hAnsi'), 'Arial'); rp.append(font) + size = OxmlElement('w:sz'); size.set(qn('w:val'), '14'); rp.append(size) + if value is None: + vertical = OxmlElement('w:vertAlign'); vertical.set(qn('w:val'), 'superscript'); rp.append(vertical) + r.append(rp); element = OxmlElement('w:'+marker) + if value is not None: + element.text = value; element.set(qn('xml:space'), 'preserve') + r.append(element); p.append(r) + note.append(p); root.append(note) + if isinstance(part, XmlPart): + part._element = root + else: + part._blob = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True) + run = paragraph.add_run(); run.font.superscript = True + run.font.size = Pt(7) + reference = OxmlElement('w:footnoteReference'); reference.set(qn('w:id'), str(identifier)) + run._r.append(reference) + return identifier diff --git a/document/formatters/__init__.py b/document/formatters/__init__.py index 8f67a5a08df3e58e777a0d026635fd6eeaaafc5c..faf9a9d49bb1ef6b38c8713896cbe46269b61465 100644 --- a/document/formatters/__init__.py +++ b/document/formatters/__init__.py @@ -14,6 +14,7 @@ from .heading import ( add_subsection_title, add_subsubsection_title, add_subsubsubsection_title, + add_subsubsubsubsection_title, ) from .table import ( set_cell_shading, @@ -45,6 +46,7 @@ __all__ = [ 'add_subsection_title', 'add_subsubsection_title', 'add_subsubsubsection_title', + 'add_subsubsubsubsection_title', # Tabelas 'set_cell_shading', 'formatar_celula_tabela', diff --git a/document/formatters/heading.py b/document/formatters/heading.py index aa65148950c397295484dc43fa8f8df114dfb448..ffe843422371b066662afe3a5c4b7b2dce86c54c 100644 --- a/document/formatters/heading.py +++ b/document/formatters/heading.py @@ -7,6 +7,12 @@ from docx.enum.text import WD_ALIGN_PARAGRAPH from .paragraph import criar_paragrafo_formatado +def _heading_paragraph(*args, **kwargs): + paragraph = criar_paragrafo_formatado(*args, **kwargs) + paragraph.paragraph_format.keep_with_next = True + return paragraph + + def add_heading_custom(doc: Document, text: str, level: int = 1): """Adiciona título customizado.""" configs = { @@ -15,7 +21,7 @@ def add_heading_custom(doc: Document, text: str, level: int = 1): } cfg = configs.get(level, {'tamanho': 11, 'antes': 6, 'depois': 6}) - return criar_paragrafo_formatado( + return _heading_paragraph( doc, text, negrito=True, sublinhado=True, tamanho=cfg['tamanho'], espaco_antes=cfg['antes'], espaco_depois=cfg['depois'], alinhamento=WD_ALIGN_PARAGRAPH.LEFT @@ -24,7 +30,7 @@ def add_heading_custom(doc: Document, text: str, level: int = 1): def add_section_title(doc: Document, number: str, text: str): """Adiciona título de seção principal (ex: 1. SOLICITAÇÃO).""" - return criar_paragrafo_formatado( + return _heading_paragraph( doc, f"{number}. {text}", negrito=True, tamanho=12, espaco_antes=18, espaco_depois=12, alinhamento=WD_ALIGN_PARAGRAPH.LEFT ) @@ -32,7 +38,7 @@ def add_section_title(doc: Document, number: str, text: str): def add_subsection_title(doc: Document, number: str, text: str): """Adiciona título de subseção (ex: 1.1 Considerações).""" - return criar_paragrafo_formatado( + return _heading_paragraph( doc, f"{number} {text}", negrito=True, tamanho=11, espaco_antes=12, espaco_depois=6, recuo_esq=0.3, alinhamento=WD_ALIGN_PARAGRAPH.LEFT @@ -41,7 +47,7 @@ def add_subsection_title(doc: Document, number: str, text: str): def add_subsubsection_title(doc: Document, number: str, text: str): """Adiciona título de sub-subseção (ex: 3.2.1).""" - return criar_paragrafo_formatado( + return _heading_paragraph( doc, f"{number} {text}", negrito=True, tamanho=11, espaco_antes=10, espaco_depois=4, recuo_esq=0.5, alinhamento=WD_ALIGN_PARAGRAPH.LEFT @@ -50,7 +56,16 @@ def add_subsubsection_title(doc: Document, number: str, text: str): def add_subsubsubsection_title(doc: Document, number: str, text: str): """Adiciona título de sub-sub-subseção (ex: 3.2.1.1).""" - return criar_paragrafo_formatado( + return _heading_paragraph( + doc, f"{number} {text}", negrito=True, tamanho=10, + espaco_antes=8, espaco_depois=4, recuo_esq=0.7, + alinhamento=WD_ALIGN_PARAGRAPH.LEFT + ) + + +def add_subsubsubsubsection_title(doc: Document, number: str, text: str): + """Título de quinto nível sem reduzir a legibilidade do texto.""" + return _heading_paragraph( doc, f"{number} {text}", negrito=True, tamanho=10, espaco_antes=8, espaco_depois=4, recuo_esq=0.7, alinhamento=WD_ALIGN_PARAGRAPH.LEFT diff --git a/document/formatters/table.py b/document/formatters/table.py index f167598fc14d0b4ba1af72ea887d43443af33a0b..f541fc2ac819bf91a5363c98861e6bc8b8e60d8d 100644 --- a/document/formatters/table.py +++ b/document/formatters/table.py @@ -15,6 +15,15 @@ from .paragraph import aplicar_cor_run WD_ALIGN_VERTICAL = WD_CELL_VERTICAL_ALIGNMENT +def set_table_font_size(table, size): + """Apply a uniform size to every run, including headers and note markers.""" + for row in table.rows: + for cell in row.cells: + for paragraph in cell.paragraphs: + for run in paragraph.runs: + run.font.size = Pt(size) + + def set_cell_shading(cell, color: str) -> None: """ Define a cor de fundo de uma célula. diff --git a/document/fundamentacao.py b/document/fundamentacao.py new file mode 100644 index 0000000000000000000000000000000000000000..9314db6a6e281e892e3c5a2017f1fc6d320bd51f --- /dev/null +++ b/document/fundamentacao.py @@ -0,0 +1,136 @@ +"""Documentary framing equivalent to MESA modelFoundation/globalFoundation. + +No valuation is recalculated. Missing evidence remains pending. Equivalence is +checked against MESA's JavaScript, including the per-item minima. +""" +from decimal import Decimal, InvalidOperation +from .mesa_content import content_for + +POINTS = {'Grau III': 3, 'Grau II': 2, 'Grau I': 1, 'Sem enquadramento': 0} + + +def number(value): + try: + if value is None or value == '' or isinstance(value, bool): + return None + text = str(value) + if ',' in text: + text = text.replace('.', '').replace(',', '.') + result = Decimal(text) + return result if result.is_finite() else None + except (InvalidOperation, ValueError): + return None + + +def grade(points): + return {3: 'Grau III', 2: 'Grau II', 1: 'Grau I', 0: 'Sem enquadramento'}.get(points, 'Pendente') + + +def threshold(value, limits): + value = number(value) + if value is None: + return None + return next((3-i for i, limit in enumerate(limits) if value <= Decimal(str(limit))), 0) + + +def model_foundation(source): + terrain = source.get('terreno') or {} + state = terrain.get('fundamentacaoModelo') or {} + context = terrain.get('contextoModelo') or {} + evidence = context.get('normativeEvidence') or {} + evaluation = terrain.get('avaliacao') or {} + content = content_for(source) + items = [] + def add(key, label, points, detail=''): + items.append({'key': key, 'label': label, 'points': points, 'grade': grade(points), 'detail': detail}) + for key, label in [('caracterizacao', 'Caracterização do imóvel avaliando'), ('identificacaoDados', 'Identificação dos dados de mercado')]: + saved = state.get(key) or {} + selected = saved.get('grau') + desc = content.get('descricoes_modelo', {}).get(key, {}).get(selected, '') + detail = ' '.join(filter(None, [desc, saved.get('justificativa')])) + add(key, label, POINTS.get(selected), detail or 'Enquadramento não informado pelo avaliador.') + n, k = number(evidence.get('n')), number(evidence.get('k')) + 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 + 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.') + extrap = POINTS.get(evaluation.get('fundamentacao')) + if extrap is None and 'qtd_extrapolacoes' in evaluation: + count = max(Decimal(0),number(evaluation['qtd_extrapolacoes']) or Decimal(0)) + impact = number(evaluation.get('perc_ext')) or Decimal(0) + severe = any(isinstance(item, dict) and item.get('status') == 'grave' for item in (evaluation.get('extrapolacoes') or {}).values()) + 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 + count_text = evaluation.get('qtd_extrapolacoes') + impact_text = evaluation.get('perc_ext') + 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 '') + add('extrapolacao', 'Extrapolação', extrap, detail) + pvalues = [(item.get('name', ''), number(item.get('pValue'))) for item in evidence.get('regressorPValues', []) if isinstance(item, dict)] + worst = max(((name, p) for name, p in pvalues if p is not None), key=lambda item: item[1], default=('', None)) + 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.') + add('significanciaModelo', 'Teste F', threshold(evidence.get('fPValue'), [.01,.02,.05]), f"p-valor: {evidence.get('fPValue', 'não informado')}.") + by_key = {item['key']: item['points'] for item in items} + p = [by_key[key] for key in ['caracterizacao','quantidadeDados','identificacaoDados','extrapolacao','significanciaRegressores','significanciaModelo']] + total = sum(value for value in p if value is not None) + result = 'Pendente' + requirements = state.get('requisitosGrauIII') or {} + req_keys = [item['key'] for item in content.get('requisitos_modelo', [])] + 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])) + missing_req = candidate and any(requirements.get(key) not in ('sim','nao',True,False) for key in req_keys) + if all(value is not None for value in p) and not missing_req: + if candidate and all(requirements.get(key) in ('sim',True) for key in req_keys): result = 'Grau III' + elif total >= 10 and all(value >= minimum for value, minimum in zip(p,[1,2,1,2,2,2])): result = 'Grau II' + elif all(value >= 1 for value in p): result = 'Grau I' + else: result = 'Sem enquadramento' + return {'items': items, 'totalPoints': total, 'grade': result, 'requirements': requirements, 'missingRequirements': bool(missing_req)} + + +def construction_foundation(source): + construction = source.get('construcao') or {} + saved = construction.get('fundamentacaoMqc') or (construction.get('avaliacao') or {}).get('fundamentacaoMqc') or {} + defs = saved.get('definitions') or {} + # Labels are extracted from the very function that assigns the MESA scores. + import re + rule = content_for(source)['funcoes_mqc']['calcularEnquadramento'] + options = re.findall(r"definitions\.(\w+) === '([^']+)' \? (\d)", rule) + items = [] + for key, label in [('formaCub','Custo direto'),('tipoBdi','BDI'),('depreciacao','Depreciação física')]: + value = defs.get(key) + points = next((int(score) for field, text, score in options if field == key and value == text), 1 if value else None) + items.append({'label':label,'points':points,'grade':grade(points),'detail':value or 'Critério não informado.'}) + p = [item['points'] for item in items] + total = sum(value for value in p if value is not None) + result = 'Pendente' + if all(value is not None for value in p): + if total >= 7 and p[0] == 3 and min(p[1:]) >= 2: result = 'Grau III' + elif total >= 5 and p[0] >= 2 and p[1] >= 2: result = 'Grau II' + elif total >= 3: result = 'Grau I' + else: result = 'Fora dos critérios' + return {'items':items,'totalPoints':total,'grade':result} + + +def global_foundation(source): + terrain = model_foundation(source) + construction = construction_foundation(source) + result = source.get('resultadoComposicao') or {} + vt = number((source.get('terreno') or {}).get('valorTotal')) + vc = number((source.get('construcao') or {}).get('valorTotal')) + # calculate(year).beforeFc in MESA is the sum of these two saved components. + total_value = vt+vc if vt is not None and vc is not None else None + items = [] + for label, value, foundation in [('Terreno',vt,terrain),('Construções',vc,construction)]: + share = value/total_value if value is not None and total_value is not None and total_value > 0 else None + reduced = value is not None and value > 0 and share is not None and share < Decimal('.15') + points = 2 if reduced else POINTS.get(foundation['grade']) + # Global MESA only recognises the three positive grades. + if not reduced and points == 0: points = None + 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}) + fc = str((source.get('fundamentacaoGlobal') or {}).get('fatorComercializacao') or '') + option = next((item for item in content_for(source)['criterios_fc'] if item['value'] == fc), {}) + 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.')}) + p = [item['points'] for item in items] + total = sum(value for value in p if value is not None) + result_grade = 'Pendente' + if all(value is not None for value in p): + if total >= 8 and p[0] == p[1] == 3 and p[2] >= 2: result_grade = 'Grau III' + elif total >= 5 and p[0] >= 2 and p[1] >= 2: result_grade = 'Grau II' + elif total >= 3 and min(p) >= 1: result_grade = 'Grau I' + else: result_grade = 'Sem enquadramento' + return {'items':items,'totalPoints':total,'grade':result_grade,'terrain':terrain,'construction':construction} diff --git a/document/generator.py b/document/generator.py index 7dcec4c9131286f17492d07295b727031b094a61..6ccd15830befd064812cf3e5d82a05ba51641ea5 100644 --- a/document/generator.py +++ b/document/generator.py @@ -11,7 +11,11 @@ from docx import Document from docx.shared import Pt, Inches, Cm from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.enum.table import WD_TABLE_ALIGNMENT +from docx.oxml import OxmlElement +from .avaliacoes_importadas import conclusion_table, calculation_sections +from .evolutivo import gerar_corpo_evolutivo, gerar_especificacao_evolutivo +from models.imported_context import context_key, matching_local from .numbering import NumeradorSecoes from .formatters import ( criar_paragrafo_formatado, @@ -29,7 +33,7 @@ from .formatters import ( ) from config.settings import TEMPLATES_DIR, TEXTOS_DIR, OUTPUT_DIR -from models import get_model +from models import get_model, list_available_models from utils.docx_loader import ler_texto_docx, mesclar_documento_fotos from .anexos import ( anexo_banco_dados_cabe_na_largura, @@ -151,12 +155,16 @@ class LaudoGenerator: cell = row.cells[0] cell.merge(row.cells[1]) criar_celula_cabecalho_tabela(cell, text) + for paragraph in cell.paragraphs: + paragraph.paragraph_format.keep_with_next = True def add_row(label, value): row = table.add_row() configurar_linha_tabela_altura(row, 0.6) criar_celula_dados_tabela(row.cells[0], label, is_label=True) criar_celula_dados_tabela(row.cells[1], str(value) if value else "") + if label in ('Valores de Mercado:', 'Referências:'): + row._tr.get_or_add_trPr().append(OxmlElement('w:cantSplit')) # Definição das seções secoes = [ @@ -228,15 +236,19 @@ class LaudoGenerator: add_subsection_title(doc, num.subsecao(), "CARACTERÍSTICAS PARTICULARMENTE DESVALORIZANTES") + caracteristicas_refs = [] secoes_motivos = motivos_formatados.get('secoes', []) if not secoes_motivos: add_placeholder_text(doc) else: for secao in secoes_motivos: - add_subsubsection_title(doc, num.subsubsecao(), secao['titulo']) + numero_caracteristica = num.subsubsecao() + add_subsubsection_title(doc, numero_caracteristica, secao['titulo']) + caracteristicas_refs.append({**secao, 'referencia':numero_caracteristica}) p = doc.add_paragraph() p.add_run(f"Status: {secao['status']}").italic = True p.paragraph_format.left_indent = Inches(0.5) + p.paragraph_format.keep_with_next = True add_placeholder_text(doc) # 3. ANÁLISE DE MERCADO @@ -245,18 +257,34 @@ class LaudoGenerator: add_placeholder_text(doc, "[Inserir diagnóstico de mercado]") add_subsection_title(doc, num.subsecao(), "METODOLOGIA DE AVALIAÇÃO") - # Tentar gerar metodologia a partir do modelo - modelo_nome = dados.get('modelo_avaliacao', '') - modelo = get_model(modelo_nome) if modelo_nome else None - if modelo: - gerar_anexo_metodologia(doc, modelo, num=num) + imported = dados.get('avaliacoes_importadas') + evo_refs = None + if imported and imported['tipo'] == 'evolutivo': + evo_refs = gerar_corpo_evolutivo(doc, imported, num, caracteristicas_refs) + elif imported: + add_body_text(doc, dados['metodo_avaliacao']) + for name in dict.fromkeys(item['modelo'] for item in imported['avaliacoes'] if item['modelo']): + modelo = get_model(name) if name in list_available_models() else None + if modelo: + add_body_text(doc, f"Modelo {name}") + gerar_anexo_metodologia(doc, modelo, num=num) + else: + 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.") else: - add_placeholder_text(doc, "[Inserir metodologia - selecione um modelo]") + modelo_nome = dados.get('modelo_avaliacao', '') + modelo = get_model(modelo_nome) if modelo_nome else None + if modelo: + gerar_anexo_metodologia(doc, modelo, num=num) + else: + add_placeholder_text(doc, "[Inserir metodologia - selecione um modelo]") # 4. ESPECIFICAÇÃO add_section_title(doc, num.secao(), "ESPECIFICAÇÃO DA AVALIAÇÃO") add_body_text(doc, f"Método: {dados.get('metodo_avaliacao', '')}") - add_placeholder_text(doc) + if evo_refs: + gerar_especificacao_evolutivo(doc, imported, evo_refs) + else: + add_placeholder_text(doc) # 5. CONCLUSÃO add_section_title(doc, num.secao(), "CONCLUSÃO TÉCNICA") @@ -275,19 +303,27 @@ class LaudoGenerator: add_subsection_title(doc, num.subsecao(), "SOBRE O VALOR DE MERCADO") valores_mercado_lista = dados.get('valores_mercado_lista', []) - if valores_mercado_lista: + if dados.get('avaliacoes_importadas'): + conclusion_table(doc, dados['avaliacoes_importadas']) + elif valores_mercado_lista: self._gerar_tabela_valores_mercado(doc, valores_mercado_lista) else: add_placeholder_text(doc) add_subsection_title(doc, num.subsecao(), "OBSERVAÇÕES COMPLEMENTARES") - add_placeholder_text(doc, "[Inserir observações]") + if evo_refs: + for row in imported['avaliacoes']: + if row.get('observacoes'): + add_body_text(doc, f"{row['titulo']}: {row['observacoes']}") + 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.") + else: + add_placeholder_text(doc, "[Inserir observações]") # 6. ANEXOS (lista) add_section_title(doc, num.secao(), "ANEXOS") for anexo in ["I. BANCO DE DADOS", "II. PLANILHA DE CÁLCULO", "III. GRÁFICOS", "IV. CÁLCULO DO VALOR", "V. DADOS DO IMÓVEL", "VI. REGISTRO FOTOGRÁFICO"]: - add_body_text(doc, anexo) + add_body_text(doc, anexo).paragraph_format.keep_with_next = not anexo.startswith("VI.") def _gerar_tabela_valores_mercado(self, doc: Document, valores_lista: List[Dict]) -> None: """Gera uma tabela arejada de valores e lista os valores por extenso abaixo.""" @@ -408,38 +444,84 @@ class LaudoGenerator: section = doc.sections[-1] return int(section.page_width - section.left_margin - section.right_margin) + def _gerar_anexos_importados(self, doc, imported): + available_names = set(list_available_models()) + if imported['tipo'] == 'evolutivo': + distinct = {context_key(item): item for item in imported['avaliacoes']} + models = [(f'{name} — contexto {fingerprint}', matching_local(item)[0]) for (name,fingerprint),item in distinct.items()] + else: + models = [(name, get_model(name) if name in available_names else None) for name in dict.fromkeys( + item['modelo'] for item in imported['avaliacoes'] if item['modelo'])] + available = [(name, model) for name, model in models if model is not None] + if not available: + doc.add_page_break() + for title, builder in [ + ('ANEXO I – BANCO DE DADOS', gerar_anexo_banco_dados), + ('ANEXO II – PLANILHA DE CÁLCULO E RESULTADOS ESTATÍSTICOS', gerar_anexo_planilha), + ('ANEXO III – GRÁFICOS', gerar_anexo_graficos), + ]: + if available: + landscape = builder == gerar_anexo_banco_dados and any( + not anexo_banco_dados_cabe_na_largura(model, None, self._largura_util_secao_atual(doc)) + for _, model in available) + if landscape: + iniciar_secao_paisagem(doc) + else: + iniciar_secao_retrato(doc) + self._criar_titulo_anexo(doc, title) + if imported['tipo'] == 'evolutivo': + for item in distinct.values(): + local, warning = matching_local(item) + if warning: + add_body_text(doc, f"{item['modelo']}: {warning} Os parâmetros e resultados originais permanecem no corpo e no Anexo IV.") + if not available: + 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.') + for name, model in available: + add_subsection_title(doc, '', name) + builder(doc, model) + if available: + iniciar_secao_retrato(doc) + else: + doc.add_page_break() + self._criar_titulo_anexo(doc, 'ANEXO IV – CÁLCULO DO VALOR POR ANO') + calculation_sections(doc, imported) + def _gerar_anexos(self, doc: Document, dados: Dict) -> None: """Gera as páginas de anexos a partir dos dados do modelo.""" - # Tentar carregar o modelo selecionado - modelo_nome = dados.get('modelo_avaliacao', '') - modelo = get_model(modelo_nome) if modelo_nome else None - banco_dados_colunas = dados.get('banco_dados_colunas') or None - - # ANEXO I - BANCO DE DADOS. Usa paisagem apenas quando as colunas - # selecionadas não cabem com legibilidade em retrato. - if not anexo_banco_dados_cabe_na_largura( - modelo, - banco_dados_colunas, - self._largura_util_secao_atual(doc), - ): - iniciar_secao_paisagem(doc) - self._criar_titulo_anexo(doc, "ANEXO I – BANCO DE DADOS") - gerar_anexo_banco_dados(doc, modelo, colunas=banco_dados_colunas) - - # ANEXO II - PLANILHA DE CÁLCULO (volta para retrato) - iniciar_secao_retrato(doc) - self._criar_titulo_anexo(doc, "ANEXO II – PLANILHA DE CÁLCULO E RESULTADOS ESTATÍSTICOS") - gerar_anexo_planilha(doc, modelo) - - # ANEXO III - GRÁFICOS - doc.add_page_break() - self._criar_titulo_anexo(doc, "ANEXO III – GRÁFICOS") - gerar_anexo_graficos(doc, modelo) + imported = dados.get('avaliacoes_importadas') + if imported: + self._gerar_anexos_importados(doc, imported) + else: + # Tentar carregar o modelo selecionado + modelo_nome = dados.get('modelo_avaliacao', '') + modelo = get_model(modelo_nome) if modelo_nome else None + banco_dados_colunas = dados.get('banco_dados_colunas') or None + + # ANEXO I - BANCO DE DADOS. Usa paisagem apenas quando as colunas + # selecionadas não cabem com legibilidade em retrato. + if not anexo_banco_dados_cabe_na_largura( + modelo, + banco_dados_colunas, + self._largura_util_secao_atual(doc), + ): + iniciar_secao_paisagem(doc) + self._criar_titulo_anexo(doc, "ANEXO I – BANCO DE DADOS") + gerar_anexo_banco_dados(doc, modelo, colunas=banco_dados_colunas) + + # ANEXO II - PLANILHA DE CÁLCULO (volta para retrato) + iniciar_secao_retrato(doc) + self._criar_titulo_anexo(doc, "ANEXO II – PLANILHA DE CÁLCULO E RESULTADOS ESTATÍSTICOS") + gerar_anexo_planilha(doc, modelo) + + # ANEXO III - GRÁFICOS + doc.add_page_break() + self._criar_titulo_anexo(doc, "ANEXO III – GRÁFICOS") + gerar_anexo_graficos(doc, modelo) - # ANEXO IV - CÁLCULO DO VALOR - doc.add_page_break() - self._criar_titulo_anexo(doc, "ANEXO IV – CÁLCULO DO VALOR") - gerar_anexo_calculo(doc, modelo) + # ANEXO IV - CÁLCULO DO VALOR + doc.add_page_break() + self._criar_titulo_anexo(doc, "ANEXO IV – CÁLCULO DO VALOR") + gerar_anexo_calculo(doc, modelo) # ANEXO V - DADOS E LOCALIZAÇÃO (placeholder manual) doc.add_page_break() diff --git a/document/mesa_content.py b/document/mesa_content.py new file mode 100644 index 0000000000000000000000000000000000000000..8bdae1636052ee9c772e8c956509f3b091037949 --- /dev/null +++ b/document/mesa_content.py @@ -0,0 +1,16 @@ +"""Versioned documentary content extracted from MESA, never manually duplicated.""" +import json +from functools import lru_cache +from config.settings import BASE_DIR + + +@lru_cache(maxsize=1) +def current_content(): + return json.loads((BASE_DIR / 'config/mesa_documentacao.json').read_text()) + + +def content_for(source): + saved = source.get('documentacaoTecnica') or {} + keys = ('notas','referencias','normas','redutores_termos','funcoes_mqc','criterios_fc','descricoes_modelo','requisitos_modelo') + valid = isinstance(saved,dict) and saved.get('versao') == 1 and saved.get('identificador') and all(key in saved for key in keys) + return saved if valid else current_content() diff --git a/document/numbering.py b/document/numbering.py index 4a32104c6d36f7905e545281eba379d15f1cae0e..4c5ff1898eb877b880a5834d159e51e27ea84b7a 100644 --- a/document/numbering.py +++ b/document/numbering.py @@ -21,7 +21,7 @@ class NumeradorSecoes: Args: secao_inicial: Número inicial para seções (0 = começa em 1) """ - self.contadores = [secao_inicial, 0, 0, 0] + self.contadores = [secao_inicial, 0, 0, 0, 0] def _reset_niveis_abaixo(self, nivel: int) -> None: """Reseta contadores de níveis abaixo do especificado.""" @@ -49,8 +49,14 @@ class NumeradorSecoes: def subsubsubsecao(self) -> str: """Incrementa e retorna número da sub-sub-subseção (ex: '1.1.1.1').""" self.contadores[3] += 1 + self._reset_niveis_abaixo(3) return f"{self.contadores[0]}.{self.contadores[1]}.{self.contadores[2]}.{self.contadores[3]}" + def subsubsubsubsecao(self) -> str: + """Incrementa o quinto nível (ex: '3.3.2.2.1').""" + self.contadores[4] += 1 + return '.'.join(map(str,self.contadores)) + def numero_atual(self, nivel: int = 0) -> str: """Retorna o número atual de um nível sem incrementar.""" if nivel == 0: @@ -59,8 +65,10 @@ class NumeradorSecoes: return f"{self.contadores[0]}.{self.contadores[1]}" elif nivel == 2: return f"{self.contadores[0]}.{self.contadores[1]}.{self.contadores[2]}" - else: + elif nivel == 3: return f"{self.contadores[0]}.{self.contadores[1]}.{self.contadores[2]}.{self.contadores[3]}" + else: + return '.'.join(map(str,self.contadores)) def definir_secao(self, numero: int) -> None: """Define manualmente o número da seção atual.""" diff --git a/document/territorial_narrative.py b/document/territorial_narrative.py new file mode 100644 index 0000000000000000000000000000000000000000..0ece2b8ee3c7a95b24cd63ae83c4c8c57d55917a --- /dev/null +++ b/document/territorial_narrative.py @@ -0,0 +1,253 @@ +"""Narrative from the recorded property conditions to the adopted land value. + +Percentages and effects come from the saved MESA assessment. Legal references +identify the condition without replacing the historical percentages or results. +""" +from collections import OrderedDict +import re +import unicodedata + +from .evo_adjustments import REDUCERS, applied_reducers +from .fundamentacao import number +from .evolutivo import (source, terrain, result, ref, fmt, pct, complement, + heading, add_body_text) + +# Original arts. 56, 60 and 61 and the 2019 wording of art. 88 checked on +# 21/10/2026. Percentages remain those recorded in the assessment. +DECREE_SOURCE = 'https://legislacao.portoalegre.rs.gov.br/media/sapl/public/normajuridica/sirel/Norma_16500_20091110_1.html' +DECREE = 'Decreto Municipal nº 16.500, de 10 de novembro de 2009' +AMENDMENT_SOURCE = 'https://dopaonlineupload.procempa.com.br/dopaonlineupload/3123_ce_272713_1.pdf' +ARTICLE_88_DECREE = DECREE+', na redação dada pelo Decreto Municipal nº 20.396, de 11 de novembro de 2019' +ARTICLE_88_LETTERS = { + 'redutor88I': {'app': 'a', 'diretriz_logradouro': 'b', 'faixa_redes': 'c', + 'alta_tensao': 'd', 'ocupacoes_irregulares': 'e'}, + 'redutor88II': {'sem_agua': 'a', 'sem_luz': 'b', 'sem_esgoto': 'c', 'sem_pavimentacao': 'd'}, +} +ALIASES = { + 'app': ['APP', 'área de preservação permanente'], + 'diretriz_logradouro': ['diretriz de logradouro', 'diretriz para implantação de logradouro'], + 'faixa_redes': ['faixa não edificável', 'passagem de redes'], + 'alta_tensao': ['alta tensão'], + 'ocupacoes_irregulares': ['ocupação irregular', 'ocupações irregulares'], + 'vila': ['imóvel em vila'], 'encravado': ['terreno encravado'], + 'triangular_ate_1000': ['terreno triangular', 'terreno interno triangular'], + 'topografia': ['topografia desfavorável', 'condições topográficas', 'declive', 'aclive', 'desnível'], + 'agua': ['inundação', 'alagamento', 'enchente'], + 'apan': ['APAN', 'área de proteção do ambiente natural', 'área de proteção ao ambiente natural'], +} + + +def normalized(text): + text = ''.join(c for c in unicodedata.normalize('NFKD',str(text)) if not unicodedata.combining(c)) + return ' '.join(re.findall(r'[a-z0-9]+',text.casefold())) + + +def condition_refs(labels, characteristics): + """Only refer to actual numbered headings; no guessed positional mapping.""" + phrases = [normalized(label) for label in labels if normalized(label)] + matches = [] + for item in characteristics: + title = ' '+normalized(item.get('titulo',''))+' ' + if any(' '+phrase+' ' in title for phrase in phrases): + reference = item.get('referencia') + if reference and reference not in matches: matches.append(reference) + return matches + + +def reference_sentence(labels, characteristics): + refs = condition_refs(labels,characteristics) + if not refs: + return 'Não há remissão específica a uma característica individualizada do capítulo 2.2 para esta condição.' + if len(refs)==1: + return f'A condição é examinada no item {refs[0]}, ao qual se remete sua caracterização.' + return 'As condições são examinadas nos itens '+join_words(refs)+', aos quais se remete sua caracterização.' + + +def join_words(items): + return items[0] if len(items)==1 else ', '.join(items[:-1])+' e '+items[-1] + + +def config_for(row): + return ((terrain(row).get('avaliacao') or {}).get('ajuste_territorial') or {}).get('configuracao') or {} + + +def weighted_conditions(row): + catalog = (source(row).get('catalogoAjustes') or {}).get('art_88_i') or {} + config = config_for(row) + minimum = number(catalog.get('limite_minimo_area')) + if minimum is None: return [] + conditions = [] + for item in catalog.get('condicoes') or []: + code = item.get('codigo') + area = number(((config.get('art_88_i') or {}).get('condicoes',{}).get(code) or {}).get('percentual_area')) + rate = number(item.get('redutor',item.get('percentual_reducao'))) + if area is not None and area>minimum and rate is not None and rate>0: + conditions.append((code,item.get('rotulo') or code,area,rate,minimum)) + return conditions + + +def scope(rows, selected): + if len(selected)==len(rows): return 'Para todas as referências avaliadas' + return 'Para '+join_words([ref(row) for row in selected]) + + +def grouped_prose(doc, rows, entries): + variants = OrderedDict() + for row,text in entries: variants.setdefault(text,[]).append(row) + for text,selected in variants.items(): + add_body_text(doc,scope(rows,selected)+', '+text).paragraph_format.keep_together = True + + +def legal_reference(key, codes=()): + if key in ARTICLE_88_LETTERS: + clause = 'I' if key == 'redutor88I' else 'II' + reference = f'art. 88, inciso {clause}' + mapping = ARTICLE_88_LETTERS[key] + # A missing/unknown condition must not inherit another condition's letter. + if codes and all(code in mapping for code in codes): + letters = list(dict.fromkeys('“'+mapping[code]+'”' for code in codes)) + reference += (', alínea ' if len(letters)==1 else ', alíneas ')+join_words(letters) + return reference + if key in ('redutorTopografia', 'redutorAgua', 'apan'): + return 'art. 88, § 5º' + if key=='redutor6061': + articles = [] + if 'vila' in codes: articles.append('art. 60, caput') + if 'encravado' in codes: articles.append('art. 61, inciso I') + if 'triangular_ate_1000' in codes: articles.append('art. 61, inciso II') + return join_words(articles) if articles else '' + return '' + + +def legal_basis(key, codes=()): + reference = legal_reference(key, codes) + if not reference: + 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.' + if key=='redutor6061': + 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.' + text = f'O tratamento desta condição fundamenta-se no {reference} do {ARTICLE_88_DECREE}.' + if key in ('redutorTopografia', 'redutorAgua', 'apan'): + 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.' + return text + + +def reducer_parts(row, key, label, rate): + """Split only documented, effective conditions, without revaluing the land.""" + saved = source(row).get('catalogoAjustes') or {} + config = config_for(row) + if key=='redutor88I': + parts = [] + for code,name,area,reference_rate,minimum in weighted_conditions(row): + 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.' + parts.append((key+':'+code,name,[name]+ALIASES.get(code,[]),text,[code])) + if parts: return parts + elif key=='redutor6061': + codes = (config.get('art_60_61') or {}).get('selecionados') or [] + choices = [item for item in (saved.get('art_60_61') or {}).get('condicoes',[]) + if item.get('codigo') in codes and number(item.get('redutor'))==rate] + if choices: + names = [item.get('rotulo') or item['codigo'] for item in choices] + labels = names+[alias for item in choices for alias in ALIASES.get(item['codigo'],[])] + 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])] + elif key=='redutorTopografia': + descriptions,labels = [],[] + for part,rate_key in [('relevo','redutorRelevo'),('nivel_rua','redutorNivel')]: + code = (config.get('topografia') or {}).get(part) + item = next((item for item in (saved.get('topografia') or {}).get(part,[]) if item.get('codigo')==code),{}) + part_rate = number(result(row).get(rate_key)) if rate_key in result(row) else number(item.get('redutor')) + if item and part_rate is not None and part_rate>0: + name = item.get('rotulo') or code + labels.append(name);descriptions.append(f'{name} ({pct(part_rate,True)})') + if descriptions: + 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.',[])] + elif key=='redutorAgua': + code = (config.get('agua') or {}).get('situacao') + item = next((item for item in (saved.get('agua') or {}).get('condicoes',[]) if item.get('codigo')==code),{}) + if item: + name = item.get('rotulo') or code + 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.',[])] + # A missing catalog or breakdown must not be replaced with today's rates. + 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.' + return [(key,label,[label],text+' A condição específica e os parâmetros de incidência não foram preservados em detalhe.',[])] + + +def summarize_restrictions(doc,rows): + entries = [] + for row in rows: + active = {key:rate for key,label,rate in applied_reducers(row,True)} + if 'redutor88I' not in active: continue + rate = active['redutor88I'] + 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.' + conditions = weighted_conditions(row) + weighted = sum(area*reference for code,name,area,reference,minimum in conditions) + if conditions and rate is not None and abs(weighted-rate)>number('0.000000001'): + 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.' + entries.append((row,text)) + grouped_prose(doc,rows,entries) + + +def gerar_ajustes_territoriais(doc, rows, num, characteristics): + groups = OrderedDict() + for row in rows: + for key,label,rate in applied_reducers(row,True): + for identity,title,labels,text,codes in reducer_parts(row,key,label,rate): + groups.setdefault(identity,[]).append((row,key,title,labels,text,codes)) + if groups: + 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.') + if any(identity.startswith('redutor88I:') for identity in groups): + 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.') + order = {key:i for i,key in enumerate(REDUCERS)} + ordered = sorted(groups.items(),key=lambda pair:order.get(pair[0].split(':',1)[0],len(order))) + restrictions = [identity for identity in groups if identity.startswith('redutor88I:')] + for identity,entries in ordered: + first = entries[0] + heading(doc,num,first[2],5) + # Group the full narrative so historical labels or legal hypotheses + # are never silently borrowed from another exercise. + variants = OrderedDict() + for row,key,title,labels,text,codes in entries: + opening = reference_sentence(labels,characteristics)+' '+legal_basis(key,codes) + variants.setdefault(opening,[]).append((row,text)) + for opening,details in variants.items(): + add_body_text(doc,opening).paragraph_format.keep_with_next = True + grouped_prose(doc,rows,details) + if restrictions and identity==restrictions[-1]: + summarize_restrictions(doc,rows) + else: + 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.') + campo_arbitrio(doc,rows,num,characteristics) + + +def campo_arbitrio(doc, rows, num, characteristics): + active = [row for row in rows if number(result(row).get('fatorCaFinal')) not in (None,1)] + # A public choice justification can matter even when the final factor is 1. + justified = any((row.get('complementos') or {}).get('justificativa_vu') for row in rows) + if not active and not justified: return + heading(doc,num,'Escolha do valor no campo de arbítrio',5) + 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.') + apan = [row for row in active if (number(result(row).get('redutorApan')) or 0)>0] + if apan: + 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.') + 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]) + entries = [] + for row in rows: + factor = number(result(row).get('fatorCaFinal')) + reason = complement(row,'justificativa_vu','') + if factor in (None,1) and not reason: continue + 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. ' + 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.' + entries.append((row,text)) + grouped_prose(doc,rows,entries) + + +def justificar_area(doc,rows): + entries = [] + for row in rows: + model_area = number(terrain(row).get('areaModelo',row['avaliacao'].get('valor_area'))) + area = number(row.get('area')) + if model_area is None or area is None or model_area==area: continue + reason = complement(row,'justificativa_area','') + 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². ' + 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.' + entries.append((row,text)) + grouped_prose(doc,rows,entries) diff --git a/frontend/package.json b/frontend/package.json index 9d3817cbfaf38d5d33f6a35c5eaf0ab8cf856c52..025660c5251abb6e25f63f6eb14cd8394ac5e0ee 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,6 +4,7 @@ "version": "0.1.0", "type": "module", "scripts": { + "test": "node --test tests/*.test.mjs", "dev": "vite", "build": "vite build", "preview": "vite preview --host 0.0.0.0 --port 4173" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a2049d260c66ebc54c4a02d68c4545d4d5ab9174..319329a90f3f56f7af08a9d23cff8d3404375142 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,8 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import ImportacaoAvaliacoes from "./ImportacaoAvaliacoes"; +import RevisaoAvaliacao from "./RevisaoAvaliacao"; +import { adoptedValues, applyImportDefaults, importDefaults, REVIEW_FIELDS } from "./importDefaults"; const API_BASE = ( import.meta.env.VITE_API_BASE_URL || @@ -6,27 +10,16 @@ const API_BASE = ( ).trim(); const TAB_LABELS = [ + "Início · Importar avaliação", "1. Solicitação", "2. Documentação", "3. Motivos Desvalorizantes", "4. Imóvel Objeto", "5. Avaliação", - "6. Valores de Mercado", - "7. Registro Fotográfico", - "8. Gerar Documento", + "6. Registro Fotográfico", + "7. Gerar Documento", ]; -const DEFAULT_UNSELECTED_COLUMNS = new Set([ - "REGISTRO", - "IMO-INSC", - "CTM", - "NUMERO", - "NUM", - "UNIDADE", - "COMPLEMENTO", - "COMPL", -]); - const emptyMotivo = { descricao: "", alegado: true, @@ -43,8 +36,7 @@ const normalizeApiUrl = (path) => { return `${base}${route}`; }; -const isSelectedByDefault = (columnName) => - !DEFAULT_UNSELECTED_COLUMNS.has(String(columnName).trim().toLocaleUpperCase("pt-BR")); + const applyCurrencyMask = (value) => { if (!value) { @@ -166,10 +158,13 @@ const applyDummyToForm = (base, dummy) => { function App() { const [options, setOptions] = useState(null); const [form, setForm] = useState(null); - const [activeTab, setActiveTab] = useState(0); + const [activeTab, setActiveTab] = useState(-1); + const editedFields = useRef(new Set()); + const previousDefaults = useRef({}); - const [anoInicial, setAnoInicial] = useState(2020); - const [anoFinal, setAnoFinal] = useState(new Date().getFullYear()); + const [importacao, setImportacao] = useState(null); + const [importEpoch, setImportEpoch] = useState(0); + const [loadingImport, setLoadingImport] = useState(false); const [pdfFile, setPdfFile] = useState(null); const [registroFotoFile, setRegistroFotoFile] = useState(null); @@ -177,9 +172,7 @@ function App() { const [loadingOptions, setLoadingOptions] = useState(true); const [loadingPdf, setLoadingPdf] = useState(false); const [loadingGenerate, setLoadingGenerate] = useState(false); - const [loadingYears, setLoadingYears] = useState(false); - const [loadingColumns, setLoadingColumns] = useState(false); - const [databaseColumns, setDatabaseColumns] = useState([]); + const [error, setError] = useState(""); const [pdfWarnings, setPdfWarnings] = useState([]); @@ -201,8 +194,7 @@ function App() { setForm(buildEmptyForm(payload)); - setAnoInicial(payload.defaults?.ano_inicial_mercado ?? 2020); - setAnoFinal(payload.defaults?.ano_final_mercado ?? new Date().getFullYear()); + } catch (err) { setError(String(err)); } finally { @@ -220,82 +212,28 @@ function App() { return normalizeApiUrl(result.download_url); }, [result]); - const limits = options?.limits || {}; - const selectedColumns = useMemo( - () => databaseColumns.filter((column) => column.selected).map((column) => column.name), - [databaseColumns], - ); - useEffect(() => { - const modelName = form?.modelo_avaliacao; - if (!modelName) { - setDatabaseColumns([]); - return undefined; - } - - const controller = new AbortController(); - const loadColumns = async () => { - setLoadingColumns(true); - try { - const response = await fetch( - normalizeApiUrl(`/api/models/${encodeURIComponent(modelName)}/columns`), - { signal: controller.signal }, - ); - const payload = await response.json(); - if (!response.ok) { - throw new Error(payload.detail || "Não foi possível carregar as colunas do modelo."); - } - setDatabaseColumns( - (payload.columns || []).map((name) => ({ name, selected: isSelectedByDefault(name) })), - ); - } catch (err) { - if (err.name !== "AbortError") { - setDatabaseColumns([]); - setError(String(err)); - } - } finally { - if (!controller.signal.aborted) { - setLoadingColumns(false); - } - } - }; + const defaults = importDefaults(importacao); + const previous = previousDefaults.current; + setForm((current) => applyImportDefaults(current, defaults, previous, editedFields.current)); + previousDefaults.current = defaults; + }, [importacao]); + + const handleImportChange = (value) => { + setImportacao(value); + setResult(null); + setError(""); + }; - loadColumns(); - return () => controller.abort(); - }, [form?.modelo_avaliacao]); + useEffect(() => { setResult(null); }, [form, registroFotoFile]); + const limits = options?.limits || {}; const updateField = (field, value) => { + editedFields.current.add(field); + setResult(null); setForm((prev) => ({ ...prev, [field]: value })); }; - const toggleDatabaseColumn = (columnName) => { - setDatabaseColumns((current) => - current.map((column) => - column.name === columnName ? { ...column, selected: !column.selected } : column, - ), - ); - }; - - const moveDatabaseColumn = (index, direction) => { - setDatabaseColumns((current) => { - const targetIndex = index + direction; - if (targetIndex < 0 || targetIndex >= current.length) { - return current; - } - const next = [...current]; - [next[index], next[targetIndex]] = [next[targetIndex], next[index]]; - return next; - }); - }; - - const selectAllDatabaseColumns = () => { - setDatabaseColumns((current) => current.map((column) => ({ ...column, selected: true }))); - }; - - const clearDatabaseColumns = () => { - setDatabaseColumns((current) => current.map((column) => ({ ...column, selected: false }))); - }; - const updateStringList = (field, index, value) => { setForm((prev) => { const list = [...prev[field]]; @@ -323,6 +261,8 @@ function App() { }; const updateMotivo = (index, patch) => { + editedFields.current.add('motivos_desvalorizantes'); + setResult(null); setForm((prev) => { const next = [...prev.motivos_desvalorizantes]; next[index] = { ...next[index], ...patch }; @@ -331,6 +271,7 @@ function App() { }; const addMotivo = () => { + editedFields.current.add('motivos_desvalorizantes'); setForm((prev) => { if (prev.motivos_desvalorizantes.length >= limits.max_motivos_desvalorizantes) { return prev; @@ -343,6 +284,7 @@ function App() { }; const removeMotivo = () => { + editedFields.current.add('motivos_desvalorizantes'); setForm((prev) => { if (prev.motivos_desvalorizantes.length <= 1) { return prev; @@ -354,50 +296,6 @@ function App() { }); }; - const updateValorMercado = (index, patch) => { - setForm((prev) => { - const next = [...prev.valores_mercado]; - next[index] = { ...next[index], ...patch }; - return { ...prev, valores_mercado: next }; - }); - }; - - const handleGenerateYears = async () => { - setLoadingYears(true); - setError(""); - - try { - const response = await fetch(normalizeApiUrl("/api/market-values/generate"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ano_inicial: Number(anoInicial), - ano_final: Number(anoFinal), - }), - }); - - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throw new Error(body.detail || "Erro ao gerar anos."); - } - - const payload = await response.json(); - setForm((prev) => ({ - ...prev, - valores_mercado: payload.anos.map((ano) => ({ ano, valor: "" })), - datas_referencia: payload.referencias || "", - })); - } catch (err) { - setError(String(err)); - } finally { - setLoadingYears(false); - } - }; - - const handleClearYears = () => { - setForm((prev) => ({ ...prev, valores_mercado: [], datas_referencia: "" })); - }; - const handleExtractPdf = async () => { if (!pdfFile) { setError("Selecione um arquivo PDF antes de processar."); @@ -458,6 +356,8 @@ function App() { return; } + setResult(null); + setForm((prev) => { const dropdowns = options.dropdowns || {}; const demoBase = { @@ -470,25 +370,37 @@ function App() { metodo_avaliacao: dropdowns.metodos_avaliacao?.[0] || prev.metodo_avaliacao, modelo_avaliacao: options.models?.[0] || prev.modelo_avaliacao, }; - return applyDummyToForm(demoBase, options.dummy); + const example = applyDummyToForm(demoBase, options.dummy); + if (importacao) { + for (const field of [...REVIEW_FIELDS, 'motivos_desvalorizantes']) example[field] = prev[field]; + } + return example; }); }; const handleClearForm = () => { + editedFields.current.clear(); + previousDefaults.current = {}; setForm(buildEmptyForm(options)); - setDatabaseColumns([]); + setImportacao(null); + setPdfFile(null); + setRegistroFotoFile(null); + setImportEpoch((current) => current + 1); setError(""); setPdfWarnings([]); setResult(null); + setActiveTab(-1); }; const handleGenerateDocument = async () => { - if (loadingColumns) { - setError("Aguarde o carregamento das colunas do banco de dados."); + if (loadingImport || !importacao) { + setError("Importe um arquivo .ame ou .evo antes de gerar o documento."); + setActiveTab(-1); return; } - if (databaseColumns.length > 0 && selectedColumns.length === 0) { - setError("Selecione ao menos uma coluna para o Anexo I - Banco de Dados."); + if (importacao.preview.avaliacoes.some((item) => !/^(19|20|21)\d{2}$/.test(importacao.anos[item.id] || ''))) { + setError("Informe o ano de todas as avaliações na etapa inicial de importação."); + setActiveTab(-1); return; } @@ -502,12 +414,15 @@ function App() { documentacao_padrao: form.documentacao_padrao.filter((item) => item.trim()), documentacao_especifica: form.documentacao_especifica.filter((item) => item.trim()), motivos_desvalorizantes: form.motivos_desvalorizantes.filter((item) => item.descricao.trim()), - valores_mercado: form.valores_mercado.filter((item) => item.ano && item.valor), - banco_dados_colunas: selectedColumns, + valores_mercado: [], + banco_dados_colunas: null, + importacao_avaliacoes: { referencia_anos: importacao.referenciaAnos, anos: importacao.anos, objeto_modelo: importacao.objeto, + valores_adotados: adoptedValues(importacao), complementos: importacao.complementos || {}, campos_editados: REVIEW_FIELDS.filter((field) => editedFields.current.has(field)) }, }; const formData = new FormData(); formData.append("payload", JSON.stringify(payload)); + formData.append("arquivo_avaliacoes", importacao.file); if (registroFotoFile) { formData.append("registro_fotografico", registroFotoFile); } @@ -524,7 +439,7 @@ function App() { const generated = await response.json(); setResult(generated); - setActiveTab(7); + setActiveTab(6); } catch (err) { setError(String(err)); } finally { @@ -563,8 +478,8 @@ function App() { @@ -572,6 +487,14 @@ function App() {
+ {activeTab === -1 && ( +
+

Comece pela avaliação

+

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.

+ + +
+ )} {activeTab === 0 && (

Dados Gerais

@@ -733,7 +656,8 @@ function App() { {activeTab === 2 && (
-

Motivos Desvalorizantes Alegados

+

Motivos Desvalorizantes

+

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.

{form.motivos_desvalorizantes.map((item, index) => (
updateField("unidade_responsavel", event.target.value)} > + {options.dropdowns.unidades_responsaveis.map((item) => ( {options.dropdowns.tecnicos_responsaveis.map((item) => (
-
- - {form.metodo_avaliacao === "Outros" && ( - - )} -
- - - -
-
-
-

Colunas do Anexo I - Banco de Dados

-

- Selecione e ordene as colunas que serão exibidas no anexo do modelo. -

-
- {selectedColumns.length} selecionadas -
- -
- - -
- - {loadingColumns ? ( -

Carregando colunas do modelo...

- ) : ( -
- {databaseColumns.map((column, index) => ( -
- -
- - -
-
- ))} -
- )} -
+ setActiveTab(-1)} />
)} {activeTab === 5 && ( -
-

Definir Intervalo de Anos

-
- - -
- - -
-
- -

Valores por Ano

- {form.valores_mercado.length === 0 && ( -

Nenhum ano gerado ainda. Defina o intervalo acima.

- )} - {form.valores_mercado.map((item, index) => ( -
- - -
- ))} - - -
- )} - - {activeTab === 6 && (

Registro Fotográfico

@@ -1108,11 +874,11 @@ function App() {

)} - {activeTab === 7 && ( + {activeTab === 6 && (

Gerar Laudo de Avaliação

Confira as abas anteriores e execute a geração do documento.

- diff --git a/frontend/src/EvolutivoReview.jsx b/frontend/src/EvolutivoReview.jsx new file mode 100644 index 0000000000000000000000000000000000000000..fe470123a57ffd9ab364b761308b7fced7c36163 --- /dev/null +++ b/frontend/src/EvolutivoReview.jsx @@ -0,0 +1,63 @@ +const fields = [ + ['justificativa_vu', 'Escolha do valor unitário e do campo de arbítrio'], + ['justificativa_area', 'Diferença entre área do modelo e área de aplicação'], + ['justificativa_fc', 'Escolha do fator de comercialização'], + ['ajustes_nao_aplicados', 'Ajustes expressamente não aplicados e justificativa'], +]; +const gradeOptions = ['', 'Grau III', 'Grau II', 'Grau I', 'Sem enquadramento']; +export const documentaryValues = (value, row) => ({ ...row.complementos, ...value.complementos?.[row.id], + bdi: { ...row.complementos?.bdi, ...value.complementos?.[row.id]?.bdi }, + requisitos_grau_iii: { ...row.complementos?.requisitos_grau_iii, ...value.complementos?.[row.id]?.requisitos_grau_iii } }); + +export default function EvolutivoReview({ value, row, onChange, changed }) { + if (value.preview.tipo !== 'evolutivo') return null; + const current = documentaryValues(value, row); + const review = row.revisao_documental || {}; + const set = (key, text) => onChange({ ...value, complementos: { ...value.complementos, [row.id]: { ...current, [key]: text } } }); + const reuse = (key) => { + const next = { ...value.complementos }; + for (const target of value.preview.avaliacoes) next[target.id] = { ...documentaryValues(value, target), [key]: current[key] }; + onChange({ ...value, complementos: next }); + }; + const reuseBdi = (construction) => { + const next = { ...value.complementos }; + for (const target of value.preview.avaliacoes) { + const matches = (target.revisao_documental?.construcoes || []).filter((item) => item.grupo === construction.grupo); + if (matches.length !== 1) continue; + const saved = documentaryValues(value,target); + next[target.id] = { ...saved, bdi: { ...saved.bdi, [matches[0].id]: current.bdi?.[construction.id] || '' } }; + } + onChange({ ...value, complementos: next }); + }; + return
+ Revisar fundamentos e complementos desta avaliação +

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.

+

Área do modelo: {review.area_modelo ?? 'não informada'} m² · Área de aplicação: {review.area_aplicacao ?? 'não informada'} m²

+

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.

+ {!!review.pendencias?.length &&

Lacunas identificadas no arquivo: {review.pendencias.join('; ')}.

} + {(review.conferencias || []).map((warning, index) =>

{warning}

)} + {fields.map(([key, label]) =>
+