Merge pull request #14 from Sea-Haven-Industries/feat/work-orders-phase-2

Feat/work orders phase 2
This commit is contained in:
Alexandre Brandizzi 2026-07-24 13:32:25 -03:00 • committed by GitHub
commit df828cf48c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1898 additions and 190 deletions

View file

@ -18,6 +18,7 @@ using System.Security.Claims;
using static System.Runtime.InteropServices.JavaScript.JSType;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.DataServices.Interfaces;
@ -45,6 +46,7 @@ namespace Api.SeaHavenIndustries.Controllers
private readonly Helper.VendorPortalTokenService _vendorTokens;
private readonly IConfiguration _config;
private readonly IWorkOrderBoardService _workOrderBoardService;
private readonly IWorkOrderBoardUpdateService _boardUpdateService;
public WorkOrderController(
UserManager<ApplicationUser> userManager,
@ -62,7 +64,8 @@ namespace Api.SeaHavenIndustries.Controllers
Helper.SendMessage sendMessage,
Helper.VendorPortalTokenService vendorTokens,
IConfiguration config,
IWorkOrderBoardService workOrderBoardService)
IWorkOrderBoardService workOrderBoardService,
IWorkOrderBoardUpdateService boardUpdateService)
{
_userManager = userManager;
_workOrderService = workOrderService;
@ -80,6 +83,7 @@ namespace Api.SeaHavenIndustries.Controllers
_vendorTokens = vendorTokens;
_config = config;
_workOrderBoardService = workOrderBoardService;
_boardUpdateService = boardUpdateService;
}
/// <summary>
@ -135,6 +139,44 @@ namespace Api.SeaHavenIndustries.Controllers
return Ok(result);
}
[HttpPatch("{id:int}/board")]
public async Task<IActionResult> PatchBoardField(int id, [FromBody] WorkOrderBoardPatchRequestDto request)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var row = await _boardUpdateService.PatchFieldAsync(id, request, actorId);
return Ok(row);
}
catch (WorkOrderBoardConcurrencyException ex)
{
return Conflict(new WorkOrderBoardConflictDto
{
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
{
Code = ex.Code,
Message = ex.Message
});
}
catch (ArgumentException ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpPost]
[Route("AddWorkorder")]
public async Task<IActionResult> Addworkorder([FromForm] Workorder_DTO model)

View file

@ -118,6 +118,51 @@ namespace Data.SeaHavenIndustries
public DbSet<Department> Departments { get; set; }
public DbSet<JobTitle> JobTitles { get; set; }
public DbSet<Region> Regions { get; set; }
public override int SaveChanges()
{
BumpRowVersionsForInMemoryProvider();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
BumpRowVersionsForInMemoryProvider();
return base.SaveChangesAsync(cancellationToken);
}
private void BumpRowVersionsForInMemoryProvider()
{
if (!Database.ProviderName?.Contains("InMemory", StringComparison.OrdinalIgnoreCase) ?? true)
return;
foreach (var entry in ChangeTracker.Entries<WorkOrder>())
{
if (entry.State == EntityState.Modified)
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
}
foreach (var entry in ChangeTracker.Entries<Dispatch>())
{
if (entry.State == EntityState.Modified)
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
}
}
private static byte[] IncrementRowVersion(byte[]? current)
{
var bytes = current != null && current.Length > 0
? (byte[])current.Clone()
: new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 };
for (var i = 0; i < bytes.Length; i++)
{
if (++bytes[i] != 0)
break;
}
return bytes;
}
}
public class ApplicationUser : IdentityUser

View file

@ -56,11 +56,12 @@ namespace SeaHaven.DataServices.Helpers
? w.PrimaryDispatch.Vendor.Phone
: null,
DispatchApptDate = w.PrimaryDispatch != null ? w.PrimaryDispatch.ScheduledDate : null,
w.Service,
w.Trade,
w.Problem,
w.DocStatus,
w.RowVersion
w.PrimaryDispatchId,
w.RowVersion,
DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null
})
.ToListAsync();
@ -93,9 +94,12 @@ namespace SeaHaven.DataServices.Helpers
w.TechName,
w.TechPhone,
w.DispatchApptDate,
w.Service ?? w.Trade ?? w.Problem,
w.Trade ?? w.Problem,
w.Problem,
w.DocStatus,
w.PrimaryDispatchId,
w.RowVersion,
w.DispatchRowVersion,
isUnscheduled)).ToList();
}
}

View file

@ -68,5 +68,27 @@ namespace SeaHaven.DataServices.Implementation
u.Color))
.ToListAsync();
}
public async Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId)
{
var query = _context.workOrders
.AsNoTracking()
.Where(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(query, isUnscheduled: false);
return rows.FirstOrDefault();
}
public async Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId)
{
return await _context.workOrders
.AsNoTracking()
.AnyAsync(w =>
w.Id != excludeWorkOrderId
&& w.istemplate != true
&& (w.IsDeleted != true || w.IsDeleted == null)
&& w.InternalWONumber == normalizedWoNumber);
}
}
}

View file

@ -6,5 +6,7 @@ namespace SeaHaven.DataServices.Interfaces
{
Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query);
Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync();
Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId);
Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId);
}
}

View file

@ -40,9 +40,12 @@ namespace SeaHaven.DataServices.Interfaces
string? TechName,
string? TechPhone,
DateTime? DispatchApptDate,
string? Service,
string? Trade,
string? Problem,
DocStatus? DocStatus,
int? PrimaryDispatchId,
byte[]? RowVersion,
byte[]? DispatchRowVersion,
bool IsUnscheduled);
public record WorkOrderBoardQueryResult(

View file

@ -1,11 +0,0 @@
namespace SeaHaven.Services.Board
{
/// <summary>
/// Shared board constants for Phase 2+ features. Not referenced in Phase 1.
/// </summary>
public static class WorkOrderBoardConstants
{
public const int MaxWindowDays = 90;
public const int ClientSideThreshold = 300;
}
}

View file

@ -5,6 +5,7 @@ namespace SeaHaven.Services.Constants
public const string LifecycleStatus = nameof(LifecycleStatus);
public const string AssignTo = nameof(AssignTo);
public const string ScheduledDate = nameof(ScheduledDate);
public const string ScheduledStart = nameof(ScheduledStart);
public const string ScheduledEnd = nameof(ScheduledEnd);
public const string TargetWeek = nameof(TargetWeek);
public const string ScheduleWeekOnly = nameof(ScheduleWeekOnly);

View file

@ -1,4 +1,5 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.Helpers;
namespace SeaHaven.Services.DTOs
{
@ -49,9 +50,32 @@ namespace SeaHaven.Services.DTOs
public string? TechPhone { get; set; }
public DateTime? ApptDate { get; set; }
public string? ApptTime { get; set; }
public string? Service { get; set; }
public string? Pm { get; set; }
public DocStatus? DocStatus { get; set; }
public int? PrimaryDispatchId { get; set; }
public byte[]? RowVersion { get; set; }
public byte[]? DispatchRowVersion { get; set; }
}
public class WorkOrderBoardPatchRequestDto
{
public string Field { get; set; } = "";
public string? Value { get; set; }
public string? WorkOrderVersion { get; set; }
public string? DispatchVersion { get; set; }
public int? PrimaryDispatchId { get; set; }
}
public class WorkOrderBoardConflictDto
{
public string Message { get; set; } = "The record was modified by another user. Refresh and retry.";
public WorkOrderBoardRowDto? CurrentState { get; set; }
}
public class WorkOrderBoardValidationErrorDto
{
public string Code { get; set; } = "";
public string Message { get; set; } = "";
}
public class WorkOrderBoardResponseDto

View file

@ -0,0 +1,25 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Exceptions
{
public class WorkOrderBoardValidationException : Exception
{
public WorkOrderBoardValidationException(string code, string message) : base(message)
{
Code = code;
}
public string Code { get; }
}
public class WorkOrderBoardConcurrencyException : Exception
{
public WorkOrderBoardConcurrencyException(WorkOrderBoardRowDto? currentState)
: base("The record was modified by another user. Refresh and retry.")
{
CurrentState = currentState;
}
public WorkOrderBoardRowDto? CurrentState { get; }
}
}

View file

@ -1,86 +1,85 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
public static class LifecycleStatusMapper
{
public static LifecycleStatus? FromLegacyStatus(string? status)
{
if (string.IsNullOrWhiteSpace(status)) return null;
return status.Trim().ToLowerInvariant() switch
{
"open" or "incomplete" => LifecycleStatus.Incomplete,
"pending" or "onhold" or "on hold" => LifecycleStatus.Pending,
"scheduled" => LifecycleStatus.Scheduled,
"en route" or "enroute" => LifecycleStatus.EnRoute,
"on site" or "onsite" => LifecycleStatus.OnSite,
"inprogress" or "in progress" => LifecycleStatus.InProgress,
"completed" or "complete" or "done" or "closed" => LifecycleStatus.Completed,
"rescheduled" => LifecycleStatus.Rescheduled,
"cancelled" or "canceled" => LifecycleStatus.Canceled,
"pending quote" or "pendingquote" or "pendingapproval" or "pending approval" => LifecycleStatus.PendingQuote,
_ => null
};
}
public static LifecycleStatus? ParseLifecycleStatus(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (TryParseFeLabel(value, out var fromLabel))
return fromLabel;
if (Enum.TryParse<LifecycleStatus>(value, true, out var parsed))
return parsed;
return FromLegacyStatus(value);
}
public static bool TryParseFeLabel(string? label, out LifecycleStatus status)
{
status = default;
if (string.IsNullOrWhiteSpace(label)) return false;
return label.Trim().ToLowerInvariant() switch
{
"incomplete" => Set(LifecycleStatus.Incomplete, out status),
"pending" => Set(LifecycleStatus.Pending, out status),
"scheduled" => Set(LifecycleStatus.Scheduled, out status),
"en route" => Set(LifecycleStatus.EnRoute, out status),
"on site" => Set(LifecycleStatus.OnSite, out status),
"in progress" => Set(LifecycleStatus.InProgress, out status),
"completed" => Set(LifecycleStatus.Completed, out status),
"rescheduled" => Set(LifecycleStatus.Rescheduled, out status),
"canceled" => Set(LifecycleStatus.Canceled, out status),
"pending quote" => Set(LifecycleStatus.PendingQuote, out status),
_ => false
};
}
public static string ToFeLabel(LifecycleStatus status) => status switch
{
LifecycleStatus.Incomplete => "Incomplete",
LifecycleStatus.Pending => "Pending",
LifecycleStatus.Scheduled => "Scheduled",
LifecycleStatus.EnRoute => "En Route",
LifecycleStatus.OnSite => "On Site",
LifecycleStatus.InProgress => "In Progress",
LifecycleStatus.Completed => "Completed",
LifecycleStatus.Rescheduled => "Rescheduled",
LifecycleStatus.Canceled => "Canceled",
LifecycleStatus.PendingQuote => "Pending Quote",
_ => status.ToString()
};
public static bool IsTerminal(LifecycleStatus? status)
=> status is LifecycleStatus.Completed or LifecycleStatus.Canceled;
private static bool Set(LifecycleStatus value, out LifecycleStatus status)
{
status = value;
return true;
}
}
}
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
public static class LifecycleStatusMapper
{
public static LifecycleStatus? FromLegacyStatus(string? status)
{
if (string.IsNullOrWhiteSpace(status)) return null;
return status.Trim().ToLowerInvariant() switch
{
"open" or "incomplete" => LifecycleStatus.Incomplete,
"pending" or "onhold" or "on hold" => LifecycleStatus.Pending,
"scheduled" => LifecycleStatus.Scheduled,
"en route" or "enroute" => LifecycleStatus.EnRoute,
"on site" or "onsite" => LifecycleStatus.OnSite,
"inprogress" or "in progress" => LifecycleStatus.InProgress,
"completed" or "complete" or "done" or "closed" => LifecycleStatus.Completed,
"rescheduled" => LifecycleStatus.Rescheduled,
"cancelled" or "canceled" => LifecycleStatus.Canceled,
"pending quote" or "pendingquote" or "pendingapproval" or "pending approval" => LifecycleStatus.PendingQuote,
_ => null
};
}
public static LifecycleStatus? ParseLifecycleStatus(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (TryParseFeLabel(value, out var fromLabel))
return fromLabel;
if (Enum.TryParse<LifecycleStatus>(value, true, out var parsed))
return parsed;
return FromLegacyStatus(value);
}
public static bool TryParseFeLabel(string? label, out LifecycleStatus status)
{
status = default;
if (string.IsNullOrWhiteSpace(label)) return false;
return label.Trim().ToLowerInvariant() switch
{
"incomplete" => Set(LifecycleStatus.Incomplete, out status),
"pending" => Set(LifecycleStatus.Pending, out status),
"scheduled" => Set(LifecycleStatus.Scheduled, out status),
"en route" => Set(LifecycleStatus.EnRoute, out status),
"on site" => Set(LifecycleStatus.OnSite, out status),
"in progress" => Set(LifecycleStatus.InProgress, out status),
"completed" => Set(LifecycleStatus.Completed, out status),
"rescheduled" => Set(LifecycleStatus.Rescheduled, out status),
"canceled" => Set(LifecycleStatus.Canceled, out status),
"pending quote" => Set(LifecycleStatus.PendingQuote, out status),
_ => false
};
}
public static string ToFeLabel(LifecycleStatus status) => status switch
{
LifecycleStatus.Incomplete => "Incomplete",
LifecycleStatus.Pending => "Pending",
LifecycleStatus.Scheduled => "Scheduled",
LifecycleStatus.EnRoute => "En Route",
LifecycleStatus.OnSite => "On Site",
LifecycleStatus.InProgress => "In Progress",
LifecycleStatus.Completed => "Completed",
LifecycleStatus.Rescheduled => "Rescheduled",
LifecycleStatus.Canceled => "Canceled",
LifecycleStatus.PendingQuote => "Pending Quote",
_ => status.ToString()
};
public static bool IsTerminal(LifecycleStatus? status)
=> status is LifecycleStatus.Completed or LifecycleStatus.Canceled;
private static bool Set(LifecycleStatus value, out LifecycleStatus status)
{
status = value;
return true;
}
}
}

View file

