mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 09:33:13 +00:00
fix(work-orders): address stacked PR review feedback
This commit is contained in:
parent
ceeb0c8fa3
commit
4bd11dcf6b
16 changed files with 3308 additions and 99 deletions
|
|
@ -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;
|
||||
|
|
@ -92,7 +93,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
|
||||
[HttpGet("board")]
|
||||
public async Task<IActionResult> GetBoard(
|
||||
[FromQuery] DateOnly weekStart,
|
||||
[FromQuery, BindRequired] DateOnly weekStart,
|
||||
[FromQuery] DateOnly? weekEnd = null,
|
||||
[FromQuery] List<string>? dispatchers = null,
|
||||
[FromQuery] bool myWorkOrders = false,
|
||||
|
|
@ -137,6 +138,14 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
CurrentState = ex.CurrentState
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
|
||||
|
|
@ -173,6 +182,14 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
|
||||
|
|
@ -192,6 +209,14 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
var row = await _boardCancelService.CancelAsync(id, actorId);
|
||||
return Ok(row);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
|
||||
|
|
|
|||
21
Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs
Normal file
21
Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
|
|||
builder.Services.AddControllers(options =>
|
||||
{
|
||||
options.Filters.Add<Api.SeaHavenIndustries.Filters.ConcurrencyExceptionFilter>();
|
||||
options.Filters.Add<Api.SeaHavenIndustries.Filters.ArgumentExceptionFilter>();
|
||||
}).AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value
|
||||
|
|
|
|||
14
Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs
Normal file
14
Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
2991
Data.SeaHavenIndustries/Migrations/20260619150000_AddWorkOrderTypeAndScheduledDateIndex.Designer.cs
generated
Normal file
2991
Data.SeaHavenIndustries/Migrations/20260619150000_AddWorkOrderTypeAndScheduledDateIndex.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
|||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
public class AddWorkOrderTypeAndScheduledDateIndex : Migration
|
||||
public partial class AddWorkOrderTypeAndScheduledDateIndex : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
namespace SeaHaven.DataServices.Board
|
||||
{
|
||||
public static class WorkOrderBoardConstants
|
||||
{
|
||||
public const int ClientSideThreshold = 300;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,11 @@ namespace SeaHaven.Services.Helpers
|
|||
public static List<BoardFieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
|
||||
{
|
||||
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;
|
||||
|
||||
var old = workOrder.LifecycleStatus?.ToString();
|
||||
|
|
|
|||
|
|
@ -4,13 +4,8 @@ namespace SeaHaven.Services.Helpers
|
|||
{
|
||||
public static class WorkOrderBoardMutationRules
|
||||
{
|
||||
private static readonly HashSet<LifecycleStatus?> ReadOnlyStatuses = new()
|
||||
{
|
||||
LifecycleStatus.Canceled,
|
||||
LifecycleStatus.Completed
|
||||
};
|
||||
|
||||
public static bool IsReadOnly(LifecycleStatus? status) => ReadOnlyStatuses.Contains(status);
|
||||
public static bool IsReadOnly(LifecycleStatus? status)
|
||||
=> status.HasValue && LifecycleStatusSets.Terminal.Contains(status);
|
||||
|
||||
public static bool ShouldBlockStatusChangeWhenPastDue(string field, bool isPastDue)
|
||||
=> field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue;
|
||||
|
|
@ -18,10 +13,12 @@ namespace SeaHaven.Services.Helpers
|
|||
public static bool ShouldAutoSchedule(
|
||||
LifecycleStatus? status,
|
||||
DateTime? scheduledDate,
|
||||
string? assignTo)
|
||||
string? assignTo,
|
||||
bool? scheduleWeekOnly = null)
|
||||
=> status == LifecycleStatus.Incomplete
|
||||
&& scheduledDate.HasValue
|
||||
&& !string.IsNullOrWhiteSpace(assignTo);
|
||||
&& !string.IsNullOrWhiteSpace(assignTo)
|
||||
&& scheduleWeekOnly != true;
|
||||
|
||||
public static bool IsReschedule(DateTime? previousDate, DateTime? newDate)
|
||||
=> previousDate.HasValue
|
||||
|
|
|
|||
|
|
@ -30,8 +30,15 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderBoardPatchRequestDto request,
|
||||
string? actorId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Field))
|
||||
throw new WorkOrderBoardValidationException("InvalidField", "Field is required.");
|
||||
// Intermediate SaveChanges (e.g. new dispatch identity) must stay atomic with the final patch save.
|
||||
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 canonicalField = WorkOrderBoardFieldNames.Canonicalize(field);
|
||||
|
|
@ -91,48 +98,68 @@ namespace SeaHaven.Services.Implementation
|
|||
var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField);
|
||||
changes = changes.Where(c => c.HasChanged).ToList();
|
||||
|
||||
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)
|
||||
if (changes.Count == 0 && !_context.ChangeTracker.HasChanges())
|
||||
{
|
||||
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;
|
||||
var unchanged = await LoadBoardRowAsync(workOrderId);
|
||||
if (transaction is not null)
|
||||
await transaction.CommitAsync();
|
||||
return unchanged ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_context.ChangeTracker.Clear();
|
||||
var currentState = await LoadBoardRowAsync(workOrderId);
|
||||
throw new WorkOrderBoardConcurrencyException(currentState);
|
||||
}
|
||||
if (changes.Count == 0)
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
if (transaction is not null)
|
||||
await transaction.CommitAsync();
|
||||
var persisted = await LoadBoardRowAsync(workOrderId);
|
||||
return persisted ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
}
|
||||
|
||||
var row = await LoadBoardRowAsync(workOrderId);
|
||||
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
foreach (var change in changes)
|
||||
{
|
||||
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(
|
||||
|
|
@ -153,7 +180,7 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(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.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.ApptTime => new List<FieldChange> { ApplyApptTime(workOrder, dispatch!, 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)
|
||||
throw new WorkOrderBoardValidationException("DispatchRequired", "A primary dispatch is required. Set vendorId first or provide primaryDispatchId.");
|
||||
|
||||
await EnsureVendorExistsAsync(vendorIdForCreate.Value);
|
||||
|
||||
var created = new Dispatch
|
||||
{
|
||||
WorkOrderId = workOrder.Id,
|
||||
|
|
@ -200,6 +229,13 @@ namespace SeaHaven.Services.Implementation
|
|||
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)
|
||||
{
|
||||
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());
|
||||
}
|
||||
|
||||
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);
|
||||
if (!vendorId.HasValue)
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "vendorId must be a positive integer.");
|
||||
|
||||
await EnsureVendorExistsAsync(vendorId.Value);
|
||||
|
||||
var old = dispatch.VendorId.ToString();
|
||||
if (dispatch.VendorId == vendorId.Value)
|
||||
return FieldChange.Unchanged(auditField, dispatch.Id);
|
||||
|
|
@ -459,7 +497,11 @@ namespace SeaHaven.Services.Implementation
|
|||
private static List<FieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
|
||||
{
|
||||
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;
|
||||
|
||||
var old = workOrder.LifecycleStatus?.ToString();
|
||||
|
|
|
|||
|
|
@ -42,6 +42,16 @@ public class WorkOrderBoardMutationRulesTests
|
|||
"dispatcher-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldAutoSchedule_FalseWhenWeekOnly()
|
||||
{
|
||||
Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule(
|
||||
LifecycleStatus.Incomplete,
|
||||
new DateTime(2026, 6, 25),
|
||||
"dispatcher-1",
|
||||
scheduleWeekOnly: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsReschedule_WhenPreviousDateExistsAndChanges()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -224,4 +224,142 @@ public class WorkOrderBoardUpdateServiceTests
|
|||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ Retorna WOs agendadas na semana + seção **Unscheduled** em uma única chamada.
|
|||
|
||||
| Param | Obrigatório | Descrição |
|
||||
|-------|-------------|-----------|
|
||||
| `weekStart` | Sim | Segunda-feira da semana (`YYYY-MM-DD`) |
|
||||
| `weekEnd` | Não | Sexta-feira (default: `weekStart + 4 dias`) |
|
||||
| `dispatchers` | Não | Lista de GUIDs; use `__unassigned__` para não atribuídos |
|
||||
| `myWorkOrders` | Não | `true` → filtra `AssignTo == usuário logado` |
|
||||
| `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) |
|
||||
|
||||
|
|
@ -45,10 +45,14 @@ curl -H "Authorization: Bearer <token>" \
|
|||
|
||||
- `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 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
|
||||
[
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue