Pular para conteúdo

0007 — Análise de Crédito: rename completo (Proposal → CreditAnalysis) + visão mobile

Status CONCLUÍDA
Branch spec/0007-analise-de-credito-linguagem-e-mobile
Criada em 2026-07-30
Apps afetados api · web · e2e

Status: RASCUNHO → APROVADA → EM IMPLEMENTAÇÃO → CONCLUÍDA

1. Problema

Duas dívidas na mesma área, ambas visíveis para quem usa:

Linguagem. O negócio chama de Análise de Crédito; o sistema chama de Proposta. O menu já foi renomeado (nav.creditAnalysis = "Análise de Crédito"), mas as telas por baixo seguem dizendo "Proposta": título ("Propostas"), botão ("Nova proposta"), vazio ("Nenhuma proposta encontrada"), diálogos ("Cancelar proposta") — 12 chaves i18n — além de cabeçalhos de tabela hardcoded em português na lista (Código, Status, Locatário, Proprietário, Imóvel, Garantia). No código, a entidade é Proposal de ponta a ponta (rota /proposals, endpoint /api/v1/proposals, tabela proposals, permissões proposals:*). O usuário lê um nome, o time fala outro e a documentação precisa traduzir — atrito permanente.

Mobile. As telas de Análise de Crédito não têm tratamento mobile. A lista usa <Table> puro (estoura a largura no celular) e os formulários de criação/edição usam grade de duas colunas com o resumo financeiro em painel lateral. Renters, imóveis e imobiliárias já ganharam a versão mobile na spec 0004 (useIsMobile + MobileListCard) — a área mais usada da operação ficou de fora.

2. Objetivo

Fazer o sistema falar Análise de Crédito em todas as camadas (interface, URL e código) e deixar as quatro telas do módulo usáveis no celular, no mesmo padrão dos outros cadastros.

3. Fora de escopo

  • Mudança de comportamento ou regra de negócio: é rename + responsividade. Nenhum status novo, nenhuma validação nova, nenhum cálculo alterado.
  • O modelo-alvo de 10 papéis area-scoped (navigation-brief.md) — as permissões são renomeadas, não redesenhadas.
  • Portal da imobiliária e as entregas E2/E3 do ROADMAP.
  • Compatibilidade retroativa da API: os endpoints antigos não são mantidos como alias (ver RN9) — o cliente é o próprio apps/web, versionado junto.

4. Regras de negócio

Rename (linguagem, URL e código)

  • RN1. A entidade hoje chamada Proposal passa a ser CreditAnalysis — o caso de análise de crédito (o dossiê que nasce em DRAFT e caminha até APPROVED).
  • RN2. A entidade hoje chamada Analysis (o parecer do analista dentro do caso) passa a ser Decision. Isso resolve a colisão: sem o rename duplo, ficaria CreditAnalysis contendo Analysis, indistinguíveis na leitura.
  • AnalysisStatus → DecisionStatus; Analysis* nos schemas → Decision*.
  • CreditRecommendation mantém o nome (já é claro e não colide).
  • RN3. Renomes de banco (uma migration, com ALTER TABLE ... RENAME, preservando dados e FKs):
De Para
proposals credit_analyses
proposal_participants credit_analysis_participants
proposal_status_history credit_analysis_status_history
analyses decisions
coluna proposal_id (em analyses/decisions, contracts, documents, credit_analysis_participants, credit_analysis_status_history) credit_analysis_id
  • RN4. ProposalStatus → CreditAnalysisStatus. Os valores do enum não mudam (DRAFT, SUBMITTED, IN_ANALYSIS, PENDING_DOCS, APPROVED, REJECTED, CANCELLED) — renomear valores exigiria migrar dados sem ganho.
  • RN5. Endpoint /api/v1/proposals → /api/v1/credit-analyses (kebab-case, como o resto da API). Sub-recursos acompanham: /{id}/analysis → /{id}/decision.
  • RN6. Permissões proposals:* → credit_analyses:* e analysis:* → decisions:* (17 permissões). A migration precisa atualizar as permissões existentes e os vínculos dos papéis — perder isso tira acesso de todo mundo.
  • RN7. Rota do front /proposals → /credit-analyses (list/create/edit/show), com o name do resource no Refine acompanhando. Sem redirect das URLs antigas (decisão: cliente único, versionado junto).
  • RN8. Textos da interface: nenhuma tela exibe "Proposta"/"proposta". As 12 chaves i18n passam a "Análise de Crédito"/"análise"; os cabeçalhos hardcoded da lista viram chaves i18n (PT/EN).
  • RN9. Sem alias de compatibilidade: as rotas e os endpoints antigos deixam de existir. O apps/web e o apps/e2e são atualizados no mesmo commit.
  • RN10. Nenhuma mudança de comportamento. Status, transições, cálculo (Pacote/LMI/Custos de Saída da spec 0006) e permissões efetivas por papel ficam idênticos — só os nomes mudam.

Visão mobile

  • RN11. Lista: abaixo do breakpoint mobile, a tabela é substituída por cards (useIsMobile + MobileListCard), no mesmo padrão de renters/imóveis/ imobiliárias. Cada card mostra código, status, locatário, imóvel e valor.
  • RN12. Criação e edição: formulário em coluna única no mobile (a grade de duas colunas colapsa), na mesma linha da spec 0004.
  • RN13. Resumo financeiro: no desktop segue como painel lateral; no mobile vira uma seção colapsável, iniciando recolhida, para não empurrar os campos para baixo. Um toque abre.
  • RN14. Detalhe (show): seções em coluna única no mobile, usando MobileDetailSection onde couber (padrão já usado em renters/show).
  • RN15. As ações principais (submeter, aprovar, rejeitar, cancelar) ficam alcançáveis com o polegar no mobile e não podem ficar escondidas atrás de scroll horizontal.

Permissões: inalteradas em efeito (RN6/RN10) — quem podia criar/aprovar continua podendo, sob os nomes novos.

5. Impacto por app

apps/api

Alcance medido: 1.049 ocorrências de Proposal/proposal em 46 arquivos.

  • Models: proposal.py → credit_analysis.py (CreditAnalysis, CreditAnalysisStatus, CreditAnalysisStatusHistory); proposal_participant.py → credit_analysis_participant.py; analysis.py → decision.py (Decision, DecisionStatus, CreditRecommendation).
  • FKs a acertar: decisions, contracts, documents, credit_analysis_participants, credit_analysis_status_history.
  • Schemas / repositories / services / routers: renomeados em bloco (proposal_service → credit_analysis_service, analysis_service → decision_service, etc.), com Swagger atualizado (summary, description, response_description) e TAGS_METADATA em app/main.py.
  • RBAC: rbac_seed.py com os nomes novos + migration atualizando permissions.name e preservando role_permissions (RN6).
  • Migration Alembic: ALTER TABLE/ALTER COLUMN ... RENAME (não recriar tabela — os dados e as FKs precisam sobreviver), mais o UPDATE das permissões. downgrade simétrico.
  • Testes: 5 arquivos citam proposal; renomear e manter a cobertura. A suíte precisa continuar em 173 passed — é a rede que prova que o rename não mudou comportamento (RN10).
  • wiki/docstrings: AutoAPI segue as docstrings; atualizar as que citam "proposta".

apps/web

