sass-imobiliaria/specs/024-filtro-busca-avancada/data-model.md

270 lines
7.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Data Model: Filtro de Busca Avançada — FilterSidebar
**Feature**: `024-filtro-busca-avancada` | **Phase**: 1 | **Date**: 2026-04-20
---
## Resumo
Sem novas tabelas ou migrations. A única alteração de "modelo de dados" é a extensão dos schemas Pydantic de saída com o campo calculado `property_count: int = 0`. No frontend, os tipos TypeScript espelham a mudança com `property_count?: number` (opcional para backward-compatibility).
São adicionados dois tipos internos do TypeScript exclusivos ao `FilterSidebar.tsx`, sem impacto em outros componentes.
---
## Backend — Schemas Pydantic (catalog.py)
### Antes e depois
```python
# ANTES
class PropertyTypeOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
parent_id: int | None
subtypes: list["PropertyTypeOut"] = []
class CityOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
state: str
class NeighborhoodOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
city_id: int
# DEPOIS (adição de property_count: int = 0 em cada schema)
class PropertyTypeOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
parent_id: int | None
subtypes: list["PropertyTypeOut"] = []
property_count: int = 0 # ← NOVO
class CityOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
state: str
property_count: int = 0 # ← NOVO
class NeighborhoodOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
city_id: int
property_count: int = 0 # ← NOVO
```
**Regras de validação**:
- `property_count >= 0` (COUNT nunca é negativo)
- Default `0` garante que entidades sem imóveis ativos retornem um valor válido
- Campo somente-leitura (nenhum endpoint de escrita o aceita como input)
---
## Backend — Queries (routes)
### `list_cities()` — locations.py
```python
from sqlalchemy import func
from app.models.property import Property
rows = (
db.session.query(City, func.count(Property.id).label("cnt"))
.outerjoin(
Property,
(Property.city_id == City.id) & (Property.is_active.is_(True))
)
.group_by(City.id)
.order_by(City.state, City.name)
.all()
)
return jsonify([
{**CityOut.model_validate(city).model_dump(), "property_count": cnt}
for city, cnt in rows
])
```
### `list_neighborhoods()` — locations.py
```python
q = (
db.session.query(Neighborhood, func.count(Property.id).label("cnt"))
.outerjoin(
Property,
(Property.neighborhood_id == Neighborhood.id) & (Property.is_active.is_(True))
)
.group_by(Neighborhood.id)
)
if city_id:
q = q.filter(Neighborhood.city_id == int(city_id))
rows = q.order_by(Neighborhood.name).all()
return jsonify([
{**NeighborhoodOut.model_validate(n).model_dump(), "property_count": cnt}
for n, cnt in rows
])
```
### `list_property_types()` — catalog.py
A hierarquia pai → subtypes torna a injeção de `property_count` ligeiramente diferente: o COUNT é calculado por `subtype_id` (leaf nodes), não por parent. Os tipos pai não recebem `property_count` (deixam default `0`).
```python
from sqlalchemy import func
from app.models.property import Property
# 1. Calcular counts por subtype em uma query plana
subtype_rows = (
db.session.query(
PropertyType.id,
func.count(Property.id).label("cnt")
)
.filter(PropertyType.parent_id.isnot(None)) # apenas leaf nodes
.outerjoin(
Property,
(Property.subtype_id == PropertyType.id) & (Property.is_active.is_(True))
)
.group_by(PropertyType.id)
.all()
)
count_map: dict[int, int] = {row.id: row.cnt for row in subtype_rows}
# 2. Buscar hierarquia normalmente
categories = (
PropertyType.query.filter_by(parent_id=None).order_by(PropertyType.id).all()
)
# 3. Serializar injetando property_count nos subtypes
def serialize_category(cat: PropertyType) -> dict:
data = PropertyTypeOut.model_validate(cat).model_dump(mode="json")
data["subtypes"] = [
{**sub, "property_count": count_map.get(sub["id"], 0)}
for sub in data["subtypes"]
]
return data
return jsonify([serialize_category(c) for c in categories])
```
---
## Frontend — Tipos TypeScript (catalog.ts)
```ts
// ANTES
export interface PropertyType {
id: number; name: string; slug: string
parent_id: number | null; subtypes: PropertyType[]
}
export interface City {
id: number; name: string; slug: string; state: string
}
export interface Neighborhood {
id: number; name: string; slug: string; city_id: number
}
// DEPOIS
export interface PropertyType {
id: number; name: string; slug: string
parent_id: number | null; subtypes: PropertyType[]
property_count?: number // ← NOVO (opcional — backward-compat)
}
export interface City {
id: number; name: string; slug: string; state: string
property_count?: number // ← NOVO
}
export interface Neighborhood {
id: number; name: string; slug: string; city_id: number
property_count?: number // ← NOVO
}
```
---
## Frontend — Tipos Internos (FilterSidebar.tsx)
Estes tipos são declarados localmente no arquivo `FilterSidebar.tsx` e não exportados.
### `SectionKey`
```ts
type SectionKey =
| 'imobiliaria'
| 'localizacao'
| 'tipo'
| 'preco'
| 'quartos'
| 'area'
| 'comodidades'
```
### `FilterSuggestion`
```ts
interface FilterSuggestion {
category: string // Label do grupo exibido na UI (ex.: "Tipo de imóvel")
sectionKey: SectionKey // Chave para expandir a seção correta ao selecionar
label: string // Texto exibido na sugestão
filterKey: keyof PropertyFilters
value: number | string | undefined
isAmenity?: boolean // true quando a ação é toggle em amenity_ids
amenityId?: number // preenchido quando isAmenity = true
}
```
**Mapeamento de categorias**:
| `category` (label UI) | `filterKey` | `sectionKey` | Fonte |
|-----------------------|-------------|--------------|-------|
| "Tipo de imóvel" | `subtype_id` | `tipo` | `propertyTypes[*].subtypes[*]` |
| "Cidade" | `city_id` | `localizacao` | `cities` |
| "Bairro" | `neighborhood_id` | `localizacao` | `neighborhoods` |
| "Comodidade" | n/a (`amenity_ids` toggle) | `comodidades` | `amenities` |
### `initOpenSections` — lógica de estado inicial
```ts
function initOpenSections(filters: PropertyFilters): Record<SectionKey, boolean> {
return {
imobiliaria: filters.imobiliaria_id != null,
localizacao: filters.city_id != null || filters.neighborhood_id != null,
tipo: filters.subtype_id != null,
preco: true, // sempre aberta (FR-009)
quartos: filters.bedrooms_min != null
|| filters.bathrooms_min != null
|| filters.parking_min != null,
area: filters.area_min != null || filters.area_max != null,
comodidades: (filters.amenity_ids?.length ?? 0) > 0,
}
}
```
---
## Estado do Banco de Dados
| Aspecto | Situação |
|---------|----------|
| Novas tabelas | Nenhuma |
| Migrations Alembic | Nenhuma |
| Colunas adicionadas | Nenhuma |
| Índices adicionados | Nenhum (os índices em `city_id`, `neighborhood_id`, `subtype_id` em `properties` já existem) |
| Triggers | Nenhum |
`property_count` é calculado em tempo de execução. O overhead da query é negligenciável:
- `cities`: esperado < 50 linhas × JOIN com `properties` (< 5 k linhas) < 5 ms
- `neighborhoods`: esperado < 200 linhas < 10 ms
- `property_types`: contagem por subtypes separada das categorias < 5 ms