diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 5b355c4..610d102 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -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 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; } /// @@ -135,6 +139,44 @@ namespace Api.SeaHavenIndustries.Controllers return Ok(result); } + [HttpPatch("{id:int}/board")] + public async Task 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 Addworkorder([FromForm] Workorder_DTO model) diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index 9511955..99c3277 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -118,6 +118,51 @@ namespace Data.SeaHavenIndustries public DbSet Departments { get; set; } public DbSet JobTitles { get; set; } public DbSet Regions { get; set; } + + public override int SaveChanges() + { + BumpRowVersionsForInMemoryProvider(); + return base.SaveChanges(); + } + + public override Task 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()) + { + if (entry.State == EntityState.Modified) + entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion); + } + + foreach (var entry in ChangeTracker.Entries()) + { + 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 diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index 75b98df..778035e 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -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(); } } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs index be23363..2389fdc 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs @@ -68,5 +68,27 @@ namespace SeaHaven.DataServices.Implementation u.Color)) .ToListAsync(); } + + public async Task 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 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); + } + } } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs index 67f1459..4f23d5a 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs @@ -6,5 +6,7 @@ namespace SeaHaven.DataServices.Interfaces { Task GetBoardRowsAsync(WorkOrderBoardQuery query); Task> GetDispatcherLookupsAsync(); + Task GetBoardRowByIdAsync(int workOrderId); + Task InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId); } } diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index 44fb01b..0173d07 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -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( diff --git a/SeaHaven.Services/Board/WorkOrderBoardConstants.cs b/SeaHaven.Services/Board/WorkOrderBoardConstants.cs deleted file mode 100644 index 78d1ab5..0000000 --- a/SeaHaven.Services/Board/WorkOrderBoardConstants.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace SeaHaven.Services.Board -{ - /// - /// Shared board constants for Phase 2+ features. Not referenced in Phase 1. - /// - public static class WorkOrderBoardConstants - { - public const int MaxWindowDays = 90; - public const int ClientSideThreshold = 300; - } -} diff --git a/SeaHaven.Services/Constants/WorkOrderFieldNames.cs b/SeaHaven.Services/Constants/WorkOrderFieldNames.cs index 967e102..f97a394 100644 --- a/SeaHaven.Services/Constants/WorkOrderFieldNames.cs +++ b/SeaHaven.Services/Constants/WorkOrderFieldNames.cs @@ -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); diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index 8e34bfb..b35c303 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -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 diff --git a/SeaHaven.Services/Exceptions/WorkOrderBoardExceptions.cs b/SeaHaven.Services/Exceptions/WorkOrderBoardExceptions.cs new file mode 100644 index 0000000..c15ecd0 --- /dev/null +++ b/SeaHaven.Services/Exceptions/WorkOrderBoardExceptions.cs @@ -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; } + } +} diff --git a/SeaHaven.Services/Helpers/LifecycleStatusMapper.cs b/SeaHaven.Services/Helpers/LifecycleStatusMapper.cs index b7341d6..551fa66 100644 --- a/SeaHaven.Services/Helpers/LifecycleStatusMapper.cs +++ b/SeaHaven.Services/Helpers/LifecycleStatusMapper.cs @@ -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(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; - } - } -} - \ No newline at end of file +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(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; + } + } +} diff --git a/SeaHaven.Services/Helpers/LifecycleStatusSets.cs b/SeaHaven.Services/Helpers/LifecycleStatusSets.cs new file mode 100644 index 0000000..5427ea6 --- /dev/null +++ b/SeaHaven.Services/Helpers/LifecycleStatusSets.cs @@ -0,0 +1,20 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Helpers +{ + /// + /// 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). + /// + 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; + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs new file mode 100644 index 0000000..cf6e4e8 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs @@ -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 DispatchFields = new(StringComparer.OrdinalIgnoreCase) + { + VendorId, + ApptDate, + ApptTime + }; + + private static readonly HashSet 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 + }; + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs new file mode 100644 index 0000000..3067947 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs @@ -0,0 +1,45 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderBoardMutationRules + { + private static readonly HashSet ReadOnlyStatuses = + LifecycleStatusSets.Terminal.Cast().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; + + /// + /// SHOC rule: Incomplete + specific scheduled date + assignee → Scheduled. + /// Week-only targets do not auto-schedule (same as ). + /// + 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); + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderTypeMapper.cs b/SeaHaven.Services/Helpers/WorkOrderTypeMapper.cs index 64c355c..4eda69f 100644 --- a/SeaHaven.Services/Helpers/WorkOrderTypeMapper.cs +++ b/SeaHaven.Services/Helpers/WorkOrderTypeMapper.cs @@ -1,48 +1,47 @@ -using Data.SeaHavenIndustries.Enums; - -namespace SeaHaven.Services.Helpers -{ - public static class WorkOrderTypeMapper - { - /// Board FE types. Overdue is not a type — use . - 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; - } - } -} - \ No newline at end of file +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderTypeMapper + { + /// Board FE types. Overdue is not a type — use . + 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; + } + } +} diff --git a/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs b/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs index eefdcbd..a14eed8 100644 --- a/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs +++ b/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs @@ -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; diff --git a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs index 19df98b..b0e7c60 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs @@ -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); - } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 03b5b56..093f39a 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -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 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 }; } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs new file mode 100644 index 0000000..98002a2 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -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 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> ApplyFieldMutationAsync( + string field, + WorkOrder workOrder, + Dispatch? dispatch, + string? value, + string auditField) + { + return field switch + { + WorkOrderBoardFieldNames.WoNumber => new List { await ApplyWoNumber(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.WorkOrderType => new List { ApplyWorkOrderType(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.SiteCode => new List { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) }, + WorkOrderBoardFieldNames.LifecycleStatus => new List { ApplyLifecycleStatus(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.AssignTo => ApplyAssignTo(workOrder, value, auditField), + WorkOrderBoardFieldNames.DueDate => new List { ApplyDateField(value, auditField, v => workOrder.DueDate = v, () => workOrder.DueDate) }, + WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField), + WorkOrderBoardFieldNames.TargetWeek => new List { ApplyTargetWeek(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.ScheduleWeekOnly => new List { ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly) }, + WorkOrderBoardFieldNames.VendorId => new List { await ApplyVendorIdAsync(dispatch!, value, auditField) }, + WorkOrderBoardFieldNames.ApptDate => new List { ApplyApptDate(dispatch!, value, auditField) }, + WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value), + WorkOrderBoardFieldNames.DocStatus => new List { ApplyDocStatus(workOrder, value, auditField) }, + _ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.") + }; + } + + private async Task 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 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 setter, + Func 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 ApplyAssignTo(WorkOrder workOrder, string? value, string auditField) + { + var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + var old = workOrder.AssignTo; + var changes = new List(); + + 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 setter, + Func 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 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(); + 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 setter, + Func 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 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 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(); + + 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(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 ApplyAutoScheduleSideEffects(WorkOrder workOrder) + { + var changes = new List(); + 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 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); + } + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs index bb83d5a..3dc4b57 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs @@ -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); } } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs b/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs index d24e64a..2a964cf 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs @@ -6,5 +6,6 @@ namespace SeaHaven.Services.Interfaces { Task GetBoardAsync(WorkOrderBoardQueryDto query, string? currentUserId); Task> GetDispatcherLookupsAsync(); + Task GetBoardRowAsync(int workOrderId); } } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderBoardUpdateService.cs b/SeaHaven.Services/Interfaces/IWorkOrderBoardUpdateService.cs new file mode 100644 index 0000000..7631d35 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderBoardUpdateService.cs @@ -0,0 +1,9 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderBoardUpdateService + { + Task PatchFieldAsync(int workOrderId, WorkOrderBoardPatchRequestDto request, string? actorId); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs new file mode 100644 index 0000000..7039963 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs @@ -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() + .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(() => + 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(() => + svc2.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "C", + WorkOrderVersion = originalVersion + }, "user-2")); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs new file mode 100644 index 0000000..933aa80 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs @@ -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))); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs new file mode 100644 index 0000000..f0b4047 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -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() + .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(() => + 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(() => + 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(() => + 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(() => + 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(() => + 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(() => + 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); + } +}