mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 09:33:13 +00:00
Merge pull request #14 from Sea-Haven-Industries/feat/work-orders-phase-2
Feat/work orders phase 2
This commit is contained in:
commit
df828cf48c
25 changed files with 1898 additions and 190 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
25
SeaHaven.Services/Exceptions/WorkOrderBoardExceptions.cs
Normal file
25
SeaHaven.Services/Exceptions/WorkOrderBoardExceptions.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
SeaHaven.Services/Helpers/LifecycleStatusSets.cs
Normal file
20
SeaHaven.Services/Helpers/LifecycleStatusSets.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
82
SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs
Normal file
82
SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
45
SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs
Normal file
45
SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
583
SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
Normal file
583
SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,5 +6,6 @@ namespace SeaHaven.Services.Interfaces
|
|||
{
|
||||
Task<WorkOrderBoardResponseDto> GetBoardAsync(WorkOrderBoardQueryDto query, string? currentUserId);
|
||||
Task<IReadOnlyList<DispatcherLookupDto>> GetDispatcherLookupsAsync();
|
||||
Task<WorkOrderBoardRowDto?> GetBoardRowAsync(int workOrderId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderBoardUpdateService
|
||||
{
|
||||
Task<WorkOrderBoardRowDto> PatchFieldAsync(int workOrderId, WorkOrderBoardPatchRequestDto request, string? actorId);
|
||||
}
|
||||
}
|
||||
184
SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs
Normal file
184
SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
65
SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs
Normal file
65
SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs
Normal 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)));
|
||||
}
|
||||
}
|
||||
521
SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
Normal file
521
SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue