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