@ -0,0 +1,20 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Shared terminal lifecycle statuses for board filters, read-only checks, and jobs.
/// Keep this as the single source of truth (EF expressions should copy these values inline).
/// </summary>
public static class LifecycleStatusSets
{
public static readonly LifecycleStatus[] Terminal =
{
LifecycleStatus.Completed,
LifecycleStatus.Canceled
};
public static bool IsTerminal(LifecycleStatus? status)
=> status is LifecycleStatus.Completed or LifecycleStatus.Canceled;
}
}

View file

@ -0,0 +1,82 @@
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderBoardFieldNames
{
public const string WoNumber = "woNumber";
public const string WorkOrderType = "workOrderType";
public const string SiteCode = "siteCode";
public const string LifecycleStatus = "lifecycleStatus";
public const string AssignTo = "assignTo";
public const string DueDate = "dueDate";
public const string ScheduledDate = "scheduledDate";
public const string TargetWeek = "targetWeek";
public const string ScheduleWeekOnly = "scheduleWeekOnly";
public const string VendorId = "vendorId";
public const string ApptDate = "apptDate";
public const string ApptTime = "apptTime";
public const string DocStatus = "docStatus";
public const string RescheduleCount = "RescheduleCount";
private static readonly HashSet<string> DispatchFields = new(StringComparer.OrdinalIgnoreCase)
{
VendorId,
ApptDate,
ApptTime
};
private static readonly HashSet<string> AllEditable = new(StringComparer.OrdinalIgnoreCase)
{
WoNumber,
WorkOrderType,
SiteCode,
LifecycleStatus,
AssignTo,
DueDate,
ScheduledDate,
TargetWeek,
ScheduleWeekOnly,
VendorId,
ApptDate,
ApptTime,
DocStatus
};
public static bool IsEditable(string field) => Canonicalize(field) != null;
public static bool IsDispatchField(string field)
{
var canonical = Canonicalize(field);
return canonical != null && DispatchFields.Contains(canonical);
}
public static string? Canonicalize(string field)
{
if (string.IsNullOrWhiteSpace(field))
return null;
return AllEditable.FirstOrDefault(f => string.Equals(f, field, StringComparison.OrdinalIgnoreCase));
}
public static string ToAuditFieldName(string field)
{
var canonical = Canonicalize(field) ?? field;
return canonical switch
{
WoNumber => "InternalWONumber",
WorkOrderType => "WorkOrderType",
SiteCode => "SiteCode",
LifecycleStatus => "LifecycleStatus",
AssignTo => "AssignTo",
DueDate => "DueDate",
ScheduledDate => "ScheduledDate",
TargetWeek => "TargetWeek",
ScheduleWeekOnly => "ScheduleWeekOnly",
VendorId => "VendorId",
ApptDate => "ApptDate",
ApptTime => "ApptTime",
DocStatus => "DocStatus",
_ => canonical
};
}
}
}

View file

@ -0,0 +1,45 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderBoardMutationRules
{
private static readonly HashSet<LifecycleStatus?> ReadOnlyStatuses =
LifecycleStatusSets.Terminal.Cast<LifecycleStatus?>().ToHashSet();
public static bool IsReadOnly(LifecycleStatus? status) => ReadOnlyStatuses.Contains(status);
public static bool ShouldBlockStatusChangeWhenPastDue(string field, bool isPastDue)
=> field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue;
/// <summary>
/// SHOC rule: Incomplete + specific scheduled date + assignee → Scheduled.
/// Week-only targets do not auto-schedule (same as <see cref="WorkOrderDerivedFields.ApplyAutoScheduleIfEligible"/>).
/// </summary>
public static bool ShouldAutoSchedule(
LifecycleStatus? status,
DateTime? scheduledDate,
string? assignTo,
bool? scheduleWeekOnly = null)
=> status == LifecycleStatus.Incomplete
&& scheduleWeekOnly != true
&& scheduledDate.HasValue
&& !string.IsNullOrWhiteSpace(assignTo);
public static bool IsReschedule(DateTime? previousDate, DateTime? newDate)
=> previousDate.HasValue
&& newDate.HasValue
&& previousDate.Value.Date != newDate.Value.Date;
public static DateOnly? ComputeOriginalWeek(DateTime? scheduledDate)
=> scheduledDate.HasValue
? DateOnly.FromDateTime(GetWeekStartMonday(scheduledDate.Value))
: null;
private static DateTime GetWeekStartMonday(DateTime date)
{
var diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7;
return date.Date.AddDays(-diff);
}
}
}

View file

@ -1,48 +1,47 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderTypeMapper
{
/// <summary>Board FE types. Overdue is not a type — use <see cref="WorkOrderDerivedFields.IsPastDue"/>.</summary>
public static bool TryParseFeLabel(string? label, out WorkOrderType type)
{
type = default;
if (string.IsNullOrWhiteSpace(label)) return false;
return label.Trim().ToLowerInvariant() switch
{
"pm" => Set(WorkOrderType.PM, out type),
"reactive" => Set(WorkOrderType.Reactive, out type),
"emergency" => Set(WorkOrderType.Emergency, out type),
"add-on" or "addon" => Set(WorkOrderType.AddOn, out type),
"po" => Set(WorkOrderType.PO, out type),
"project" => Set(WorkOrderType.Project, out type),
"inspection" => Set(WorkOrderType.Inspection, out type),
"other" => Set(WorkOrderType.Other, out type),
"overdue" => false,
_ => Enum.TryParse(label, true, out type)
};
}
public static string ToFeLabel(WorkOrderType type) => type switch
{
WorkOrderType.PM => "PM",
WorkOrderType.Reactive => "Reactive",
WorkOrderType.Emergency => "Emergency",
WorkOrderType.AddOn => "Add-On",
WorkOrderType.PO => "PO",
WorkOrderType.Project => "Project",
WorkOrderType.Inspection => "Inspection",
WorkOrderType.Other => "Other",
_ => type.ToString()
};
private static bool Set(WorkOrderType value, out WorkOrderType type)
{
type = value;
return true;
}
}
}
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderTypeMapper
{
/// <summary>Board FE types. Overdue is not a type — use <see cref="WorkOrderDerivedFields.IsPastDue(System.DateTime?, LifecycleStatus?, System.DateTime?)"/>.</summary>
public static bool TryParseFeLabel(string? label, out WorkOrderType type)
{
type = default;
if (string.IsNullOrWhiteSpace(label)) return false;
return label.Trim().ToLowerInvariant() switch
{
"pm" => Set(WorkOrderType.PM, out type),
"reactive" => Set(WorkOrderType.Reactive, out type),
"emergency" => Set(WorkOrderType.Emergency, out type),
"add-on" or "addon" => Set(WorkOrderType.AddOn, out type),
"po" => Set(WorkOrderType.PO, out type),
"project" => Set(WorkOrderType.Project, out type),
"inspection" => Set(WorkOrderType.Inspection, out type),
"other" => Set(WorkOrderType.Other, out type),
"overdue" => false,
_ => Enum.TryParse(label, true, out type)
};
}
public static string ToFeLabel(WorkOrderType type) => type switch
{
WorkOrderType.PM => "PM",
WorkOrderType.Reactive => "Reactive",
WorkOrderType.Emergency => "Emergency",
WorkOrderType.AddOn => "Add-On",
WorkOrderType.PO => "PO",
WorkOrderType.Project => "Project",
WorkOrderType.Inspection => "Inspection",
WorkOrderType.Other => "Other",
_ => type.ToString()
};
private static bool Set(WorkOrderType value, out WorkOrderType type)
{
type = value;
return true;
}
}
}

View file

@ -12,6 +12,7 @@ namespace SeaHaven.Services.Implementation
WorkOrderFieldNames.LifecycleStatus,
WorkOrderFieldNames.AssignTo,
WorkOrderFieldNames.ScheduledDate,
WorkOrderFieldNames.ScheduledStart,
WorkOrderFieldNames.ScheduledEnd,
WorkOrderFieldNames.TargetWeek,
WorkOrderFieldNames.ScheduleWeekOnly,
@ -68,6 +69,7 @@ namespace SeaHaven.Services.Implementation
WorkOrderFieldNames.Status => wo.Status,
WorkOrderFieldNames.DueDate => wo.DueDate?.ToString("O"),
WorkOrderFieldNames.ScheduledDate => wo.ScheduledDate?.ToString("O"),
WorkOrderFieldNames.ScheduledStart => wo.ScheduledStart?.ToString("O"),
WorkOrderFieldNames.ScheduledEnd => wo.ScheduledEnd?.ToString("O"),
WorkOrderFieldNames.TargetWeek => wo.TargetWeek?.ToString("O"),
WorkOrderFieldNames.ScheduleWeekOnly => wo.ScheduleWeekOnly?.ToString(),
@ -105,6 +107,9 @@ namespace SeaHaven.Services.Implementation
case WorkOrderFieldNames.ScheduledDate when TryParseDateTime(incomingValue, out var scheduledDate):
wo.ScheduledDate = scheduledDate;
return true;
case WorkOrderFieldNames.ScheduledStart when TryParseDateTime(incomingValue, out var scheduledStart):
wo.ScheduledStart = scheduledStart;
return true;
case WorkOrderFieldNames.ScheduledEnd when TryParseDateTime(incomingValue, out var scheduledEnd):
wo.ScheduledEnd = scheduledEnd;
return true;

View file

@ -1,7 +1,6 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
@ -19,16 +18,28 @@ namespace SeaHaven.Services.Implementation
public Task LogStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId)
=> LogAsync(workOrderId, AuditActionType.StatusChanged, AuditEventType.Manual,
AuditActorType.Dispatcher, WorkOrderFieldNames.LifecycleStatus, oldStatus, newStatus, actorId);
AuditActorType.Dispatcher, "LifecycleStatus", oldStatus, newStatus, actorId);
public Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId)
=> LogAsync(workOrderId, AuditActionType.AssignmentChanged, AuditEventType.Manual,
AuditActorType.Dispatcher, WorkOrderFieldNames.AssignTo, oldValue, newValue, actorId);
AuditActorType.Dispatcher, "AssignTo", oldValue, newValue, actorId);
public Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue)
=> LogAsync(workOrderId, AuditActionType.SyncRejected, AuditEventType.Sync,
AuditActorType.Sync, fieldName, oldValue, newValue);
public Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null)
=> StageAuditAsync(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId);
public Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId)
=> StageAuditAsync(workOrderId, AuditActionType.StatusChanged, "LifecycleStatus", oldStatus, newStatus, actorId);
public Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId)
=> StageAuditAsync(workOrderId, AuditActionType.AssignmentChanged, "AssignTo", oldValue, newValue, actorId);
public Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber)
=> StageAuditAsync(workOrderId, AuditActionType.Create, "WorkOrder", null, woNumber, actorId);
public async Task LogAsync(
int workOrderId,
AuditActionType action,
@ -40,6 +51,51 @@ namespace SeaHaven.Services.Implementation
string? actorId = null,
int? dispatchId = null,
string? correlationId = null)
{
AddAuditEntry(workOrderId, action, eventType, actorType, fieldName, oldValue, newValue, actorId, dispatchId, correlationId);
if (eventType == AuditEventType.Manual &&
(action == AuditActionType.FieldChanged ||
action == AuditActionType.StatusChanged ||
action == AuditActionType.AssignmentChanged))
{
await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId);
}
await _context.SaveChangesAsync();
}
private async Task StageAuditAsync(
int workOrderId,
AuditActionType action,
string fieldName,
string? oldValue,
string? newValue,
string? actorId,
int? dispatchId = null)
{
AddAuditEntry(workOrderId, action, AuditEventType.Manual, AuditActorType.Dispatcher,
fieldName, oldValue, newValue, actorId, dispatchId);
if (action == AuditActionType.FieldChanged ||
action == AuditActionType.StatusChanged ||
action == AuditActionType.AssignmentChanged)
{
await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId);
}
}
private void AddAuditEntry(
int workOrderId,
AuditActionType action,
AuditEventType eventType,
AuditActorType actorType,
string fieldName,
string? oldValue,
string? newValue,
string? actorId,
int? dispatchId = null,
string? correlationId = null)
{
_context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
@ -55,38 +111,7 @@ namespace SeaHaven.Services.Implementation
CorrelationId = correlationId,
CreatedAt = DateTime.UtcNow
});
if (eventType == AuditEventType.Manual &&
(action == AuditActionType.FieldChanged ||
action == AuditActionType.StatusChanged ||
action == AuditActionType.AssignmentChanged))
{
await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId);
}
await SaveChangesIdempotentAsync();
}
private async Task SaveChangesIdempotentAsync()
{
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex))
{
foreach (var entry in ex.Entries.Where(e => e.Entity is WorkOrderFieldLock))
entry.State = EntityState.Detached;
await _context.SaveChangesAsync();
}
}
private static bool IsUniqueConstraintViolation(DbUpdateException ex)
{
var message = ex.InnerException?.Message ?? ex.Message;
return message.Contains("duplicate", StringComparison.OrdinalIgnoreCase)
|| message.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase);
}
}
}

View file

@ -47,8 +47,8 @@ namespace SeaHaven.Services.Implementation
Total = result.ScheduledTotalBeforeSearch,
Returned = result.ScheduledRows.Count
},
Scheduled = result.ScheduledRows.Select(r => MapRow(r, utcNow)).ToList(),
Unscheduled = result.UnscheduledRows.Select(r => MapRow(r, utcNow)).ToList()
Scheduled = result.ScheduledRows.Select(r => MapRawRow(r, utcNow)).ToList(),
Unscheduled = result.UnscheduledRows.Select(r => MapRawRow(r, utcNow)).ToList()
};
}
@ -64,7 +64,13 @@ namespace SeaHaven.Services.Implementation
}).ToList();
}
private static WorkOrderBoardRowDto MapRow(WorkOrderBoardRawRow row, DateTime utcNow)
public async Task<WorkOrderBoardRowDto?> GetBoardRowAsync(int workOrderId)
{
var raw = await _boardDataService.GetBoardRowByIdAsync(workOrderId);
return raw == null ? null : MapRawRow(raw, DateTime.UtcNow);
}
public static WorkOrderBoardRowDto MapRawRow(WorkOrderBoardRawRow row, DateTime utcNow)
{
// Appt column: date prefers dispatch appointment (vendor slot), then WO ScheduledDate.
// Time prefers WO ScheduledStart/End (dispatcher window), then dispatch datetime.
@ -102,9 +108,11 @@ namespace SeaHaven.Services.Implementation
TechPhone = row.TechPhone,
ApptDate = row.DispatchApptDate ?? row.ScheduledDate,
ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd),
Service = row.Service,
Pm = row.Trade ?? row.Problem,
DocStatus = row.DocStatus,
RowVersion = row.RowVersion
PrimaryDispatchId = row.PrimaryDispatchId,
RowVersion = row.RowVersion,
DispatchRowVersion = row.DispatchRowVersion
};
}

View file

@ -0,0 +1,583 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderBoardUpdateService : IWorkOrderBoardUpdateService
{
private readonly ApplicationDbContext _context;
private readonly IWorkOrderBoardDataService _boardDataService;
private readonly IWorkOrderAuditService _auditService;
public WorkOrderBoardUpdateService(
ApplicationDbContext context,
IWorkOrderBoardDataService boardDataService,
IWorkOrderAuditService auditService)
{
_context = context;
_boardDataService = boardDataService;
_auditService = auditService;
}
public async Task<WorkOrderBoardRowDto> PatchFieldAsync(
int workOrderId,
WorkOrderBoardPatchRequestDto request,
string? actorId)
{
// 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);
if (canonicalField == null)
throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.");
var workOrderVersion = ParseRowVersion(request.WorkOrderVersion, "workOrderVersion");
if (workOrderVersion == null)
throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required.");
var dispatchVersion = ParseRowVersion(request.DispatchVersion, "dispatchVersion");
var workOrder = await _context.workOrders
.FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (!RowVersionsMatch(workOrder.RowVersion, workOrderVersion))
{
var staleState = await LoadBoardRowAsync(workOrderId);
throw new WorkOrderBoardConcurrencyException(staleState);
}
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("CanceledReadOnly", "Work order is read-only in its current status.");
var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.ScheduledDate, workOrder.LifecycleStatus);
if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(canonicalField, isPastDue))
throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Reschedule first.");
_context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = workOrderVersion;
Dispatch? dispatch = null;
if (WorkOrderBoardFieldNames.IsDispatchField(canonicalField))
{
var vendorIdHint = canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase)
? ParseVendorIdHint(request.Value)
: null;
var hadPrimaryDispatch = workOrder.PrimaryDispatchId.HasValue;
dispatch = await ResolveDispatchAsync(workOrder, request.PrimaryDispatchId, vendorIdHint);
if (dispatchVersion != null && !RowVersionsMatch(dispatch.RowVersion, dispatchVersion))
{
var staleState = await LoadBoardRowAsync(workOrderId);
throw new WorkOrderBoardConcurrencyException(staleState);
}
if (dispatchVersion != null)
_context.Entry(dispatch).Property(d => d.RowVersion).OriginalValue = dispatchVersion;
else if (hadPrimaryDispatch && dispatch.RowVersion != null)
throw new WorkOrderBoardValidationException("DispatchVersionRequired", "dispatchVersion is required for dispatch fields.");
}
var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField);
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);
if (transaction is not null)
await transaction.CommitAsync();
return unchanged ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
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.");
}
foreach (var change in changes)
{
var dispatchId = change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null);
switch (change.Action)
{
case AuditActionType.StatusChanged:
await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
break;
case AuditActionType.AssignmentChanged:
await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
break;
default:
await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, 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(
string field,
WorkOrder workOrder,
Dispatch? dispatch,
string? value,
string auditField)
{
return field switch
{
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { await ApplyWoNumber(workOrder, value, auditField) },
WorkOrderBoardFieldNames.WorkOrderType => new List<FieldChange> { ApplyWorkOrderType(workOrder, value, auditField) },
WorkOrderBoardFieldNames.SiteCode => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) },
WorkOrderBoardFieldNames.LifecycleStatus => new List<FieldChange> { ApplyLifecycleStatus(workOrder, value, auditField) },
WorkOrderBoardFieldNames.AssignTo => ApplyAssignTo(workOrder, value, auditField),
WorkOrderBoardFieldNames.DueDate => new List<FieldChange> { ApplyDateField(value, auditField, v => workOrder.DueDate = v, () => workOrder.DueDate) },
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> { await ApplyVendorIdAsync(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value),
WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { ApplyDocStatus(workOrder, value, auditField) },
_ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.")
};
}
private async Task<Dispatch> ResolveDispatchAsync(WorkOrder workOrder, int? primaryDispatchId, int? vendorIdForCreate)
{
if (primaryDispatchId.HasValue)
{
var dispatch = await _context.Dispatches
.FirstOrDefaultAsync(d => d.Id == primaryDispatchId.Value && d.WorkOrderId == workOrder.Id);
if (dispatch == null)
throw new WorkOrderBoardValidationException("InvalidDispatch", "Primary dispatch does not belong to this work order.");
workOrder.PrimaryDispatchId = dispatch.Id;
return dispatch;
}
if (workOrder.PrimaryDispatchId.HasValue)
{
var existing = await _context.Dispatches
.FirstOrDefaultAsync(d => d.Id == workOrder.PrimaryDispatchId.Value);
if (existing != null)
return existing;
}
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,
VendorId = vendorIdForCreate.Value,
Status = "Pending",
CreatedDate = DateTime.UtcNow
};
_context.Dispatches.Add(created);
await _context.SaveChangesAsync();
workOrder.PrimaryDispatchId = created.Id;
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))
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, workOrder.Id))
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
var old = workOrder.InternalWONumber;
if (old == normalized)
return FieldChange.Unchanged(auditField);
workOrder.InternalWONumber = normalized;
workOrder.WorkerOrderNumber = normalized;
return FieldChange.ForField(auditField, old, normalized);
}
private static FieldChange ApplyWorkOrderType(WorkOrder workOrder, string? value, string auditField)
{
if (string.IsNullOrWhiteSpace(value))
throw new WorkOrderBoardValidationException("InvalidValue", "workOrderType value is required.");
if (!WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
var old = workOrder.WorkOrderType?.ToString();
if (workOrder.WorkOrderType == parsed)
return FieldChange.Unchanged(auditField);
workOrder.WorkOrderType = parsed;
return FieldChange.ForField(auditField, old, parsed.ToString());
}
private static FieldChange ApplyStringField(
string? value,
string auditField,
Action<string?> setter,
Func<string?> getter)
{
var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
var old = getter();
if (old == normalized)
return FieldChange.Unchanged(auditField);
setter(normalized);
return FieldChange.ForField(auditField, old, normalized);
}
private static FieldChange ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField)
{
var parsed = LifecycleStatusMapper.ParseLifecycleStatus(value);
if (parsed == null)
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid lifecycleStatus: {value}");
var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status;
if (workOrder.LifecycleStatus == parsed)
return FieldChange.Unchanged(auditField);
workOrder.LifecycleStatus = parsed;
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(parsed);
if (workOrder.LegacyStatus == null && workOrder.Status != null)
workOrder.LegacyStatus = workOrder.Status;
return FieldChange.StatusChanged(auditField, old, parsed.ToString());
}
private static List<FieldChange> ApplyAssignTo(WorkOrder workOrder, string? value, string auditField)
{
var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
var old = workOrder.AssignTo;
var changes = new List<FieldChange>();
if (old != normalized)
{
workOrder.AssignTo = normalized;
changes.Add(FieldChange.AssignmentChanged(auditField, old, normalized));
}
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
return changes;
}
private static FieldChange ApplyDateField(
string? value,
string auditField,
Action<DateTime?> setter,
Func<DateTime?> getter)
{
DateTime? parsed = null;
if (!string.IsNullOrWhiteSpace(value))
{
if (!DateTime.TryParse(value, out var dt))
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid date for {auditField}: {value}");
parsed = dt.Date;
}
var old = FormatDate(getter());
var newVal = FormatDate(parsed);
if (old == newVal)
return FieldChange.Unchanged(auditField);
setter(parsed);
return FieldChange.ForField(auditField, old, newVal);
}
private static List<FieldChange> ApplyScheduledDate(WorkOrder workOrder, string? value, string auditField)
{
var previousDate = workOrder.ScheduledDate;
DateTime? parsed = null;
if (!string.IsNullOrWhiteSpace(value))
{
if (!DateTime.TryParse(value, out var dt))
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid scheduledDate: {value}");
parsed = dt.Date;
}
var changes = new List<FieldChange>();
var oldFormatted = FormatDate(previousDate);
var newFormatted = FormatDate(parsed);
if (oldFormatted != newFormatted)
{
workOrder.ScheduledDate = parsed;
changes.Add(FieldChange.ForField(auditField, oldFormatted, newFormatted));
if (!workOrder.OriginalDate.HasValue && parsed.HasValue)
{
workOrder.OriginalDate = DateOnly.FromDateTime(parsed.Value);
var originalWeek = WorkOrderBoardMutationRules.ComputeOriginalWeek(parsed);
if (originalWeek.HasValue)
workOrder.OriginalWeek = originalWeek;
}
if (WorkOrderBoardMutationRules.IsReschedule(previousDate, parsed))
{
var oldCount = workOrder.RescheduleCount.ToString();
workOrder.RescheduleCount++;
changes.Add(FieldChange.ForField(WorkOrderBoardFieldNames.RescheduleCount, oldCount, workOrder.RescheduleCount.ToString()));
}
}
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
return changes;
}
private static FieldChange ApplyTargetWeek(WorkOrder workOrder, string? value, string auditField)
{
DateOnly? parsed = null;
if (!string.IsNullOrWhiteSpace(value))
{
if (!DateOnly.TryParse(value, out var d))
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid targetWeek: {value}");
parsed = d;
}
var old = workOrder.TargetWeek?.ToString("yyyy-MM-dd");
var newVal = parsed?.ToString("yyyy-MM-dd");
if (old == newVal)
return FieldChange.Unchanged(auditField);
workOrder.TargetWeek = parsed;
return FieldChange.ForField(auditField, old, newVal);
}
private static FieldChange ApplyBoolField(
string? value,
string auditField,
Action<bool?> setter,
Func<bool?> getter)
{
if (string.IsNullOrWhiteSpace(value))
throw new WorkOrderBoardValidationException("InvalidValue", $"{auditField} value is required.");
if (!bool.TryParse(value, out var parsed))
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid boolean for {auditField}: {value}");
var old = getter()?.ToString();
if (getter() == parsed)
return FieldChange.Unchanged(auditField);
setter(parsed);
return FieldChange.ForField(auditField, old, parsed.ToString());
}
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, ResolveDispatchIdForAudit(dispatch));
dispatch.VendorId = vendorId.Value;
return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), ResolveDispatchIdForAudit(dispatch));
}
private static FieldChange ApplyApptDate(Dispatch dispatch, string? value, string auditField)
{
DateTime? parsed = null;
if (!string.IsNullOrWhiteSpace(value))
{
if (!DateTime.TryParse(value, out var dt))
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid apptDate: {value}");
parsed = dt.Date;
}
var old = FormatDate(dispatch.ScheduledDate);
var newVal = FormatDate(parsed);
if (old == newVal)
return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch));
dispatch.ScheduledDate = parsed;
return FieldChange.ForField(auditField, old, newVal, ResolveDispatchIdForAudit(dispatch));
}
// apptTime is stored on WorkOrder (ScheduledStart/End). Lock those sync field names so
// SyncFieldMergePolicy honors the manual edit (not a synthetic "ApptTime" lock).
private static List<FieldChange> ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value)
{
if (!WorkOrderBoardApptTimeParser.TryParse(value, out var start, out var end, out var error))
throw new WorkOrderBoardValidationException("InvalidValue", error ?? "Invalid apptTime.");
var apptDate = dispatch.ScheduledDate ?? workOrder.ScheduledDate;
var oldStart = FormatDateTime(workOrder.ScheduledStart);
var oldEnd = FormatDateTime(workOrder.ScheduledEnd);
var dispatchId = ResolveDispatchIdForAudit(dispatch);
workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start);
workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end);
var newStart = FormatDateTime(workOrder.ScheduledStart);
var newEnd = FormatDateTime(workOrder.ScheduledEnd);
var changes = new List<FieldChange>();
if (oldStart != newStart)
changes.Add(FieldChange.ForField(WorkOrderFieldNames.ScheduledStart, oldStart, newStart, dispatchId));
if (oldEnd != newEnd)
changes.Add(FieldChange.ForField(WorkOrderFieldNames.ScheduledEnd, oldEnd, newEnd, dispatchId));
if (changes.Count == 0)
changes.Add(FieldChange.Unchanged(WorkOrderFieldNames.ScheduledStart, dispatchId));
return changes;
}
private static FieldChange ApplyDocStatus(WorkOrder workOrder, string? value, string auditField)
{
if (string.IsNullOrWhiteSpace(value))
throw new WorkOrderBoardValidationException("InvalidValue", "docStatus value is required.");
if (!Enum.TryParse<DocStatus>(value, true, out var parsed))
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid docStatus: {value}");
var old = workOrder.DocStatus?.ToString();
if (workOrder.DocStatus == parsed)
return FieldChange.Unchanged(auditField);
workOrder.DocStatus = parsed;
return FieldChange.ForField(auditField, old, parsed.ToString());
}
private static List<FieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
{
var changes = new List<FieldChange>();
if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(
workOrder.LifecycleStatus,
workOrder.ScheduledDate,
workOrder.AssignTo,
workOrder.ScheduleWeekOnly))
return changes;
var old = workOrder.LifecycleStatus?.ToString();
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Scheduled);
changes.Add(FieldChange.StatusChanged("LifecycleStatus", old, LifecycleStatus.Scheduled.ToString()));
return changes;
}
private static bool RowVersionsMatch(byte[]? current, byte[] expected)
=> current != null && current.AsSpan().SequenceEqual(expected);
private async Task<WorkOrderBoardRowDto?> LoadBoardRowAsync(int workOrderId)
{
var raw = await _boardDataService.GetBoardRowByIdAsync(workOrderId);
return raw == null ? null : WorkOrderBoardService.MapRawRow(raw, DateTime.UtcNow);
}
private static byte[]? ParseRowVersion(string? base64, string fieldName)
{
if (string.IsNullOrWhiteSpace(base64))
return null;
try
{
return Convert.FromBase64String(base64);
}
catch (FormatException)
{
throw new WorkOrderBoardValidationException("InvalidRowVersion", $"Invalid {fieldName} format.");
}
}
private static int? ParseVendorIdHint(string? value)
{
if (string.IsNullOrWhiteSpace(value) || !int.TryParse(value, out var vendorId) || vendorId <= 0)
return null;
return vendorId;
}
private static int? ResolveDispatchIdForAudit(Dispatch dispatch)
=> dispatch.Id > 0 ? dispatch.Id : null;
private static string? FormatDate(DateTime? value) => value?.ToString("yyyy-MM-dd");
private static string? FormatDateTime(DateTime? value) => value?.ToString("O");
private static string? FormatApptRange(string? start, string? end)
=> start == null && end == null ? null : $"{start}|{end}";
private sealed record FieldChange(
string FieldName,
string? OldValue,
string? NewValue,
AuditActionType Action,
int? DispatchId,
bool HasChanged)
{
public static FieldChange Unchanged(string fieldName, int? dispatchId = null)
=> new(fieldName, null, null, AuditActionType.FieldChanged, dispatchId, false);
public static FieldChange ForField(string fieldName, string? oldValue, string? newValue, int? dispatchId = null)
=> new(fieldName, oldValue, newValue, AuditActionType.FieldChanged, dispatchId, true);
public static FieldChange StatusChanged(string fieldName, string? oldValue, string? newValue)
=> new(fieldName, oldValue, newValue, AuditActionType.StatusChanged, null, true);
public static FieldChange AssignmentChanged(string fieldName, string? oldValue, string? newValue)
=> new(fieldName, oldValue, newValue, AuditActionType.AssignmentChanged, null, true);
}
}
}

View file

@ -19,5 +19,10 @@ namespace SeaHaven.Services.Interfaces
Task LogStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId);
Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId);
Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue);
Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null);
Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId);
Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId);
Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber);
}
}

View file

@ -6,5 +6,6 @@ namespace SeaHaven.Services.Interfaces
{
Task<WorkOrderBoardResponseDto> GetBoardAsync(WorkOrderBoardQueryDto query, string? currentUserId);
Task<IReadOnlyList<DispatcherLookupDto>> GetDispatcherLookupsAsync();
Task<WorkOrderBoardRowDto?> GetBoardRowAsync(int workOrderId);
}
}

View file

@ -0,0 +1,9 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderBoardUpdateService
{
Task<WorkOrderBoardRowDto> PatchFieldAsync(int workOrderId, WorkOrderBoardPatchRequestDto request, string? actorId);
}
}

View file

@ -0,0 +1,184 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
namespace SeaHavenIndustries.Tests;
public class WorkOrderBoardConcurrencyTests
{
private static ApplicationDbContext CreateContext(string dbName)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(dbName)
.Options;
return new ApplicationDbContext(options);
}
private static WorkOrderBoardUpdateService CreateService(ApplicationDbContext context)
{
var boardData = new WorkOrderBoardDataService(context);
var fieldLocks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, fieldLocks);
return new WorkOrderBoardUpdateService(context, boardData, audit);
}
[Fact]
public async Task ParallelVendorAndStatusEdits_OnDifferentAggregates_Succeed()
{
var dbName = Guid.NewGuid().ToString();
await using var seedContext = CreateContext(dbName);
seedContext.Vendors.Add(new Vendor { Id = 3, CompanyName = "Parallel Vendor" });
var dispatch = new Dispatch
{
Id = 10,
VendorId = 3,
WorkOrderId = 1,
RowVersion = new byte[] { 9, 0, 0, 0, 0, 0, 0, 9 }
};
seedContext.Dispatches.Add(dispatch);
seedContext.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 8, 0, 0, 0, 0, 0, 0, 8 }
});
await seedContext.SaveChangesAsync();
await using var woContext = CreateContext(dbName);
await using var dispatchContext = CreateContext(dbName);
var woService = CreateService(woContext);
var dispatchService = CreateService(dispatchContext);
var wo = await woContext.workOrders.FindAsync(1);
var disp = await dispatchContext.Dispatches.FindAsync(10);
var statusTask = woService.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Scheduled",
WorkOrderVersion = Convert.ToBase64String(wo!.RowVersion!)
}, "dispatcher-a");
var vendorTask = dispatchService.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.VendorId,
Value = "3",
WorkOrderVersion = Convert.ToBase64String(wo.RowVersion!),
DispatchVersion = Convert.ToBase64String(disp!.RowVersion!),
PrimaryDispatchId = 10
}, "dispatcher-b");
await Task.WhenAll(statusTask, vendorTask);
Assert.Equal(LifecycleStatus.Scheduled, statusTask.Result.LifecycleStatus);
Assert.Equal(3, vendorTask.Result.VendorId);
}
[Fact]
public async Task StaleWorkOrderVersion_ThrowsConcurrencyWithCurrentState()
{
var dbName = Guid.NewGuid().ToString();
await using var context1 = CreateContext(dbName);
context1.workOrders.Add(new WorkOrder
{
Id = 1,
SiteCode = "A",
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
await context1.SaveChangesAsync();
await using var context2 = CreateContext(dbName);
var wo2 = await context2.workOrders.FirstAsync();
wo2.SiteCode = "B";
await context2.SaveChangesAsync();
await using var patchContext = CreateContext(dbName);
var service = CreateService(patchContext);
var staleVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
var ex = await Assert.ThrowsAsync<WorkOrderBoardConcurrencyException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "C",
WorkOrderVersion = staleVersion
}, "actor-1"));
Assert.NotNull(ex.CurrentState);
Assert.Equal("B", ex.CurrentState!.SiteCode);
}
[Fact]
public async Task RescheduleAuditCount_MatchesChangedFields()
{
var dbName = Guid.NewGuid().ToString();
await using var context = CreateContext(dbName);
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 6, 20),
RescheduleCount = 0,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
await context.SaveChangesAsync();
var service = CreateService(context);
var wo = await context.workOrders.FindAsync(1);
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ScheduledDate,
Value = "2026-06-25",
WorkOrderVersion = Convert.ToBase64String(wo!.RowVersion!)
}, "actor-1");
var auditCount = await context.WorkOrderAuditLogs.CountAsync();
Assert.Equal(2, auditCount);
}
[Fact]
public async Task SecondEditWithStaleVersion_FailsAfterFirstEdit()
{
var dbName = Guid.NewGuid().ToString();
await using var seed = CreateContext(dbName);
seed.workOrders.Add(new WorkOrder
{
Id = 1,
SiteCode = "A",
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 4, 0, 0, 0, 0, 0, 0, 4 }
});
await seed.SaveChangesAsync();
var originalVersion = Convert.ToBase64String(new byte[] { 4, 0, 0, 0, 0, 0, 0, 4 });
await using (var ctx1 = CreateContext(dbName))
{
var svc1 = CreateService(ctx1);
await svc1.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "B",
WorkOrderVersion = originalVersion
}, "user-1");
}
await using var ctx2 = CreateContext(dbName);
var svc2 = CreateService(ctx2);
await Assert.ThrowsAsync<WorkOrderBoardConcurrencyException>(() =>
svc2.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "C",
WorkOrderVersion = originalVersion
}, "user-2"));
}
}

View file

@ -0,0 +1,65 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.Helpers;
namespace SeaHavenIndustries.Tests;
public class WorkOrderBoardMutationRulesTests
{
[Theory]
[InlineData(LifecycleStatus.Canceled, true)]
[InlineData(LifecycleStatus.Completed, true)]
[InlineData(LifecycleStatus.Scheduled, false)]
[InlineData(LifecycleStatus.Incomplete, false)]
public void IsReadOnly_ReflectsTerminalStatuses(LifecycleStatus status, bool expected)
{
Assert.Equal(expected, WorkOrderBoardMutationRules.IsReadOnly(status));
}
[Fact]
public void ShouldBlockStatusChangeWhenPastDue_BlocksOnlyLifecycleStatus()
{
Assert.True(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(
WorkOrderBoardFieldNames.LifecycleStatus, isPastDue: true));
Assert.False(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(
WorkOrderBoardFieldNames.ScheduledDate, isPastDue: true));
}
[Fact]
public void ShouldAutoSchedule_WhenIncompleteWithDateAndAssignee()
{
Assert.True(WorkOrderBoardMutationRules.ShouldAutoSchedule(
LifecycleStatus.Incomplete,
new DateTime(2026, 6, 25),
"dispatcher-1"));
}
[Fact]
public void ShouldAutoSchedule_FalseWhenAlreadyScheduled()
{
Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule(
LifecycleStatus.Scheduled,
new DateTime(2026, 6, 25),
"dispatcher-1"));
}
[Fact]
public void ShouldAutoSchedule_FalseWhenScheduleWeekOnly()
{
Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule(
LifecycleStatus.Incomplete,
new DateTime(2026, 6, 25),
"dispatcher-1",
scheduleWeekOnly: true));
}
[Fact]
public void IsReschedule_WhenPreviousDateExistsAndChanges()
{
Assert.True(WorkOrderBoardMutationRules.IsReschedule(
new DateTime(2026, 6, 20),
new DateTime(2026, 6, 25)));
Assert.False(WorkOrderBoardMutationRules.IsReschedule(
null,
new DateTime(2026, 6, 25)));
}
}

View file

@ -0,0 +1,521 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
public class WorkOrderBoardUpdateServiceTests
{
private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var boardData = new WorkOrderBoardDataService(context);
var fieldLocks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, fieldLocks);
var service = new WorkOrderBoardUpdateService(context, boardData, audit);
return (context, service);
}
private static string ToVersion(WorkOrder workOrder)
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
private static string ToVersion(Dispatch dispatch)
=> Convert.ToBase64String(dispatch.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 });
[Fact]
public async Task PatchField_AutoSchedulesIncompleteToScheduled()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
AssignTo = "user-a",
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.ScheduledDate,
Value = "2026-06-25",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
Assert.Equal(2, await context.WorkOrderAuditLogs.CountAsync());
}
[Fact]
public async Task PatchField_IncrementsRescheduleCount()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 6, 20),
RescheduleCount = 1,
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.ScheduledDate,
Value = "2026-06-25",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(2, result.RescheduleCount);
}
[Fact]
public async Task PatchField_BlocksStatusChangeWhenPastDue()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = DateTime.UtcNow.Date.AddDays(-3),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Complete",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("PastDueStatusBlocked", ex.Code);
}
[Fact]
public async Task PatchField_BlocksEditWhenCanceled()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Canceled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "BK5",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("CanceledReadOnly", ex.Code);
}
[Fact]
public async Task PatchField_VendorCreatesPrimaryDispatch()
{
var (context, service) = CreateSut();
context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Vendor Co" });
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
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.VendorId,
Value = "5",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.NotNull(result.PrimaryDispatchId);
Assert.Equal(5, result.VendorId);
Assert.Equal(1, await context.Dispatches.CountAsync());
}
[Fact]
public async Task PatchField_CreatesFieldLockAndAuditPerField()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
SiteCode = "OLD",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "BK5",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Single(await context.WorkOrderAuditLogs.Where(a => a.Action == AuditActionType.FieldChanged.ToString()).ToListAsync());
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.FieldName == "SiteCode"));
}
[Fact]
public async Task PatchField_SecondEditSameField_DoesNotDuplicateFieldLock()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
SiteCode = "OLD",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "BK5",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
var updated = await context.workOrders.FindAsync(1);
var secondResult = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "BK6",
WorkOrderVersion = ToVersion(updated!)
}, "actor-1");
Assert.Equal("BK6", secondResult.SiteCode);
Assert.Single(await context.WorkOrderFieldLocks.Where(l => l.FieldName == "SiteCode").ToListAsync());
Assert.Equal(2, await context.WorkOrderAuditLogs.Where(a => a.FieldName == "SiteCode").CountAsync());
}
[Fact]
public async Task PatchField_NotFound_ThrowsNotFoundCode()
{
var (_, service) = CreateSut();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(999, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "BK5",
WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
}, "actor-1"));
Assert.Equal("NotFound", ex.Code);
}
[Fact]
public async Task PatchField_NormalizesWoNumber()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
InternalWONumber = "00000009999",
LifecycleStatus = LifecycleStatus.Incomplete,
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.WoNumber,
Value = "12345",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal("00000012345", result.WoNumber);
}
[Fact]
public async Task PatchField_RejectsDuplicateWoNumber()
{
var (context, service) = CreateSut();
context.workOrders.AddRange(
new WorkOrder { Id = 1, InternalWONumber = "00000011111", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } },
new WorkOrder { Id = 2, InternalWONumber = "00000022222", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } });
await context.SaveChangesAsync();
var wo = await context.workOrders.FindAsync(1);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WoNumber,
Value = "22222",
WorkOrderVersion = ToVersion(wo!)
}, "actor-1"));
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_ApptTime_DashPrefixedToken_TreatedAsSingleStart()
{
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();
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ApptTime,
Value = "-30",
WorkOrderVersion = ToVersion(wo),
DispatchVersion = ToVersion(dispatch),
PrimaryDispatchId = 10
}, "actor-1");
var reloaded = await context.workOrders.FindAsync(1);
Assert.NotNull(reloaded!.ScheduledStart);
Assert.Equal(new DateTime(2026, 6, 25), reloaded.ScheduledEnd);
Assert.Equal(new DateTime(2026, 6, 25).Add(TimeSpan.FromDays(30)), reloaded.ScheduledStart);
}
[Fact]
public async Task PatchField_ApptTime_LocksScheduledStartAndEnd_ForSync()
{
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();
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ApptTime,
Value = "09:00-10:00",
WorkOrderVersion = ToVersion(wo),
DispatchVersion = ToVersion(dispatch),
PrimaryDispatchId = 10
}, "actor-1");
var lockNames = await context.WorkOrderFieldLocks
.Where(l => l.WorkOrderId == 1)
.Select(l => l.FieldName)
.ToListAsync();
Assert.Contains(WorkOrderFieldNames.ScheduledStart, lockNames);
Assert.Contains(WorkOrderFieldNames.ScheduledEnd, lockNames);
Assert.DoesNotContain("ApptTime", lockNames);
var locks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, locks);
var policy = new SyncFieldMergePolicy(locks);
var reloaded = await context.workOrders.SingleAsync(w => w.Id == 1);
var syncContext = new WorkOrderSyncContext
{
WorkOrder = reloaded,
FieldLocks = locks,
Audit = audit
};
var appliedStart = await policy.TryApplyAsync(
syncContext, WorkOrderFieldNames.ScheduledStart, "2026-06-25T08:00:00");
var appliedEnd = await policy.TryApplyAsync(
syncContext, WorkOrderFieldNames.ScheduledEnd, "2026-06-25T11:00:00");
Assert.False(appliedStart);
Assert.False(appliedEnd);
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);
}
}