fix(work-orders): address stacked PR review feedback

This commit is contained in:
Arthur Bassi 2026-07-17 14:31:08 -03:00
parent ceeb0c8fa3
commit 4bd11dcf6b
16 changed files with 3308 additions and 99 deletions

View file

@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Components.Forms;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.VisualBasic; using Microsoft.VisualBasic;
@ -92,7 +93,7 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet("board")] [HttpGet("board")]
public async Task<IActionResult> GetBoard( public async Task<IActionResult> GetBoard(
[FromQuery] DateOnly weekStart, [FromQuery, BindRequired] DateOnly weekStart,
[FromQuery] DateOnly? weekEnd = null, [FromQuery] DateOnly? weekEnd = null,
[FromQuery] List<string>? dispatchers = null, [FromQuery] List<string>? dispatchers = null,
[FromQuery] bool myWorkOrders = false, [FromQuery] bool myWorkOrders = false,
@ -137,6 +138,14 @@ namespace Api.SeaHavenIndustries.Controllers
CurrentState = ex.CurrentState CurrentState = ex.CurrentState
}); });
} }
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (WorkOrderBoardValidationException ex) catch (WorkOrderBoardValidationException ex)
{ {
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
@ -173,6 +182,14 @@ namespace Api.SeaHavenIndustries.Controllers
Message = ex.Message Message = ex.Message
}); });
} }
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (WorkOrderBoardValidationException ex) catch (WorkOrderBoardValidationException ex)
{ {
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
@ -192,6 +209,14 @@ namespace Api.SeaHavenIndustries.Controllers
var row = await _boardCancelService.CancelAsync(id, actorId); var row = await _boardCancelService.CancelAsync(id, actorId);
return Ok(row); return Ok(row);
} }
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (WorkOrderBoardValidationException ex) catch (WorkOrderBoardValidationException ex)
{ {
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto return UnprocessableEntity(new WorkOrderBoardValidationErrorDto

View file

@ -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;
}
}
}

View file

@ -25,6 +25,7 @@ builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
builder.Services.AddControllers(options => builder.Services.AddControllers(options =>
{ {
options.Filters.Add<Api.SeaHavenIndustries.Filters.ConcurrencyExceptionFilter>(); options.Filters.Add<Api.SeaHavenIndustries.Filters.ConcurrencyExceptionFilter>();
options.Filters.Add<Api.SeaHavenIndustries.Filters.ArgumentExceptionFilter>();
}).AddJsonOptions(options => }).AddJsonOptions(options =>
{ {
options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value

View file

@ -0,0 +1,14 @@
namespace Data.SeaHavenIndustries.Enums
{
/// <summary>
/// Shared terminal lifecycle statuses for EF-translatable filters and board rules.
/// </summary>
public static class LifecycleStatusSets
{
public static readonly LifecycleStatus?[] Terminal =
{
LifecycleStatus.Completed,
LifecycleStatus.Canceled
};
}
}

View file

@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Data.SeaHavenIndustries.Migrations namespace Data.SeaHavenIndustries.Migrations
{ {
public class AddWorkOrderTypeAndScheduledDateIndex : Migration public partial class AddWorkOrderTypeAndScheduledDateIndex : Migration
{ {
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {

View file

@ -1,7 +0,0 @@
namespace SeaHaven.DataServices.Board
{
public static class WorkOrderBoardConstants
{
public const int ClientSideThreshold = 300;
}
}

View file

@ -1,10 +0,0 @@
namespace SeaHaven.DataServices.DTOs
{
public record WorkOrderBoardQuery(
DateOnly ScheduledFrom,
DateOnly ScheduledTo,
string? Search,
IReadOnlyList<string>? AssigneeIds,
IReadOnlyList<string>? DbStatuses,
int? LocationId);
}

View file

@ -1,20 +0,0 @@
namespace SeaHaven.DataServices.DTOs
{
public record WorkOrderBoardRowData(
int Id,
string? WoNumber,
string? SiteCode,
string? LocationLabel,
DateTime? ScheduledDate,
DateTime? ScheduledStart,
DateTime? DueDate,
string? WorkOrderType,
string? ServiceType,
string? AssigneeId,
string? AssigneeName,
string? VendorCompany,
string? VendorTechnician,
string? Status,
string? PocName,
string? PocPhone);
}

View file

@ -33,8 +33,7 @@ namespace SeaHaven.DataServices.Implementation
var unscheduledBase = baseQuery.Where(w => var unscheduledBase = baseQuery.Where(w =>
w.ScheduledDate == null w.ScheduledDate == null
&& w.LifecycleStatus != LifecycleStatus.Completed && !LifecycleStatusSets.Terminal.Contains(w.LifecycleStatus));
&& w.LifecycleStatus != LifecycleStatus.Canceled);
var scheduledTotal = await scheduledBase.CountAsync(); var scheduledTotal = await scheduledBase.CountAsync();

View file

@ -64,7 +64,11 @@ namespace SeaHaven.Services.Helpers
public static List<BoardFieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder) public static List<BoardFieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
{ {
var changes = new List<BoardFieldChange>(); var changes = new List<BoardFieldChange>();
if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(workOrder.LifecycleStatus, workOrder.ScheduledDate, workOrder.AssignTo)) if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(
workOrder.LifecycleStatus,
workOrder.ScheduledDate,
workOrder.AssignTo,
workOrder.ScheduleWeekOnly))
return changes; return changes;
var old = workOrder.LifecycleStatus?.ToString(); var old = workOrder.LifecycleStatus?.ToString();

View file

@ -4,13 +4,8 @@ namespace SeaHaven.Services.Helpers
{ {
public static class WorkOrderBoardMutationRules public static class WorkOrderBoardMutationRules
{ {
private static readonly HashSet<LifecycleStatus?> ReadOnlyStatuses = new() public static bool IsReadOnly(LifecycleStatus? status)
{ => status.HasValue && LifecycleStatusSets.Terminal.Contains(status);
LifecycleStatus.Canceled,
LifecycleStatus.Completed
};
public static bool IsReadOnly(LifecycleStatus? status) => ReadOnlyStatuses.Contains(status);
public static bool ShouldBlockStatusChangeWhenPastDue(string field, bool isPastDue) public static bool ShouldBlockStatusChangeWhenPastDue(string field, bool isPastDue)
=> field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue; => field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue;
@ -18,10 +13,12 @@ namespace SeaHaven.Services.Helpers
public static bool ShouldAutoSchedule( public static bool ShouldAutoSchedule(
LifecycleStatus? status, LifecycleStatus? status,
DateTime? scheduledDate, DateTime? scheduledDate,
string? assignTo) string? assignTo,
bool? scheduleWeekOnly = null)
=> status == LifecycleStatus.Incomplete => status == LifecycleStatus.Incomplete
&& scheduledDate.HasValue && scheduledDate.HasValue
&& !string.IsNullOrWhiteSpace(assignTo); && !string.IsNullOrWhiteSpace(assignTo)
&& scheduleWeekOnly != true;
public static bool IsReschedule(DateTime? previousDate, DateTime? newDate) public static bool IsReschedule(DateTime? previousDate, DateTime? newDate)
=> previousDate.HasValue => previousDate.HasValue

View file

@ -30,8 +30,15 @@ namespace SeaHaven.Services.Implementation
WorkOrderBoardPatchRequestDto request, WorkOrderBoardPatchRequestDto request,
string? actorId) string? actorId)
{ {
if (string.IsNullOrWhiteSpace(request.Field)) // Intermediate SaveChanges (e.g. new dispatch identity) must stay atomic with the final patch save.
throw new WorkOrderBoardValidationException("InvalidField", "Field is required."); await using var transaction = _context.Database.IsRelational()
? await _context.Database.BeginTransactionAsync()
: null;
try
{
if (string.IsNullOrWhiteSpace(request.Field))
throw new WorkOrderBoardValidationException("InvalidField", "Field is required.");
var field = request.Field.Trim(); var field = request.Field.Trim();
var canonicalField = WorkOrderBoardFieldNames.Canonicalize(field); var canonicalField = WorkOrderBoardFieldNames.Canonicalize(field);
@ -91,48 +98,68 @@ namespace SeaHaven.Services.Implementation
var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField); var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField);
changes = changes.Where(c => c.HasChanged).ToList(); changes = changes.Where(c => c.HasChanged).ToList();
if (changes.Count == 0 && !_context.ChangeTracker.HasChanges()) if (changes.Count == 0 && !_context.ChangeTracker.HasChanges())
{
var unchanged = await LoadBoardRowAsync(workOrderId);
return unchanged ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
if (changes.Count == 0)
{
await _context.SaveChangesAsync();
var persisted = await LoadBoardRowAsync(workOrderId);
return persisted ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
foreach (var change in changes)
{
switch (change.Action)
{ {
case AuditActionType.StatusChanged: var unchanged = await LoadBoardRowAsync(workOrderId);
_auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId); if (transaction is not null)
break; await transaction.CommitAsync();
case AuditActionType.AssignmentChanged: return unchanged ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
_auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId);
break;
default:
_auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, change.DispatchId);
break;
} }
}
try if (changes.Count == 0)
{ {
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} if (transaction is not null)
catch (DbUpdateConcurrencyException) await transaction.CommitAsync();
{ var persisted = await LoadBoardRowAsync(workOrderId);
_context.ChangeTracker.Clear(); return persisted ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
var currentState = await LoadBoardRowAsync(workOrderId); }
throw new WorkOrderBoardConcurrencyException(currentState);
}
var row = await LoadBoardRowAsync(workOrderId); foreach (var change in changes)
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); {
switch (change.Action)
{
case AuditActionType.StatusChanged:
_auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId);
break;
case AuditActionType.AssignmentChanged:
_auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId);
break;
default:
_auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, change.DispatchId);
break;
}
}
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (transaction is not null)
await transaction.RollbackAsync();
_context.ChangeTracker.Clear();
var currentState = await LoadBoardRowAsync(workOrderId);
throw new WorkOrderBoardConcurrencyException(currentState);
}
if (transaction is not null)
await transaction.CommitAsync();
var row = await LoadBoardRowAsync(workOrderId);
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
catch (WorkOrderBoardConcurrencyException)
{
throw;
}
catch
{
if (transaction is not null)
await transaction.RollbackAsync();
throw;
}
} }
private async Task<List<FieldChange>> ApplyFieldMutationAsync( private async Task<List<FieldChange>> ApplyFieldMutationAsync(
@ -153,7 +180,7 @@ namespace SeaHaven.Services.Implementation
WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField), WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField),
WorkOrderBoardFieldNames.TargetWeek => new List<FieldChange> { ApplyTargetWeek(workOrder, value, auditField) }, WorkOrderBoardFieldNames.TargetWeek => new List<FieldChange> { ApplyTargetWeek(workOrder, value, auditField) },
WorkOrderBoardFieldNames.ScheduleWeekOnly => new List<FieldChange> { ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly) }, WorkOrderBoardFieldNames.ScheduleWeekOnly => new List<FieldChange> { ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly) },
WorkOrderBoardFieldNames.VendorId => new List<FieldChange> { ApplyVendorId(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.VendorId => new List<FieldChange> { await ApplyVendorIdAsync(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptTime => new List<FieldChange> { ApplyApptTime(workOrder, dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptTime => new List<FieldChange> { ApplyApptTime(workOrder, dispatch!, value, auditField) },
WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { ApplyDocStatus(workOrder, value, auditField) }, WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { ApplyDocStatus(workOrder, value, auditField) },
@ -187,6 +214,8 @@ namespace SeaHaven.Services.Implementation
if (!vendorIdForCreate.HasValue || vendorIdForCreate.Value <= 0) if (!vendorIdForCreate.HasValue || vendorIdForCreate.Value <= 0)
throw new WorkOrderBoardValidationException("DispatchRequired", "A primary dispatch is required. Set vendorId first or provide primaryDispatchId."); throw new WorkOrderBoardValidationException("DispatchRequired", "A primary dispatch is required. Set vendorId first or provide primaryDispatchId.");
await EnsureVendorExistsAsync(vendorIdForCreate.Value);
var created = new Dispatch var created = new Dispatch
{ {
WorkOrderId = workOrder.Id, WorkOrderId = workOrder.Id,
@ -200,6 +229,13 @@ namespace SeaHaven.Services.Implementation
return created; return created;
} }
private async Task EnsureVendorExistsAsync(int vendorId)
{
var vendorExists = await _context.Vendors.AnyAsync(v => v.Id == vendorId && v.IsDeleted != true);
if (!vendorExists)
throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist.");
}
private async Task<FieldChange> ApplyWoNumber(WorkOrder workOrder, string? value, string auditField) private async Task<FieldChange> ApplyWoNumber(WorkOrder workOrder, string? value, string auditField)
{ {
if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error)) if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error))
@ -384,12 +420,14 @@ namespace SeaHaven.Services.Implementation
return FieldChange.ForField(auditField, old, parsed.ToString()); return FieldChange.ForField(auditField, old, parsed.ToString());
} }
private static FieldChange ApplyVendorId(Dispatch dispatch, string? value, string auditField) private async Task<FieldChange> ApplyVendorIdAsync(Dispatch dispatch, string? value, string auditField)
{ {
var vendorId = ParseVendorIdHint(value); var vendorId = ParseVendorIdHint(value);
if (!vendorId.HasValue) if (!vendorId.HasValue)
throw new WorkOrderBoardValidationException("InvalidValue", "vendorId must be a positive integer."); throw new WorkOrderBoardValidationException("InvalidValue", "vendorId must be a positive integer.");
await EnsureVendorExistsAsync(vendorId.Value);
var old = dispatch.VendorId.ToString(); var old = dispatch.VendorId.ToString();
if (dispatch.VendorId == vendorId.Value) if (dispatch.VendorId == vendorId.Value)
return FieldChange.Unchanged(auditField, dispatch.Id); return FieldChange.Unchanged(auditField, dispatch.Id);
@ -459,7 +497,11 @@ namespace SeaHaven.Services.Implementation
private static List<FieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder) private static List<FieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
{ {
var changes = new List<FieldChange>(); var changes = new List<FieldChange>();
if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(workOrder.LifecycleStatus, workOrder.ScheduledDate, workOrder.AssignTo)) if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(
workOrder.LifecycleStatus,
workOrder.ScheduledDate,
workOrder.AssignTo,
workOrder.ScheduleWeekOnly))
return changes; return changes;
var old = workOrder.LifecycleStatus?.ToString(); var old = workOrder.LifecycleStatus?.ToString();

View file

@ -42,6 +42,16 @@ public class WorkOrderBoardMutationRulesTests
"dispatcher-1")); "dispatcher-1"));
} }
[Fact]
public void ShouldAutoSchedule_FalseWhenWeekOnly()
{
Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule(
LifecycleStatus.Incomplete,
new DateTime(2026, 6, 25),
"dispatcher-1",
scheduleWeekOnly: true));
}
[Fact] [Fact]
public void IsReschedule_WhenPreviousDateExistsAndChanges() public void IsReschedule_WhenPreviousDateExistsAndChanges()
{ {

View file

@ -224,4 +224,142 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal("DuplicateWoNumber", ex.Code); Assert.Equal("DuplicateWoNumber", ex.Code);
} }
[Fact]
public async Task PatchField_WeekOnly_DoesNotAutoScheduleOnAssignTo()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
ScheduledDate = new DateTime(2026, 6, 25),
ScheduleWeekOnly = true,
TargetWeek = new DateOnly(2026, 6, 22),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.AssignTo,
Value = "user-a",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus);
Assert.Equal("user-a", result.DispatcherId);
}
[Fact]
public async Task PatchField_ApptTime_UpdatesScheduledStart()
{
var (context, service) = CreateSut();
var dispatch = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
ScheduledDate = new DateTime(2026, 6, 25),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
ScheduledDate = new DateTime(2026, 6, 25),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
context.Dispatches.Add(dispatch);
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ApptTime,
Value = "09:00-10:00",
WorkOrderVersion = ToVersion(wo),
DispatchVersion = ToVersion(dispatch),
PrimaryDispatchId = 10
}, "actor-1");
Assert.Contains("09:00", result.ApptTime);
var reloaded = await context.workOrders.FindAsync(1);
Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), reloaded!.ScheduledStart);
Assert.Equal(new DateTime(2026, 6, 25, 10, 0, 0), reloaded.ScheduledEnd);
}
[Fact]
public async Task PatchField_DispatchFieldWithoutVersion_ThrowsDispatchVersionRequired()
{
var (context, service) = CreateSut();
var dispatch = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
context.Dispatches.Add(dispatch);
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ApptDate,
Value = "2026-06-26",
WorkOrderVersion = ToVersion(wo),
PrimaryDispatchId = 10
}, "actor-1"));
Assert.Equal("DispatchVersionRequired", ex.Code);
}
[Fact]
public async Task PatchField_VendorIdNotFound_Throws()
{
var (context, service) = CreateSut();
var dispatch = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
context.Dispatches.Add(dispatch);
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.VendorId,
Value = "999",
WorkOrderVersion = ToVersion(wo),
DispatchVersion = ToVersion(dispatch),
PrimaryDispatchId = 10
}, "actor-1"));
Assert.Equal("VendorNotFound", ex.Code);
}
} }

View file

@ -17,10 +17,10 @@ Retorna WOs agendadas na semana + seção **Unscheduled** em uma única chamada.
| Param | Obrigatório | Descrição | | Param | Obrigatório | Descrição |
|-------|-------------|-----------| |-------|-------------|-----------|
| `weekStart` | Sim | Segunda-feira da semana (`YYYY-MM-DD`) | | `weekStart` | Sim (`BindRequired`) | Segunda-feira da semana (`YYYY-MM-DD`). Omitir retorna **400**. |
| `weekEnd` | Não | Sexta-feira (default: `weekStart + 4 dias`) | | `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 | | `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` | | `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.) | | `types` | Não | Valores enum `WorkOrderType` (`PM`, `PO`, `Emergency`, etc.) |
| `search` | Não | Busca contextual (site, WO#, location, dispatcher, trade, vendor, status) | | `search` | Não | Busca contextual (site, WO#, location, dispatcher, trade, vendor, status) |
@ -45,10 +45,14 @@ curl -H "Authorization: Bearer <token>" \
- `counts.total` — WOs agendadas na semana (filtros dispatcher/tipo, **sem** search) - `counts.total` — WOs agendadas na semana (filtros dispatcher/tipo, **sem** search)
- `counts.returned` — WOs agendadas após aplicar 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` ### `GET /api/workorders/lookups/dispatchers`
Lista dispatchers para filtros do board. 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 ```json
[ [