mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +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