diff --git a/SeaHaven.Services/Helpers/LifecycleStatusMapper.cs b/SeaHaven.Services/Helpers/LifecycleStatusMapper.cs new file mode 100644 index 0000000..348048f --- /dev/null +++ b/SeaHaven.Services/Helpers/LifecycleStatusMapper.cs @@ -0,0 +1,34 @@ +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" => LifecycleStatus.Incomplete, + "inprogress" or "in progress" => LifecycleStatus.InProgress, + "completed" or "complete" => LifecycleStatus.Complete, + "cancelled" or "canceled" => LifecycleStatus.Canceled, + "onhold" or "on hold" => LifecycleStatus.OnHold, + "scheduled" => LifecycleStatus.Scheduled, + "pendingvendor" or "pending vendor" => LifecycleStatus.PendingVendor, + "pendingapproval" or "pending approval" => LifecycleStatus.PendingApproval, + "needsreview" or "needs review" => LifecycleStatus.NeedsReview, + "closed" => LifecycleStatus.Closed, + _ => LifecycleStatus.NeedsReview + }; + } + + public static LifecycleStatus? ParseLifecycleStatus(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + if (Enum.TryParse(value, true, out var parsed)) return parsed; + return FromLegacyStatus(value); + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderNumberNormalizer.cs b/SeaHaven.Services/Helpers/WorkOrderNumberNormalizer.cs new file mode 100644 index 0000000..59f8494 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderNumberNormalizer.cs @@ -0,0 +1,37 @@ +using System.Text.RegularExpressions; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderNumberNormalizer + { + private const int RequiredLength = 11; + + public static bool TryNormalize(string? input, out string normalized, out string? error) + { + normalized = ""; + error = null; + + if (string.IsNullOrWhiteSpace(input)) + { + error = "WO number is required."; + return false; + } + + var digits = Regex.Replace(input.Trim(), @"\D", ""); + if (digits.Length == 0) + { + error = "WO number must contain digits."; + return false; + } + + if (digits.Length > RequiredLength) + { + error = $"WO number cannot exceed {RequiredLength} digits."; + return false; + } + + normalized = digits.PadLeft(RequiredLength, '0'); + return true; + } + } +} diff --git a/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs b/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs new file mode 100644 index 0000000..6d564e3 --- /dev/null +++ b/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs @@ -0,0 +1,92 @@ +using Data.SeaHavenIndustries; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class SyncFieldMergePolicy : ISyncFieldMergePolicy + { + private static readonly HashSet ShocOwnedFieldSet = new(StringComparer.OrdinalIgnoreCase) + { + "LifecycleStatus", "AssignTo", "ScheduledDate", "ScheduledEnd", "TargetWeek", "ScheduleWeekOnly", + "DueDate", "Description", "WorkerOrderTitle", "SiteCode", "Building", "LocationId", + "WorkOrderType", "DocStatus", "Trade", "Problem", "Priority", "InternalWONumber", "Status" + }; + + private readonly IWorkOrderFieldLockService _fieldLocks; + private readonly IWorkOrderAuditService _audit; + + public SyncFieldMergePolicy(IWorkOrderFieldLockService fieldLocks, IWorkOrderAuditService audit) + { + _fieldLocks = fieldLocks; + _audit = audit; + } + + public IReadOnlySet ShocOwnedFields => ShocOwnedFieldSet; + + public bool CanSyncOverwrite(int workOrderId, string fieldName) + => !_fieldLocks.IsLockedAsync(workOrderId, fieldName).GetAwaiter().GetResult(); + + public async Task TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue) + { + if (incomingValue == null) return false; + + if (await context.FieldLocks.IsLockedAsync(context.WorkOrder.Id, fieldName)) + { + var current = GetFieldValue(context.WorkOrder, fieldName); + await context.Audit.LogSyncRejectedAsync( + context.WorkOrder.Id, + fieldName, + current, + incomingValue.ToString()); + return false; + } + + ApplyField(context.WorkOrder, fieldName, incomingValue); + return true; + } + + private static string? GetFieldValue(WorkOrder wo, string fieldName) => fieldName switch + { + "Description" => wo.Description, + "WorkerOrderTitle" => wo.WorkerOrderTitle, + "Status" => wo.Status, + "DueDate" => wo.DueDate?.ToString("O"), + "SiteCode" => wo.SiteCode, + "Building" => wo.Building, + "Priority" => wo.Priority, + "Severity" => wo.Severity, + _ => null + }; + + private static void ApplyField(WorkOrder wo, string fieldName, object incomingValue) + { + switch (fieldName) + { + case "Description": + wo.Description = incomingValue.ToString(); + break; + case "WorkerOrderTitle": + wo.WorkerOrderTitle = incomingValue.ToString(); + break; + case "Status": + wo.Status = incomingValue.ToString(); + break; + case "DueDate" when incomingValue is DateTime dt: + wo.DueDate = dt; + break; + case "SiteCode": + wo.SiteCode = incomingValue.ToString(); + break; + case "Building": + wo.Building = incomingValue.ToString(); + break; + case "Priority": + wo.Priority = incomingValue.ToString(); + break; + case "Severity": + wo.Severity = incomingValue.ToString(); + break; + } + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs new file mode 100644 index 0000000..4543c91 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs @@ -0,0 +1,69 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderAuditService : IWorkOrderAuditService + { + private readonly ApplicationDbContext _context; + private readonly IWorkOrderFieldLockService _fieldLocks; + + public WorkOrderAuditService(ApplicationDbContext context, IWorkOrderFieldLockService fieldLocks) + { + _context = context; + _fieldLocks = fieldLocks; + } + + public Task LogStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId) + => LogAsync(workOrderId, AuditActionType.StatusChanged, AuditEventType.Manual, + 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, "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 async Task LogAsync( + int workOrderId, + AuditActionType action, + AuditEventType eventType, + AuditActorType actorType, + string fieldName, + string? oldValue, + string? newValue, + string? actorId = null, + int? dispatchId = null, + string? correlationId = null) + { + _context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + { + WorkOrderId = workOrderId, + UserId = actorId, + FieldName = fieldName, + OldValue = oldValue ?? "", + NewValue = newValue ?? "", + Action = action.ToString(), + EventType = eventType.ToString(), + ActorType = actorType.ToString(), + DispatchId = dispatchId, + 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 _context.SaveChangesAsync(); + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs b/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs new file mode 100644 index 0000000..d132ea8 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs @@ -0,0 +1,37 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderFieldLockService : IWorkOrderFieldLockService + { + private readonly ApplicationDbContext _context; + + public WorkOrderFieldLockService(ApplicationDbContext context) + { + _context = context; + } + + public async Task IsLockedAsync(int workOrderId, string fieldName) + { + return await _context.WorkOrderFieldLocks + .AnyAsync(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName); + } + + public async Task LockFieldAsync(int workOrderId, string fieldName, string? userId) + { + var exists = await IsLockedAsync(workOrderId, fieldName); + if (exists) return; + + _context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock + { + WorkOrderId = workOrderId, + FieldName = fieldName, + LockedAt = DateTime.UtcNow, + LockedByUserId = userId + }); + await _context.SaveChangesAsync(); + } + } +} diff --git a/SeaHaven.Services/Interfaces/ISyncFieldMergePolicy.cs b/SeaHaven.Services/Interfaces/ISyncFieldMergePolicy.cs new file mode 100644 index 0000000..96b401d --- /dev/null +++ b/SeaHaven.Services/Interfaces/ISyncFieldMergePolicy.cs @@ -0,0 +1,18 @@ +namespace SeaHaven.Services.Interfaces +{ + public interface ISyncFieldMergePolicy + { + IReadOnlySet ShocOwnedFields { get; } + + bool CanSyncOverwrite(int workOrderId, string fieldName); + + Task TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue); + } + + public class WorkOrderSyncContext + { + public required Data.SeaHavenIndustries.WorkOrder WorkOrder { get; init; } + public required SeaHaven.Services.Interfaces.IWorkOrderFieldLockService FieldLocks { get; init; } + public required SeaHaven.Services.Interfaces.IWorkOrderAuditService Audit { get; init; } + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs new file mode 100644 index 0000000..bb83d5a --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs @@ -0,0 +1,23 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderAuditService + { + Task LogAsync( + int workOrderId, + AuditActionType action, + AuditEventType eventType, + AuditActorType actorType, + string fieldName, + string? oldValue, + string? newValue, + string? actorId = null, + int? dispatchId = null, + string? correlationId = null); + + 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); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderFieldLockService.cs b/SeaHaven.Services/Interfaces/IWorkOrderFieldLockService.cs new file mode 100644 index 0000000..2254724 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderFieldLockService.cs @@ -0,0 +1,8 @@ +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderFieldLockService + { + Task LockFieldAsync(int workOrderId, string fieldName, string? userId); + Task IsLockedAsync(int workOrderId, string fieldName); + } +}