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

181 lines
6.8 KiB
Markdown
Raw 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.

# 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`.