fix(work-orders): address PR review on sync, audit, lock and mapper

This commit is contained in:
Arthur Bassi 2026-07-02 09:30:05 -03:00
parent 76e839addf
commit e5902e1df3
11 changed files with 452 additions and 142 deletions

View file

@ -4,10 +4,6 @@ using Data.SeaHavenIndustries.Enums;
namespace Data.SeaHavenIndustries namespace Data.SeaHavenIndustries
{ {
/// <summary>
/// Work Order aggregate root. Domain slices (Core, Scheduling, Tracking, Analytics, Completion)
/// are persisted as flat columns on workOrders for Fase 0 backward compatibility.
/// </summary>
public class WorkOrder : FullAuditEntity public class WorkOrder : FullAuditEntity
{ {
// --- Core slice --- // --- Core slice ---

View file

@ -0,0 +1,26 @@
namespace SeaHaven.Services.Constants
{
public static class WorkOrderFieldNames
{
public const string LifecycleStatus = nameof(LifecycleStatus);
public const string AssignTo = nameof(AssignTo);
public const string ScheduledDate = nameof(ScheduledDate);
public const string ScheduledEnd = nameof(ScheduledEnd);
public const string TargetWeek = nameof(TargetWeek);
public const string ScheduleWeekOnly = nameof(ScheduleWeekOnly);
public const string DueDate = nameof(DueDate);
public const string Description = nameof(Description);
public const string WorkerOrderTitle = nameof(WorkerOrderTitle);
public const string SiteCode = nameof(SiteCode);
public const string Building = nameof(Building);
public const string LocationId = nameof(LocationId);
public const string WorkOrderType = nameof(WorkOrderType);
public const string Service = nameof(Service);
public const string DocStatus = nameof(DocStatus);
public const string Trade = nameof(Trade);
public const string Problem = nameof(Problem);
public const string Priority = nameof(Priority);
public const string InternalWONumber = nameof(InternalWONumber);
public const string Status = nameof(Status);
}
}

View file

@ -1,86 +1,86 @@
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers namespace SeaHaven.Services.Helpers
{ {
public static class LifecycleStatusMapper public static class LifecycleStatusMapper
{ {
public static LifecycleStatus? FromLegacyStatus(string? status) public static LifecycleStatus? FromLegacyStatus(string? status)
{ {
if (string.IsNullOrWhiteSpace(status)) return null; if (string.IsNullOrWhiteSpace(status)) return null;
return status.Trim().ToLowerInvariant() switch return status.Trim().ToLowerInvariant() switch
{ {
"open" or "incomplete" => LifecycleStatus.Incomplete, "open" or "incomplete" => LifecycleStatus.Incomplete,
"pending" or "onhold" or "on hold" => LifecycleStatus.Pending, "pending" or "onhold" or "on hold" => LifecycleStatus.Pending,
"scheduled" => LifecycleStatus.Scheduled, "scheduled" => LifecycleStatus.Scheduled,
"en route" or "enroute" => LifecycleStatus.EnRoute, "en route" or "enroute" => LifecycleStatus.EnRoute,
"on site" or "onsite" => LifecycleStatus.OnSite, "on site" or "onsite" => LifecycleStatus.OnSite,
"inprogress" or "in progress" => LifecycleStatus.InProgress, "inprogress" or "in progress" => LifecycleStatus.InProgress,
"completed" or "complete" or "done" or "closed" => LifecycleStatus.Completed, "completed" or "complete" or "done" or "closed" => LifecycleStatus.Completed,
"rescheduled" => LifecycleStatus.Rescheduled, "rescheduled" => LifecycleStatus.Rescheduled,
"cancelled" or "canceled" => LifecycleStatus.Canceled, "cancelled" or "canceled" => LifecycleStatus.Canceled,
"pending quote" or "pendingquote" or "pendingapproval" or "pending approval" => LifecycleStatus.PendingQuote, "pending quote" or "pendingquote" or "pendingapproval" or "pending approval" => LifecycleStatus.PendingQuote,
_ => LifecycleStatus.Incomplete _ => null
}; };
} }
public static LifecycleStatus? ParseLifecycleStatus(string? value) public static LifecycleStatus? ParseLifecycleStatus(string? value)
{ {
if (string.IsNullOrWhiteSpace(value)) return null; if (string.IsNullOrWhiteSpace(value)) return null;
if (TryParseFeLabel(value, out var fromLabel)) if (TryParseFeLabel(value, out var fromLabel))
return fromLabel; return fromLabel;
if (Enum.TryParse<LifecycleStatus>(value, true, out var parsed)) if (Enum.TryParse<LifecycleStatus>(value, true, out var parsed))
return parsed; return parsed;
return FromLegacyStatus(value); return FromLegacyStatus(value);
} }
public static bool TryParseFeLabel(string? label, out LifecycleStatus status) public static bool TryParseFeLabel(string? label, out LifecycleStatus status)
{ {
status = default; status = default;
if (string.IsNullOrWhiteSpace(label)) return false; if (string.IsNullOrWhiteSpace(label)) return false;
return label.Trim().ToLowerInvariant() switch return label.Trim().ToLowerInvariant() switch
{ {
"incomplete" => Set(LifecycleStatus.Incomplete, out status), "incomplete" => Set(LifecycleStatus.Incomplete, out status),
"pending" => Set(LifecycleStatus.Pending, out status), "pending" => Set(LifecycleStatus.Pending, out status),
"scheduled" => Set(LifecycleStatus.Scheduled, out status), "scheduled" => Set(LifecycleStatus.Scheduled, out status),
"en route" => Set(LifecycleStatus.EnRoute, out status), "en route" => Set(LifecycleStatus.EnRoute, out status),
"on site" => Set(LifecycleStatus.OnSite, out status), "on site" => Set(LifecycleStatus.OnSite, out status),
"in progress" => Set(LifecycleStatus.InProgress, out status), "in progress" => Set(LifecycleStatus.InProgress, out status),
"completed" => Set(LifecycleStatus.Completed, out status), "completed" => Set(LifecycleStatus.Completed, out status),
"rescheduled" => Set(LifecycleStatus.Rescheduled, out status), "rescheduled" => Set(LifecycleStatus.Rescheduled, out status),
"canceled" => Set(LifecycleStatus.Canceled, out status), "canceled" => Set(LifecycleStatus.Canceled, out status),
"pending quote" => Set(LifecycleStatus.PendingQuote, out status), "pending quote" => Set(LifecycleStatus.PendingQuote, out status),
_ => false _ => false
}; };
} }
public static string ToFeLabel(LifecycleStatus status) => status switch public static string ToFeLabel(LifecycleStatus status) => status switch
{ {
LifecycleStatus.Incomplete => "Incomplete", LifecycleStatus.Incomplete => "Incomplete",
LifecycleStatus.Pending => "Pending", LifecycleStatus.Pending => "Pending",
LifecycleStatus.Scheduled => "Scheduled", LifecycleStatus.Scheduled => "Scheduled",
LifecycleStatus.EnRoute => "En Route", LifecycleStatus.EnRoute => "En Route",
LifecycleStatus.OnSite => "On Site", LifecycleStatus.OnSite => "On Site",
LifecycleStatus.InProgress => "In Progress", LifecycleStatus.InProgress => "In Progress",
LifecycleStatus.Completed => "Completed", LifecycleStatus.Completed => "Completed",
LifecycleStatus.Rescheduled => "Rescheduled", LifecycleStatus.Rescheduled => "Rescheduled",
LifecycleStatus.Canceled => "Canceled", LifecycleStatus.Canceled => "Canceled",
LifecycleStatus.PendingQuote => "Pending Quote", LifecycleStatus.PendingQuote => "Pending Quote",
_ => status.ToString() _ => status.ToString()
}; };
public static bool IsTerminal(LifecycleStatus? status) public static bool IsTerminal(LifecycleStatus? status)
=> status is LifecycleStatus.Completed or LifecycleStatus.Canceled; => status is LifecycleStatus.Completed or LifecycleStatus.Canceled;
private static bool Set(LifecycleStatus value, out LifecycleStatus status) private static bool Set(LifecycleStatus value, out LifecycleStatus status)
{ {
status = value; status = value;
return true; return true;
} }
} }
} }

View file

@ -1,4 +1,6 @@
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation namespace SeaHaven.Services.Implementation
@ -7,24 +9,39 @@ namespace SeaHaven.Services.Implementation
{ {
private static readonly HashSet<string> ShocOwnedFieldSet = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> ShocOwnedFieldSet = new(StringComparer.OrdinalIgnoreCase)
{ {
"LifecycleStatus", "AssignTo", "ScheduledDate", "ScheduledEnd", "TargetWeek", "ScheduleWeekOnly", WorkOrderFieldNames.LifecycleStatus,
"DueDate", "Description", "WorkerOrderTitle", "SiteCode", "Building", "LocationId", WorkOrderFieldNames.AssignTo,
"WorkOrderType", "Service", "DocStatus", "Trade", "Problem", "Priority", "InternalWONumber", "Status" WorkOrderFieldNames.ScheduledDate,
WorkOrderFieldNames.ScheduledEnd,
WorkOrderFieldNames.TargetWeek,
WorkOrderFieldNames.ScheduleWeekOnly,
WorkOrderFieldNames.DueDate,
WorkOrderFieldNames.Description,
WorkOrderFieldNames.WorkerOrderTitle,
WorkOrderFieldNames.SiteCode,
WorkOrderFieldNames.Building,
WorkOrderFieldNames.LocationId,
WorkOrderFieldNames.WorkOrderType,
WorkOrderFieldNames.Service,
WorkOrderFieldNames.DocStatus,
WorkOrderFieldNames.Trade,
WorkOrderFieldNames.Problem,
WorkOrderFieldNames.Priority,
WorkOrderFieldNames.InternalWONumber,
WorkOrderFieldNames.Status,
}; };
private readonly IWorkOrderFieldLockService _fieldLocks; private readonly IWorkOrderFieldLockService _fieldLocks;
private readonly IWorkOrderAuditService _audit;
public SyncFieldMergePolicy(IWorkOrderFieldLockService fieldLocks, IWorkOrderAuditService audit) public SyncFieldMergePolicy(IWorkOrderFieldLockService fieldLocks)
{ {
_fieldLocks = fieldLocks; _fieldLocks = fieldLocks;
_audit = audit;
} }
public IReadOnlySet<string> ShocOwnedFields => ShocOwnedFieldSet; public IReadOnlySet<string> ShocOwnedFields => ShocOwnedFieldSet;
public bool CanSyncOverwrite(int workOrderId, string fieldName) public async Task<bool> CanSyncOverwriteAsync(int workOrderId, string fieldName)
=> !_fieldLocks.IsLockedAsync(workOrderId, fieldName).GetAwaiter().GetResult(); => !await _fieldLocks.IsLockedAsync(workOrderId, fieldName);
public async Task<bool> TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue) public async Task<bool> TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue)
{ {
@ -41,52 +58,162 @@ namespace SeaHaven.Services.Implementation
return false; return false;
} }
ApplyField(context.WorkOrder, fieldName, incomingValue); return TryApplyField(context.WorkOrder, fieldName, incomingValue);
return true;
} }
private static string? GetFieldValue(WorkOrder wo, string fieldName) => fieldName switch private static string? GetFieldValue(WorkOrder wo, string fieldName) => fieldName switch
{ {
"Description" => wo.Description, WorkOrderFieldNames.Description => wo.Description,
"WorkerOrderTitle" => wo.WorkerOrderTitle, WorkOrderFieldNames.WorkerOrderTitle => wo.WorkerOrderTitle,
"Status" => wo.Status, WorkOrderFieldNames.Status => wo.Status,
"DueDate" => wo.DueDate?.ToString("O"), WorkOrderFieldNames.DueDate => wo.DueDate?.ToString("O"),
"SiteCode" => wo.SiteCode, WorkOrderFieldNames.ScheduledDate => wo.ScheduledDate?.ToString("O"),
"Building" => wo.Building, WorkOrderFieldNames.ScheduledEnd => wo.ScheduledEnd?.ToString("O"),
"Priority" => wo.Priority, WorkOrderFieldNames.TargetWeek => wo.TargetWeek?.ToString("O"),
"Severity" => wo.Severity, WorkOrderFieldNames.ScheduleWeekOnly => wo.ScheduleWeekOnly?.ToString(),
WorkOrderFieldNames.SiteCode => wo.SiteCode,
WorkOrderFieldNames.Building => wo.Building,
WorkOrderFieldNames.Priority => wo.Priority,
WorkOrderFieldNames.AssignTo => wo.AssignTo,
WorkOrderFieldNames.LocationId => wo.LocationId?.ToString(),
WorkOrderFieldNames.LifecycleStatus => wo.LifecycleStatus?.ToString(),
WorkOrderFieldNames.WorkOrderType => wo.WorkOrderType?.ToString(),
WorkOrderFieldNames.Service => wo.Service,
WorkOrderFieldNames.DocStatus => wo.DocStatus?.ToString(),
WorkOrderFieldNames.Trade => wo.Trade,
WorkOrderFieldNames.Problem => wo.Problem,
WorkOrderFieldNames.InternalWONumber => wo.InternalWONumber,
_ => null _ => null
}; };
private static void ApplyField(WorkOrder wo, string fieldName, object incomingValue) private static bool TryApplyField(WorkOrder wo, string fieldName, object incomingValue)
{ {
switch (fieldName) switch (fieldName)
{ {
case "Description": case WorkOrderFieldNames.Description:
wo.Description = incomingValue.ToString(); wo.Description = incomingValue.ToString();
break; return true;
case "WorkerOrderTitle": case WorkOrderFieldNames.WorkerOrderTitle:
wo.WorkerOrderTitle = incomingValue.ToString(); wo.WorkerOrderTitle = incomingValue.ToString();
break; return true;
case "Status": case WorkOrderFieldNames.Status:
wo.Status = incomingValue.ToString(); wo.Status = incomingValue.ToString();
break; return true;
case "DueDate" when incomingValue is DateTime dt: case WorkOrderFieldNames.DueDate when TryParseDateTime(incomingValue, out var dueDate):
wo.DueDate = dt; wo.DueDate = dueDate;
break; return true;
case "SiteCode": case WorkOrderFieldNames.ScheduledDate when TryParseDateTime(incomingValue, out var scheduledDate):
wo.ScheduledDate = scheduledDate;
return true;
case WorkOrderFieldNames.ScheduledEnd when TryParseDateTime(incomingValue, out var scheduledEnd):
wo.ScheduledEnd = scheduledEnd;
return true;
case WorkOrderFieldNames.TargetWeek when TryParseDateOnly(incomingValue, out var targetWeek):
wo.TargetWeek = targetWeek;
return true;
case WorkOrderFieldNames.ScheduleWeekOnly when TryParseBool(incomingValue, out var scheduleWeekOnly):
wo.ScheduleWeekOnly = scheduleWeekOnly;
return true;
case WorkOrderFieldNames.SiteCode:
wo.SiteCode = incomingValue.ToString(); wo.SiteCode = incomingValue.ToString();
break; return true;
case "Building": case WorkOrderFieldNames.Building:
wo.Building = incomingValue.ToString(); wo.Building = incomingValue.ToString();
break; return true;
case "Priority": case WorkOrderFieldNames.Priority:
wo.Priority = incomingValue.ToString(); wo.Priority = incomingValue.ToString();
break; return true;
case "Severity": case WorkOrderFieldNames.AssignTo:
wo.Severity = incomingValue.ToString(); wo.AssignTo = incomingValue.ToString();
break; return true;
case WorkOrderFieldNames.LocationId when TryParseInt(incomingValue, out var locationId):
wo.LocationId = locationId;
return true;
case WorkOrderFieldNames.LifecycleStatus when TryParseEnum(incomingValue, out LifecycleStatus lifecycleStatus):
wo.LifecycleStatus = lifecycleStatus;
return true;
case WorkOrderFieldNames.WorkOrderType when TryParseEnum(incomingValue, out WorkOrderType workOrderType):
wo.WorkOrderType = workOrderType;
return true;
case WorkOrderFieldNames.Service:
wo.Service = incomingValue.ToString();
return true;
case WorkOrderFieldNames.DocStatus when TryParseEnum(incomingValue, out DocStatus docStatus):
wo.DocStatus = docStatus;
return true;
case WorkOrderFieldNames.Trade:
wo.Trade = incomingValue.ToString();
return true;
case WorkOrderFieldNames.Problem:
wo.Problem = incomingValue.ToString();
return true;
case WorkOrderFieldNames.InternalWONumber:
wo.InternalWONumber = incomingValue.ToString();
return true;
default:
return false;
} }
} }
private static bool TryParseDateTime(object value, out DateTime result)
{
if (value is DateTime dt)
{
result = dt;
return true;
}
return DateTime.TryParse(value.ToString(), out result);
}
private static bool TryParseDateOnly(object value, out DateOnly result)
{
if (value is DateOnly dateOnly)
{
result = dateOnly;
return true;
}
if (value is DateTime dt)
{
result = DateOnly.FromDateTime(dt);
return true;
}
return DateOnly.TryParse(value.ToString(), out result);
}
private static bool TryParseBool(object value, out bool result)
{
if (value is bool b)
{
result = b;
return true;
}
return bool.TryParse(value.ToString(), out result);
}
private static bool TryParseInt(object value, out int result)
{
if (value is int i)
{
result = i;
return true;
}
return int.TryParse(value.ToString(), out result);
}
private static bool TryParseEnum<TEnum>(object value, out TEnum result) where TEnum : struct, Enum
{
if (value is TEnum enumValue)
{
result = enumValue;
return true;
}
return Enum.TryParse(value.ToString(), true, out result);
}
} }
} }

