mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-03 17:53:22 +00:00
feat(work-orders): add audit, field lock and sync merge services
This commit is contained in:
parent
35b3bba596
commit
f52c430c2c
8 changed files with 318 additions and 0 deletions
34
SeaHaven.Services/Helpers/LifecycleStatusMapper.cs
Normal file
34
SeaHaven.Services/Helpers/LifecycleStatusMapper.cs
Normal file
|
|
@ -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<LifecycleStatus>(value, true, out var parsed)) return parsed;
|
||||||
|
return FromLegacyStatus(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
SeaHaven.Services/Helpers/WorkOrderNumberNormalizer.cs
Normal file
37
SeaHaven.Services/Helpers/WorkOrderNumberNormalizer.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs
Normal file
92
SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs
Normal 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", "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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
SeaHaven.Services/Implementation/WorkOrderAuditService.cs
Normal file
69
SeaHaven.Services/Implementation/WorkOrderAuditService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
SeaHaven.Services/Interfaces/ISyncFieldMergePolicy.cs
Normal file
18
SeaHaven.Services/Interfaces/ISyncFieldMergePolicy.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
23
SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs
Normal file
23
SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue