From f3d2a5cfe738cf677a3f1af2b3c90beddef2f7ca Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 14:39:47 -0300 Subject: [PATCH] fix(work-orders): address PR13 phase-1 review feedback --- .../Controllers/WorkOrderController.cs | 3 +- .../Filters/ArgumentExceptionFilter.cs | 21 +++ Api.SeaHavenIndustries/Program.cs | 1 + .../Enums/LifecycleStatusSets.cs | 14 ++ .../WorkOrderBoardDataService.cs | 3 +- docs/work-orders/phase-1/README.md | 159 ++++++++++++++++++ 6 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs create mode 100644 Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs create mode 100644 docs/work-orders/phase-1/README.md diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 1989d84..f72501c 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Microsoft.VisualBasic; @@ -83,7 +84,7 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("board")] public async Task GetBoard( - [FromQuery] DateOnly weekStart, + [FromQuery, BindRequired] DateOnly weekStart, [FromQuery] DateOnly? weekEnd = null, [FromQuery] List? dispatchers = null, [FromQuery] bool myWorkOrders = false, diff --git a/Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs b/Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs new file mode 100644 index 0000000..0bfeabb --- /dev/null +++ b/Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Api.SeaHavenIndustries.Filters +{ + public class ArgumentExceptionFilter : IExceptionFilter + { + public void OnException(ExceptionContext context) + { + if (context.Exception is not ArgumentException ex) return; + + context.Result = new BadRequestObjectResult(new + { + status = "Error", + message = ex.Message, + code = 400 + }); + context.ExceptionHandled = true; + } + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 6dc0e95..55d5a4d 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -25,6 +25,7 @@ builder.Services.AddIdentity(options => builder.Services.AddControllers(options => { options.Filters.Add(); + options.Filters.Add(); }).AddJsonOptions(options => { options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value diff --git a/Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs b/Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs new file mode 100644 index 0000000..d4a93bc --- /dev/null +++ b/Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs @@ -0,0 +1,14 @@ +namespace Data.SeaHavenIndustries.Enums +{ + /// + /// Shared terminal lifecycle statuses for EF-translatable filters and board rules. + /// + public static class LifecycleStatusSets + { + public static readonly LifecycleStatus?[] Terminal = + { + LifecycleStatus.Completed, + LifecycleStatus.Canceled + }; + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs index 8fd0422..be23363 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs @@ -33,8 +33,7 @@ namespace SeaHaven.DataServices.Implementation var unscheduledBase = baseQuery.Where(w => w.ScheduledDate == null - && w.LifecycleStatus != LifecycleStatus.Completed - && w.LifecycleStatus != LifecycleStatus.Canceled); + && !LifecycleStatusSets.Terminal.Contains(w.LifecycleStatus)); var scheduledTotal = await scheduledBase.CountAsync(); diff --git a/docs/work-orders/phase-1/README.md b/docs/work-orders/phase-1/README.md new file mode 100644 index 0000000..660bfb3 --- /dev/null +++ b/docs/work-orders/phase-1/README.md @@ -0,0 +1,159 @@ +# Fase 1 — Weekly Board (leitura) + +**Programa:** Work Orders Board +**Objetivo:** Endpoint window-based para carregar a visão semanal do SHOC (Mon–Fri + Unscheduled) com projeção das 14 colunas operacionais. + +--- + +## Endpoints + +### `GET /api/workorders/board` + +Retorna WOs agendadas na semana + seção **Unscheduled** em uma única chamada. + +**Autenticação:** Bearer JWT (`[Authorize]`) + +**Query params:** + +| Param | Obrigatório | Descrição | +|-------|-------------|-----------| +| `weekStart` | Sim (`BindRequired`) | Segunda-feira da semana (`YYYY-MM-DD`). Omitir retorna **400**. | +| `weekEnd` | Não | Default: `weekStart + 4 dias` (sexta). Janela pode ter até 7 dias. | +| `dispatchers` | Não | Lista de GUIDs; use `__unassigned__` para não atribuídos. **Ignorado** quando `myWorkOrders=true`. | +| `myWorkOrders` | Não | `true` → filtra `AssignTo == usuário logado` e tem **precedência** sobre `dispatchers` (não é AND). | +| `types` | Não | Valores enum `WorkOrderType` (`PM`, `PO`, `Emergency`, etc.) | +| `search` | Não | Busca contextual (site, WO#, location, dispatcher, trade, vendor, status) | + +**Exemplo:** + +```bash +curl -H "Authorization: Bearer " \ + "https://localhost:5001/api/workorders/board?weekStart=2026-06-22&myWorkOrders=true" +``` + +**Response:** + +```json +{ + "weekStart": "2026-06-22", + "weekEnd": "2026-06-26", + "counts": { "returned": 42, "total": 58 }, + "unscheduled": [ /* WorkOrderBoardRowDto[] */ ], + "scheduled": [ /* WorkOrderBoardRowDto[] — agrupar por dayGroup no FE */ ] +} +``` + +- `counts.total` — WOs agendadas na semana (filtros dispatcher/tipo, **sem** search) +- `counts.returned` — WOs agendadas após aplicar search +- `dayGroup` — `monday`…`friday` para dias úteis; **`null` no sábado/domingo** (a janela pode incluir fim de semana; o FE deve tratar `dayGroup` nulo) +- Referência de dia / past-due: **UTC** nesta fase (timezone de negócio fica para fase posterior) + +### `GET /api/workorders/lookups/dispatchers` + +Lista opções para o filtro/dropdown de assignee do board. + +**Escopo atual (placeholder):** todos os users com `IsDeleted != true` — ainda **sem** filtro por role. Quando roles de assignee estiverem definidos, este lookup será restrito. + +```json +[ + { "id": "guid", "name": "Jane Doe", "initials": "JD", "color": "#4A90D9" } +] +``` + +--- + +## Matriz coluna → campo API + +| Coluna board | Campos response | +|--------------|-----------------| +| WO# | `woNumber`, `rescheduleCount`, `carriedOver` | +| Type | `workOrderType`, `isPastDue` | +| Site | `siteCode`, `locationName`, `pocName`, `pocPhone`, `pocNotes` | +| Status | `lifecycleStatus`, `lifecycleStatusLabel`, `legacyStatus` | +| Assignee | `dispatcherId`, `dispatcherName`, `initials`, `color` | +| Due | `dueDate` | +| Scheduled | `scheduledDate`, `targetWeek`, `scheduleWeekOnly`, `dayGroup` | +| Vendor | `vendorId`, `vendorName`, `techName`, `techPhone` | +| Appt | `apptDate`, `apptTime` | +| Past Due | `isPastDue` (derivado on-read) | +| Carried | `carriedOver` | +| Reschedule | `rescheduleCount` | +| Doc | `docStatus` | +| Actions | FE only | + +--- + +## Regras de derivação + +### isPastDue + +``` +ScheduledDate < UTC hoje +AND LifecycleStatus NOT IN (Complete, Canceled, Closed) +``` + +Implementação: `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs` + +### Vendor / Appt + +Projeção read-only via `WorkOrder.PrimaryDispatchId` → `Dispatch` → `Vendor` ([ADR](../phase-0/adr-vendor-source-of-truth.md)). + +### Janela semanal + +WO entra em **scheduled** se: + +- `ScheduledDate` entre `weekStart` e `weekEnd`, **ou** +- `ScheduleWeekOnly == true` e `TargetWeek == weekStart` + +**Unscheduled:** `ScheduledDate == null` e status não terminal (`LifecycleStatusSets.Terminal`). + +--- + +## Código entregue + +| Camada | Arquivo | +|--------|---------| +| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` | +| Derived fields | `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs` | +| Data | `SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs` | +| Service | `SeaHaven.Services/Implementation/WorkOrderBoardService.cs` | +| API | `Api.SeaHavenIndustries/Controllers/WorkOrderController.cs` (`board`, `lookups/dispatchers`) | +| Migration | `Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs` | +| Testes | `SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs`, `WorkOrderBoardServiceTests.cs` | + +--- + +## Gates de aceite + +### Desenvolvimento (concluído) + +- [x] Endpoint `GET board` — semana + Unscheduled +- [x] Projeção 12/14 colunas de dados +- [x] `isPastDue` derivado on-read +- [x] Vendor via dispatch primário +- [x] Filtros dispatcher / My WOs / tipo / search +- [x] Contador X of Y +- [x] Índices Tier S +- [x] 14 testes unitários board (+ 12 Fase 0 = 26 total) + +### Staging / produção (pendente) + +- [ ] Sign-off CTO/PO (herda gates Fase 0) +- [ ] Dry-run migration Phase0 + Phase1 em staging +- [ ] Tier volume confirmado em staging/prod +- [ ] Smoke checklist Blazor / Sync / Portal +- [ ] Gate Vendor Portal regression (endpoints dispatch inalterados) +- [ ] Latência aceitável com volume real (Tier S: < 25k WOs) +- [ ] Feature flag SHOC board em staging + +--- + +## Coexistência + +Endpoints legados (`GetWorkOrderList`, `GetWorkorderById`, etc.) **não foram alterados**. O board usa contrato novo em rotas separadas. + +--- + +## Próximo passo + +**Fase 2** — Inline edit + concorrência (dual RowVersion, audit por campo, PATCH granular).