270 lines
7.7 KiB
Markdown
270 lines
7.7 KiB
Markdown
# 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
|