View file

@ -1,6 +1,7 @@
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation namespace SeaHaven.Services.Implementation
@ -18,11 +19,11 @@ namespace SeaHaven.Services.Implementation
public Task LogStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId) public Task LogStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId)
=> LogAsync(workOrderId, AuditActionType.StatusChanged, AuditEventType.Manual, => LogAsync(workOrderId, AuditActionType.StatusChanged, AuditEventType.Manual,
AuditActorType.Dispatcher, "LifecycleStatus", oldStatus, newStatus, actorId); AuditActorType.Dispatcher, WorkOrderFieldNames.LifecycleStatus, oldStatus, newStatus, actorId);
public Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId) public Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId)
=> LogAsync(workOrderId, AuditActionType.AssignmentChanged, AuditEventType.Manual, => LogAsync(workOrderId, AuditActionType.AssignmentChanged, AuditEventType.Manual,
AuditActorType.Dispatcher, "AssignTo", oldValue, newValue, actorId); AuditActorType.Dispatcher, WorkOrderFieldNames.AssignTo, oldValue, newValue, actorId);
public Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue) public Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue)
=> LogAsync(workOrderId, AuditActionType.SyncRejected, AuditEventType.Sync, => LogAsync(workOrderId, AuditActionType.SyncRejected, AuditEventType.Sync,
@ -63,7 +64,29 @@ namespace SeaHaven.Services.Implementation
await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId); await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId);
} }
await _context.SaveChangesAsync(); 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);
} }
} }
} }

View file

@ -15,14 +15,20 @@ namespace SeaHaven.Services.Implementation
public async Task<bool> IsLockedAsync(int workOrderId, string fieldName) public async Task<bool> IsLockedAsync(int workOrderId, string fieldName)
{ {
if (IsTracked(workOrderId, fieldName))
return true;
return await _context.WorkOrderFieldLocks return await _context.WorkOrderFieldLocks
.AnyAsync(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName); .AnyAsync(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName);
} }
public async Task LockFieldAsync(int workOrderId, string fieldName, string? userId) public async Task LockFieldAsync(int workOrderId, string fieldName, string? userId)
{ {
var exists = await IsLockedAsync(workOrderId, fieldName); if (IsTracked(workOrderId, fieldName))
if (exists) return; return;
if (await IsLockedAsync(workOrderId, fieldName))
return;
_context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock _context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
{ {
@ -31,7 +37,11 @@ namespace SeaHaven.Services.Implementation
LockedAt = DateTime.UtcNow, LockedAt = DateTime.UtcNow,
LockedByUserId = userId LockedByUserId = userId
}); });
await _context.SaveChangesAsync();
} }
private bool IsTracked(int workOrderId, string fieldName)
=> _context.WorkOrderFieldLocks.Local.Any(l =>
l.WorkOrderId == workOrderId &&
string.Equals(l.FieldName, fieldName, StringComparison.OrdinalIgnoreCase));
} }
} }

