feat(work-orders): add audit, field lock and sync merge services

This commit is contained in:
Arthur Bassi 2026-06-30 13:20:41 -03:00
parent e244c32bc9
commit 49e5de5152
8 changed files with 370 additions and 0 deletions

View file

@ -0,0 +1,86 @@
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,
_ => LifecycleStatus.Incomplete
};
}
public static LifecycleStatus? ParseLifecycleStatus(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (TryParseFeLabel(value, out var fromLabel))
return fromLabel;
if (Enum.TryParse<LifecycleStatus>(value, true, out var parsed))
return parsed;
return FromLegacyStatus(value);
}
public static bool TryParseFeLabel(string? label, out LifecycleStatus status)
{
status = default;
if (string.IsNullOrWhiteSpace(label)) return false;
return label.Trim().ToLowerInvariant() switch
{
"incomplete" => Set(LifecycleStatus.Incomplete, out status),
"pending" => Set(LifecycleStatus.Pending, out status),
"scheduled" => Set(LifecycleStatus.Scheduled, out status),
"en route" => Set(LifecycleStatus.EnRoute, out status),
"on site" => Set(LifecycleStatus.OnSite, out status),
"in progress" => Set(LifecycleStatus.InProgress, out status),
"completed" => Set(LifecycleStatus.Completed, out status),
"rescheduled" => Set(LifecycleStatus.Rescheduled, out status),
"canceled" => Set(LifecycleStatus.Canceled, out status),
"pending quote" => Set(LifecycleStatus.PendingQuote, out status),
_ => false
};
}
public static string ToFeLabel(LifecycleStatus status) => status switch
{
LifecycleStatus.Incomplete => "Incomplete",
LifecycleStatus.Pending => "Pending",
LifecycleStatus.Scheduled => "Scheduled",
LifecycleStatus.EnRoute => "En Route",
LifecycleStatus.OnSite => "On Site",
LifecycleStatus.InProgress => "In Progress",
LifecycleStatus.Completed => "Completed",
LifecycleStatus.Rescheduled => "Rescheduled",
LifecycleStatus.Canceled => "Canceled",
LifecycleStatus.PendingQuote => "Pending Quote",
_ => status.ToString()
};
public static bool IsTerminal(LifecycleStatus? status)
=> status is LifecycleStatus.Completed or LifecycleStatus.Canceled;
private static bool Set(LifecycleStatus value, out LifecycleStatus status)
{
status = value;
return true;
}
}
}

View file

@ -0,0 +1,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;
}
}
}

View file

@ -0,0 +1,92 @@
using Data.SeaHavenIndustries;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class SyncFieldMergePolicy : ISyncFieldMergePolicy
{
private static readonly HashSet<string> ShocOwnedFieldSet = new(StringComparer.OrdinalIgnoreCase)
{
"LifecycleStatus", "AssignTo", "ScheduledDate", "ScheduledEnd", "TargetWeek", "ScheduleWeekOnly",
"DueDate", "Description", "WorkerOrderTitle", "SiteCode", "Building", "LocationId",
"WorkOrderType", "Service", "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<string> ShocOwnedFields => ShocOwnedFieldSet;
public bool CanSyncOverwrite(int workOrderId, string fieldName)
=> !_fieldLocks.IsLockedAsync(workOrderId, fieldName).GetAwaiter().GetResult();
public async Task<bool> 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;
}
}
}
}

View file

@ -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();
}
}
}

View file

@ -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<bool> 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();
}
}
}

View file

@ -0,0 +1,18 @@
namespace SeaHaven.Services.Interfaces
{
public interface ISyncFieldMergePolicy
{
IReadOnlySet<string> ShocOwnedFields { get; }
bool CanSyncOverwrite(int workOrderId, string fieldName);
Task<bool> 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; }
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,8 @@
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderFieldLockService
{
Task LockFieldAsync(int workOrderId, string fieldName, string? userId);
Task<bool> IsLockedAsync(int workOrderId, string fieldName);
}
}