shoc-backend/docs/spikes/search-strategy.md

182 lines
6.8 KiB
Markdown
Raw Normal View History

# Spike Search Strategy — `GetWorkOrdersBoard`
**Data:** 2026-06-19
**Gate:** G3 (pré-requisito Fase 1)
**Objetivo:** Definir busca mínima viável para o Schedule Board sem degradar performance.
---
## Estado atual (`GetWorkOrderList`)
Implementação em `SeaHaven.DataServices/Implementation/WorkOrderDataService.cs` (`GetWorkOrderListPagedAsync`):
| Aspecto | Comportamento atual |
|---------|---------------------|
| Paginação | `page` / `pageSize` (default 12), sem cap |
| Janela temporal | **Ausente** — sem filtro `ScheduledDate` |
| Search | `Contains` + `ToLower()` em 5 campos |
| Campos search | `InternalWONumber`, `WorkerOrderNumber`, `WorkerOrderTitle`, `Locations.Title`, `Locations.Name` |
| Campos omitidos | `SiteCode`, `Trade`, `Problem`, vendor, dispatcher por ID |
| Joins | `Locations`, `AssignToUser` (AspNetUsers) |
| Vendor/dispatch | **Não incluídos** |
| Projeção extra | Subquery `lastUpdated` via Comments + WorkOrderAuditLogs por linha |
### Padrão SQL gerado (search)
```sql
WHERE LOWER(InternalWONumber) LIKE '%term%'
OR LOWER(WorkerOrderNumber) LIKE '%term%'
OR LOWER(WorkerOrderTitle) LIKE '%term%'
OR LOWER(Locations.Name) LIKE '%term%'
...
```
Leading wildcard → table scan em colunas `nvarchar(max)`.
---
## Respostas às perguntas do spike
| # | Pergunta | Decisão Fase 1 |
|---|----------|----------------|
| S1 | Volume típico por semana | Assumir **< 200 WOs/semana** até medir em prod; threshold fallback = **300** |
| S2 | Campos pesquisáveis mínimos | `internalWONumber`, `siteCode`, `assigneeId[]` (exato), `locationId` |
| S3 | LIKE vs full-text | **Sem full-text** Fase 1; `StartsWith` em WO# e siteCode; evitar `Contains` em title/location no servidor |
| S4 | Escopo | Search **sempre scoped** a `scheduledFrom` / `scheduledTo` (obrigatório no endpoint) |
| S5 | Vendor/tech no search | **Fase 2** — exige join `Dispatches → Vendor` |
---
## Estratégia em 2 camadas
```
Request GetWorkOrdersBoard
│
├─► 1. Filtro janela ScheduledDate (obrigatório)
│
├─► 2. Filtros exatos: status, assigneeId[], locationId
│
├─► 3. COUNT no escopo
│
├─► count <= 300?
│ ├─ SIM → retornar semana inteira (sem paginação Fase 1)
│ │ search textual extra no FE: title, locationLabel, serviceType
│ └─ NÃO → exigir critério indexável no servidor
│ (woNumber ou siteCode StartsWith)
│
└─► Response: { scheduledFrom, scheduledTo, totalCount, items[] }
```
### Campos Fase 1 vs Fase 2
| Campo | Fase 1 (servidor) | Fase 1 (client fallback) | Fase 2 |
|-------|-------------------|--------------------------|--------|
| `internalWONumber` / `workerOrderNumber` | `StartsWith` | — | — |
| `siteCode` | `StartsWith` ou igualdade | — | — |
| `assigneeId` | multi-select exato | — | — |
| `locationId` | exato | — | — |
| `status` | exato / multi | pills client-side | — |
| `workerOrderTitle` | — | client-side se count ≤ 300 | server `Contains` opcional |
| `locationLabel` | — | client-side | — |
| `serviceType` (trade/category) | — | client-side | server join |
| `vendorCompany` / `vendorTechnician` | — | — | join Dispatch→Vendor |
| `assigneeName` | — | client-side | evitar join por nome |
---
## Query plan esperado (Fase 1)
### Caminho feliz (semana típica, ≤ 300 rows)
1. **Index seek** em `IX_workOrders_ScheduledDate` (proposto) com range `[scheduledFrom, scheduledTo+1day)`
2. Filtro `istemplate != true`
3. Filtros exatos em `AssignTo`, `LocationId`, `Status` (AND)
4. **Sem** search textual no servidor se `search` vazio
5. Join LEFT `Locations`, `AssignToUser`, LEFT `Dispatches` + `Vendor` (Fase 1 board — vendor no DTO, não no search)
6. Projeção flat para `WorkOrderBoardRowDto` — **sem** subquery `lastUpdated`
### Caminho search servidor (count > 300 ou user digitou termo indexável)
1. Mesma janela + filtros exatos
2. AND (`InternalWONumber LIKE 'term%'` OR `SiteCode LIKE 'term%'`)
3. Se count ainda > 300 → HTTP 400 com mensagem "Refine search or narrow date window"
### Anti-padrões proibidos no board endpoint
- `pageSize=500` como gambiarra de fetch semanal
- `Contains('%x%')` em múltiplas tabelas sem janela temporal
- Filtro assignee por nome concatenado (`FirstName + LastName`) — usar `assigneeId`
- Subquery correlated Comments/AuditLog por row
---
## Índices recomendados
Migration separada **após aprovação** desta spike (não incluir na Fase 1 code sem review):
| Índice | Coluna(s) | Prioridade | Nota |
|--------|-----------|------------|------|
| `IX_workOrders_ScheduledDate` | `ScheduledDate` | **P1** | Filtro de janela do board |
| `IX_workOrders_InternalWONumber` | `InternalWONumber` | P2 | Requer alterar coluna de `nvarchar(max)` → `nvarchar(50)` |
| `IX_workOrders_SiteCode` | `SiteCode` | P2 | Idem — tamanho fixo ~50 |
| `IX_workOrders_AssignTo` | `AssignTo` | existente | Multi-select dispatcher |
| `IX_workOrders_LocationId` | `LocationId` | existente | Filtro location |
**Full-text index:** não recomendado Fase 1 — volume semanal baixo + fallback client-side suficiente.
---
## Referência de implementação
Copiar padrão de janela temporal de `DispatchController.List`:
```csharp
if (dateFrom.HasValue)
q = q.Where(d => d.DispatchedAt >= dateFrom.Value);
if (dateTo.HasValue)
{
var end = dateTo.Value.Date.AddDays(1);
q = q.Where(d => d.DispatchedAt < end);
}
```
Adaptar para `WorkOrder.ScheduledDate` no board endpoint.
---
## Parâmetros propostos — `GetWorkOrdersBoard`
| Parâmetro | Tipo | Obrigatório | Descrição |
|-----------|------|-------------|-----------|
| `scheduledFrom` | `DateOnly` | **Sim** | Início da semana/janela |
| `scheduledTo` | `DateOnly` | **Sim** | Fim da janela (inclusivo) |
| `search` | `string` | Não | WO# ou siteCode prefix |
| `assignee` | `string[]` | Não | IDs de dispatcher (multi) |
| `status` | `string[]` | Não | Status canônico API |
| `locationId` | `int?` | Não | Filtro location |
**Limite hard janela:** max **90 dias** (Fase 2 advanced search cross-week).
---
## Threshold e fallback
| Constante | Valor | Justificativa |
|-----------|-------|---------------|
| `BoardSearchClientSideThreshold` | **300** | Payload ~300 rows × ~500 bytes ≈ 150 KB — aceitável para 1 call/semana |
| Sem paginação Fase 1 | — | Janela semanal é o filtro natural |
| Paginação Fase 2 | cursor/offset | Só quando janela > 1 semana com search global |
---
## Conclusão
Fase 1 adota **window-first, search-second**:
1. Janela `ScheduledDate` obrigatória + índice dedicado
2. Search servidor mínimo (`StartsWith` em identificadores)
3. Fallback client-side para campos ricos quando count ≤ 300
4. Vendor/tech e full-text adiados para Fase 2
Esta estratégia desbloqueia `GetWorkOrdersBoard` sem replicar os anti-padrões de `GetWorkOrderListPagedAsync`.