Alcance: 25 arquivos citam proposal.

  • src/pages/proposals/ → src/pages/credit-analyses/ (index, create, edit, show, api.ts, components) com os componentes renomeados (ProposalListPage → CreditAnalysisListPage, etc.).
  • App.tsx: resource proposals → credit_analyses, rotas /credit-analyses/*. components/custom-sider/index.tsx precisa acompanhar (sider é hardcoded — ver CLAUDE.md do web).
  • i18n: namespace proposals.* → creditAnalyses.*, textos sem "proposta", e as chaves novas para os cabeçalhos da lista (PT/EN).
  • Mobile (RN11–RN15): useIsMobile + MobileListCard na lista; coluna única em create/edit; resumo financeiro colapsável; MobileDetailSection no show.
  • data-testid (renomeados/novos): credit-analyses-new-button, credit-analysis-row-{id}, credit-analysis-card-{id}, credit-analysis-form, credit-analysis-form-submit, credit-analysis-financial-summary-toggle.
  • Responsividade & PWA: é o coração da parte 2. Sem impacto de PWA além do cache normal de assets (o banner da spec 0005 avisa a nova versão).

apps/e2e

3 specs citam proposal — proposals-analysis.spec.ts (renomear para credit-analyses.spec.ts), pacote-locaticio.spec.ts e mobile-shell.spec.ts (ajustar rotas/testids).

  • Cenários novos: lista em cards no mobile, formulário em coluna única e resumo financeiro colapsável (fechado por padrão, abre ao toque).
  • Rodar em desktop e mobile.

6. Contrato de API

Quebra de contrato assumida (RN9). Antes → depois:

GET    /api/v1/proposals              →  GET    /api/v1/credit-analyses
POST   /api/v1/proposals              →  POST   /api/v1/credit-analyses
GET    /api/v1/proposals/{id}         →  GET    /api/v1/credit-analyses/{id}
PATCH  /api/v1/proposals/{id}         →  PATCH  /api/v1/credit-analyses/{id}
POST   /api/v1/proposals/{id}/submit  →  POST   /api/v1/credit-analyses/{id}/submit
POST   /api/v1/proposals/{id}/analysis/approve
                                      →  POST   /api/v1/credit-analyses/{id}/decision/approve

O corpo das requisições/respostas mantém os campos, exceto o rename de proposal_id → credit_analysis_id onde ele aparece (ex.: criação de contrato).

Permissões no token/perfil: proposals:create → credit_analyses:create, analysis:approve → decisions:approve, e assim por diante.

7. Critérios de aceite

  • [x] CA1. Nenhuma tela do módulo exibe "Proposta"/"proposta" — título, botão novo, estado vazio, diálogos e cabeçalhos da lista dizem "Análise de Crédito"/"análise".
  • [x] CA2. A URL é /app/credit-analyses (list/create/edit/show) e o item do menu leva até lá.
  • [x] CA3. GET /api/v1/credit-analyses responde 200; /api/v1/proposals não existe mais (404).
  • [x] CA4. Um ANALYST continua conseguindo submeter e aprovar, e um VIEWER continua sem conseguir — as permissões renomeadas preservam o acesso efetivo.
  • [x] CA5. Os dados sobrevivem à migration: análises, participantes, histórico, pareceres, contratos e documentos anteriores seguem consultáveis e ligados corretamente.
  • [x] CA6. No mobile (375px) a lista mostra cards, sem scroll horizontal.
  • [x] CA7. No mobile, criação e edição ficam em coluna única, e o resumo financeiro aparece recolhido, abrindo ao toque.
  • [x] CA8. No mobile, submeter/aprovar/rejeitar/cancelar estão acessíveis sem scroll horizontal.
  • [x] CA9. make test segue verde (173 passed) e o e2e passa em desktop e mobile — prova de que o rename não alterou comportamento (RN10).

8. Plano de implementação

Ordem obrigatória: api → web → e2e. O rename vem antes do mobile (o mobile mexe nos arquivos já renomeados).

  • [x] apps/api — models (CreditAnalysis, Decision, participantes, histórico) + FKs
  • [x] apps/api — schemas → repositories → services → routers, com Swagger e TAGS_METADATA
  • [x] apps/api — rbac_seed com permissões novas
  • [x] apps/api — migration l5n7p9r1u3v4: RENAME de 4 tabelas e das colunas proposal_id, UPDATE das permissões preservando os vínculos de papel; downgrade simétrico
  • [x] apps/api — testes renomeados; suíte em 173 passed (igual ao pré-rename)
  • [x] apps/web — pages/proposals → pages/credit-analyses, App.tsx, sider, i18n (namespace creditAnalyses.* + textos sem "Proposta"), testids em kebab
  • [x] apps/web — mobile: cards na lista, coluna única nos formulários, resumo financeiro colapsável, detalhe em seções
  • [x] apps/e2e — 3 specs ajustadas (rotas, testids, /decision); 42 passed
  • [x] apps/e2e — cenários mobile novos (credit-analyses-mobile.spec.ts)
  • [x] make test e tsc do web limpos; sem apontamento novo de lint nos arquivos tocados
  • [x] Validado no browser em desktop e mobile

9. Decisões em aberto

Resolvidas com o usuário (2026-07-30):

  • [x] Alcance do rename — interface + URLs + código/API (o refactor completo que o ROADMAP prevê).
  • [x] Colisão de nomes — Proposal → CreditAnalysis e Analysis → Decision, para não ficar "CreditAnalysis contendo Analysis".
  • [x] Fatiamento — tudo na 0007 (rename + mobile juntos), ciente de que o diff fica grande.
  • [x] Telas — lista, criação, edição e detalhe.
  • [x] Lista no mobile — cards, reusando MobileListCard.
  • [x] Resumo financeiro no mobile — colapsável, recolhido.
  • [x] Compatibilidade — sem alias das rotas/endpoints antigos (RN9).

Risco assumido e registrado: é o maior refactor da base até aqui (1.049 ocorrências, 46 arquivos na API, 4 tabelas, 17 permissões) entregue junto de uma mudança de UI. O ponto mais perigoso é a migration de permissões (RN6): errar ali tira acesso de todos os papéis — CA4 existe para pegar isso. A rede de proteção é a suíte de 173 testes + e2e (CA9).

10. Registro

Parte 2 — mobile (2026-07-30)

Entregue como planejado: cards na lista (MobileListCard), coluna única nos formulários, resumo financeiro colapsável e detalhe em MobileDetailSection.

Divergências do planejado:

  • Resumo financeiro fica acima dos campos no mobile, não no fim da página. A grade já colapsava para uma coluna, então o painel caía no rodapé — onde "colapsável" não resolve nada. Subiu para logo abaixo do cabeçalho, recolhido: cumpre a razão de ser da RN13 ("não empurrar os campos para baixo") e deixa o número a um toque.
  • Ações do detalhe migraram para barra fixa no rodapé no mobile. O PageHeader portaliza actions para a top bar, que tem ~40px de altura — três botões não caberiam (RN15). O mesmo JSX serve aos dois lugares via flex por breakpoint.
  • Botão "Nova análise de crédito" virou ícone no mobile. Com o rótulo inteiro na top bar, o título da página era truncado para "An… Gere…". Só ícone, nome no aria-label.
  • Footers dos formulários empilham no mobile (primária em cima, largura total; secundárias abaixo) — três botões lado a lado não cabem em 375px.

Pendências da parte 1 que estavam em aberto e foram fechadas aqui:

  • Cabeçalhos da lista estavam hardcoded em português (Código, Status, Locatário, Proprietário, Imóvel, Garantia, Criado por, Aprovado por) — viraram chaves i18n (PT/EN), como a RN8 exigia.
  • Sobras de "Proposta" na interface: usePageTitle("Propostas"), contador "proposta/propostas", título do detalhe (Proposta P2026…), textos de edit.tsx, rótulo do resource em App.tsx, TenantListPage, WorkQueueItem, tooltip em contracts e texto de document-types.
  • Catálogo EN tinha 25 linhas com o artefato do rename mecânico ("No creditAnalyses found", "CreditAnalyses", "This creditAnalysis has…").

Três regressões da parte 1 encontradas na verificação

Nenhuma tinha a ver com a parte 2 — apareceram porque make test e o browser foram exercitados de verdade.

  1. Suíte da API estava em 6 failed / 167 passed, não nos 173 registrados. Causa: o espelho de permissões do tests/conftest.py ficou com o recurso decision (singular) enquanto rbac_seed.py e os routers usam decisions. O if key in perm_map engolia a chave inexistente em silêncio, então o ANALYST não recebia decisions:create e todo teste de parecer dava 403. Corrigido em conftest.py (_ALL_PERMISSIONS e _SYSTEM_ROLE_PERMISSIONS), test_rbac_security.py e test_operations_center.py → 173 passed. O banco real estava correto (credit_analyses 9, decisions 4): a migration da RN6 fez seu trabalho — era desalinhamento só do espelho de teste.
  2. Painel de documentos da Análise de Crédito não filtrava. O front mandava entity_type=creditAnalysis (camelCase) e a API espera credit_analysis; o col_map.get() do document_repo devolvia None e a consulta seguia sem filtro nenhum, listando documentos de todas as entidades do tenant. Antes da parte 1 o valor era proposal e casava. Corrigido em shared/api/documents.ts e show.tsx.
  3. Rótulos e ícones do Centro de Operações e o filtro da Auditoria usavam creditAnalysis/analysis, mas a API emite credit_analysis/decision em entity_type — os rótulos caíam no fallback e o filtro de auditoria não casava nenhum registro. Corrigido em WorkQueueItem, WorkQueue, ActivityItem e audit-logs.

Bug de renda declarada — reportado pelo usuário em tela real

Depois da entrega, o usuário mandou print do celular: renda preenchida (R$ 8.000,00) e a API recusando com "ao menos um inquilino deve ter a renda mensal declarada". Causa: o create.tsx guarda a renda num único state que, após o onBlur, passa a conter o valor já formatado ("R$ 8.000,00") — e o envio parseava com parseFloat cru:

Onde Parser Resultado com "R$ 8.000,00"
buildParticipants LESSEE parseFloat(v) NaN → null → API recusa
buildParticipants CO_RESPONSIBLE replace(",", ".") + parseFloat 8 → análise criada com renda errada, sem erro
onFocus do inquilino idem 8.000 vira 8 ao reabrir o campo

O caso do corresponsável é o mais grave: não dá erro, só grava a renda errada e contamina o índice de capacidade financeira.

Corrigido usando o parseBRLInput do próprio arquivo (aceita "8000" e "R$ 8.000,00") nos três pontos, mais os onChange de condomínio e IPTU — que usavam o parser ingênuo enquanto o edit.tsx já usava o correto, então digitar 1.500,00 no condomínio virava R$ 1,00 no Pacote Locatício.

Pré-existente desde efcc33c, anterior a esta spec; nenhuma linha de parsing foi tocada pela parte 2 — apareceu porque o formulário foi finalmente exercitado num celular real.

Coberto por teste e2e novo que dirige o formulário mobile e inspeciona o corpo do POST (declared_income === 8000), e o create.tsx ganhou os data-testid que faltavam (imóvel, inquilinos, aluguel, renda por inquilino) — exigência do CLAUDE.md do web que o arquivo não cumpria.

Dois defeitos vizinhos, corrigidos a pedido do usuário

Encontrados ao investigar o bug da renda; o usuário mandou corrigir os dois.

1. Busca de imóvel não achava endereço com número. O filterOptions tirava espaços/pontos/hífens só da consulta quando ela tinha 3+ dígitos — recurso pensado para casar CEP mascarado (01310-100 ↔ 01310100). Só que o alvo continuava com espaços, então "Rua E2E 0007" virava "ruae2e0007" e não casava com "rua e2e 0007". Buscar por endereço + número ("Paulista 1000") não devolvia nada. Agora a forma compacta é aplicada aos dois lados, e a comparação literal continua valendo — o CEP mascarado segue funcionando. Verificado com 11 casos; 3 que retornavam 0 agora acham.

2. Separador de milhar digitado à mão virava centavo. parseFloat("1.500") é 1.5: um condomínio de R$ 1.500 entrava como R$ 1,50 no Pacote Locatício — a base da taxa, do LMI e dos Custos de Saída (spec 0006). Silencioso, direto no dinheiro.

O parseBRLInput estava duplicado em create.tsx e edit.tsx, com o mesmo defeito nas duas cópias. Passou a viver único em shared/utils/formatUtils.ts, com regra explícita de pt-BR:

Entrada Antes Agora
"1.500" 1.5 1500
"1.500.000" 1.5 1500000
"1.234.567,89" 1234567.89 1234567.89
"1500.50" (US) 1500.5 1500.5
"1.5" 1.5 1.5

A regra: vírgula manda (é o decimal do pt-BR); sem vírgula, pontos em grupos de 3 (/^\d{1,3}(\.\d{3})+$/) são milhar e caem; fora do agrupamento, o último ponto é decimal — assim "1.5" e "1500.50" não regridem. Verificado com 16 casos.

Ambos cobertos por teste e2e.

Unificação do formatBRL (feita depois, a pedido do usuário). A função estava duplicada byte a byte em create.tsx e edit.tsx e divergia do fmtBRL compartilhado: devolve "" quando não há número, em vez de "R$ 0,00". A diferença é intencional e necessária — é o valor exibido num campo, que nasce vazio; com fmtBRL o campo de aluguel apareceria preenchido com "R$ 0,00" ao abrir o formulário.

Então não era caso de deletar uma e usar a outra: são dois contratos. Ambas passaram a viver em shared/utils/formatUtils.ts com os papéis nomeados e documentados:

Função Para quê Sem valor
fmtBRL valor conhecido (tabela, detalhe, resumo) "R$ 0,00" / "R$ NaN"
fmtBRLInput conteúdo exibido de um campo ""
parseBRLInput normaliza o que o usuário digita ""
parseBRLNumber idem, já como number \| null null

O sufixo Input marca o par voltado a formulário. As 16 chamadas locais passaram a apontar para a compartilhada, e a troca de toLocaleString pelo Intl.NumberFormat em cache foi conferida em 14 casos (incluindo negativos, zero e arredondamento) — saída idêntica.

parseBRLNumber foi usado nos dois pontos de declared_income do create.tsx, onde expressa exatamente o que a API espera. Não foi aplicado no edit.tsx: lá o state guarda o valor já normalizado, e reparsear introduziria o erro inverso — "100.000" (cem reais, normalizado) viraria cem mil. Os dois arquivos têm contratos de state diferentes, e isso agora está comentado no código.

Bug pré-existente corrigido de carona: renters/show.tsx mandava entity_type=person, mas a API só conhece renter — o painel de documentos do inquilino tinha o mesmo defeito de filtro desde o commit que o introduziu (e8fd38f), anterior a esta spec. Um caractere para consertar; deixar quebrado ao lado do irmão corrigido seria pior.

Verificação

  • API: 173 passed (rodado dentro de garantora-devremote-api-1 — o container tem o código embutido, sem mount, então os testes precisam ser copiados com docker cp; o host não tem uv).
  • Lint: 40 apontamentos do ruff nos arquivos de teste tocados antes e depois — nenhum novo (todos E501/F401/I001 herdados). tsc do web limpo.
  • e2e: 17 passed (credit-analyses-mobile, credit-analyses, mobile-shell, pacote-locaticio) — 14 da entrega inicial + 3 de regressão (renda declarada, busca de imóvel com número, separador de milhar).
  • Browser real (Playwright contra o dev-remote, web rebuildado): 4 telas × 2 viewports (1440px e 375px) — scrollWidth - clientWidth == 0 nas 8, e as capturas confirmam cards, resumo recolhido, coluna única e barra de ações.