View file

@ -4,7 +4,7 @@ namespace SeaHaven.Services.Interfaces
{ {
IReadOnlySet<string> ShocOwnedFields { get; } IReadOnlySet<string> ShocOwnedFields { get; }
bool CanSyncOverwrite(int workOrderId, string fieldName); Task<bool> CanSyncOverwriteAsync(int workOrderId, string fieldName);
Task<bool> TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue); Task<bool> TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue);
} }

View file

@ -1,4 +1,5 @@
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Helpers; using SeaHaven.Services.Helpers;
namespace SeaHavenIndustries.Tests; namespace SeaHavenIndustries.Tests;
@ -23,10 +24,10 @@ public class LifecycleStatusMapperTests
} }
[Fact] [Fact]
public void FromLegacyStatus_Unknown_ReturnsIncomplete() public void FromLegacyStatus_Unknown_ReturnsNull()
{ {
var result = LifecycleStatusMapper.FromLegacyStatus("MysteryStatus"); var result = LifecycleStatusMapper.FromLegacyStatus("MysteryStatus");
Assert.Equal(LifecycleStatus.Incomplete, result); Assert.Null(result);
} }
[Theory] [Theory]

View file

@ -1,5 +1,7 @@
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Implementation; using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
@ -24,14 +26,14 @@ public class SyncFieldMergePolicyTests
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
{ {
WorkOrderId = 10, WorkOrderId = 10,
FieldName = "Description", FieldName = WorkOrderFieldNames.Description,
LockedAt = DateTime.UtcNow LockedAt = DateTime.UtcNow
}); });
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var locks = new WorkOrderFieldLockService(context); var locks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, locks); var audit = new WorkOrderAuditService(context, locks);
var policy = new SyncFieldMergePolicy(locks, audit); var policy = new SyncFieldMergePolicy(locks);
var syncContext = new WorkOrderSyncContext var syncContext = new WorkOrderSyncContext
{ {
@ -40,7 +42,7 @@ public class SyncFieldMergePolicyTests
Audit = audit Audit = audit
}; };
var applied = await policy.TryApplyAsync(syncContext, "Description", "Sync value"); var applied = await policy.TryApplyAsync(syncContext, WorkOrderFieldNames.Description, "Sync value");
Assert.False(applied); Assert.False(applied);
Assert.Equal("SHOC edited", wo.Description); Assert.Equal("SHOC edited", wo.Description);
@ -61,7 +63,7 @@ public class SyncFieldMergePolicyTests
var locks = new WorkOrderFieldLockService(context); var locks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, locks); var audit = new WorkOrderAuditService(context, locks);
var policy = new SyncFieldMergePolicy(locks, audit); var policy = new SyncFieldMergePolicy(locks);
var syncContext = new WorkOrderSyncContext var syncContext = new WorkOrderSyncContext
{ {
@ -70,9 +72,80 @@ public class SyncFieldMergePolicyTests
Audit = audit Audit = audit
}; };
var applied = await policy.TryApplyAsync(syncContext, "Description", "From sync"); var applied = await policy.TryApplyAsync(syncContext, WorkOrderFieldNames.Description, "From sync");
Assert.True(applied); Assert.True(applied);
Assert.Equal("From sync", wo.Description); Assert.Equal("From sync", wo.Description);
} }
[Fact]
public async Task TryApply_UnsupportedField_ReturnsFalse()
{
await using var context = CreateContext();
var wo = new WorkOrder { Id = 12 };
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var locks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, locks);
var policy = new SyncFieldMergePolicy(locks);
var syncContext = new WorkOrderSyncContext
{
WorkOrder = wo,
FieldLocks = locks,
Audit = audit
};
var applied = await policy.TryApplyAsync(syncContext, "NotARealField", "value");
Assert.False(applied);
}
[Theory]
[InlineData(WorkOrderFieldNames.LifecycleStatus, LifecycleStatus.Scheduled)]
[InlineData(WorkOrderFieldNames.Service, "PM")]
[InlineData(WorkOrderFieldNames.DueDate, "2026-07-01T10:00:00Z")]
public async Task TryApply_ShocOwnedFields_AppliesValue(string fieldName, object incomingValue)
{
await using var context = CreateContext();
var wo = new WorkOrder { Id = 13 };
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var locks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, locks);
var policy = new SyncFieldMergePolicy(locks);
var syncContext = new WorkOrderSyncContext
{
WorkOrder = wo,
FieldLocks = locks,
Audit = audit
};
var applied = await policy.TryApplyAsync(syncContext, fieldName, incomingValue);
Assert.True(applied);
}
[Fact]
public async Task CanSyncOverwriteAsync_WhenLocked_ReturnsFalse()
{
await using var context = CreateContext();
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
{
WorkOrderId = 20,
FieldName = WorkOrderFieldNames.AssignTo,
LockedAt = DateTime.UtcNow
});
await context.SaveChangesAsync();
var locks = new WorkOrderFieldLockService(context);
var policy = new SyncFieldMergePolicy(locks);
var canOverwrite = await policy.CanSyncOverwriteAsync(20, WorkOrderFieldNames.AssignTo);
Assert.False(canOverwrite);
}
} }

View file

@ -1,6 +1,7 @@
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Implementation; using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
@ -33,7 +34,7 @@ public class WorkOrderAuditServiceTests
Assert.Equal("StatusChanged", logs[0].Action); Assert.Equal("StatusChanged", logs[0].Action);
Assert.Equal("Manual", logs[0].EventType); Assert.Equal("Manual", logs[0].EventType);
Assert.Equal("Dispatcher", logs[0].ActorType); Assert.Equal("Dispatcher", logs[0].ActorType);
Assert.Equal("LifecycleStatus", logs[0].FieldName); Assert.Equal(WorkOrderFieldNames.LifecycleStatus, logs[0].FieldName);
} }
[Fact] [Fact]
@ -50,6 +51,6 @@ public class WorkOrderAuditServiceTests
var fieldLocks = await context.WorkOrderFieldLocks.ToListAsync(); var fieldLocks = await context.WorkOrderFieldLocks.ToListAsync();
Assert.Single(fieldLocks); Assert.Single(fieldLocks);
Assert.Equal("LifecycleStatus", fieldLocks[0].FieldName); Assert.Equal(WorkOrderFieldNames.LifecycleStatus, fieldLocks[0].FieldName);
} }
} }

View file

@ -0,0 +1,53 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Implementation;
namespace SeaHavenIndustries.Tests;
public class WorkOrderFieldLockServiceTests
{
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
[Fact]
public async Task LockFieldAsync_DuplicateCalls_EnqueuesSingleLock()
{
await using var context = CreateContext();
var locks = new WorkOrderFieldLockService(context);
await locks.LockFieldAsync(1, WorkOrderFieldNames.Description, "user-1");
await locks.LockFieldAsync(1, WorkOrderFieldNames.Description, "user-2");
await context.SaveChangesAsync();
var fieldLocks = await context.WorkOrderFieldLocks.ToListAsync();
Assert.Single(fieldLocks);
Assert.Equal(WorkOrderFieldNames.Description, fieldLocks[0].FieldName);
}
[Fact]
public async Task LockFieldAsync_WhenAlreadyPersisted_IsIdempotent()
{
await using var context = CreateContext();
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
{
WorkOrderId = 2,
FieldName = WorkOrderFieldNames.AssignTo,
LockedAt = DateTime.UtcNow,
LockedByUserId = "user-1"
});
await context.SaveChangesAsync();
var locks = new WorkOrderFieldLockService(context);
await locks.LockFieldAsync(2, WorkOrderFieldNames.AssignTo, "user-2");
await context.SaveChangesAsync();
var fieldLocks = await context.WorkOrderFieldLocks.ToListAsync();
Assert.Single(fieldLocks);
}
}