diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs
index a5038d7..18e51bf 100644
--- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs
+++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs
@@ -4,10 +4,6 @@ using Data.SeaHavenIndustries.Enums;
namespace Data.SeaHavenIndustries
{
- ///
- /// Work Order aggregate root. Domain slices (Core, Scheduling, Tracking, Analytics, Completion)
- /// are persisted as flat columns on workOrders for Fase 0 backward compatibility.
- ///
public class WorkOrder : FullAuditEntity
{
// --- Core slice ---
diff --git a/SeaHaven.Services/Constants/WorkOrderFieldNames.cs b/SeaHaven.Services/Constants/WorkOrderFieldNames.cs
new file mode 100644
index 0000000..967e102
--- /dev/null
+++ b/SeaHaven.Services/Constants/WorkOrderFieldNames.cs
@@ -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);
+ }
+}
diff --git a/SeaHaven.Services/Helpers/LifecycleStatusMapper.cs b/SeaHaven.Services/Helpers/LifecycleStatusMapper.cs
index 9f18ad1..b7341d6 100644
--- a/SeaHaven.Services/Helpers/LifecycleStatusMapper.cs
+++ b/SeaHaven.Services/Helpers/LifecycleStatusMapper.cs
@@ -1,86 +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(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;
- }
- }
-}
+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,
+ _ => null
+ };
+ }
+
+ public static LifecycleStatus? ParseLifecycleStatus(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value)) return null;
+
+ if (TryParseFeLabel(value, out var fromLabel))
+ return fromLabel;
+
+ if (Enum.TryParse(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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs b/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs
index ddf05bd..eefdcbd 100644
--- a/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs
+++ b/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs
@@ -1,4 +1,6 @@
using Data.SeaHavenIndustries;
+using Data.SeaHavenIndustries.Enums;
+using SeaHaven.Services.Constants;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
@@ -7,24 +9,39 @@ namespace SeaHaven.Services.Implementation
{
private static readonly HashSet ShocOwnedFieldSet = new(StringComparer.OrdinalIgnoreCase)
{
- "LifecycleStatus", "AssignTo", "ScheduledDate", "ScheduledEnd", "TargetWeek", "ScheduleWeekOnly",
- "DueDate", "Description", "WorkerOrderTitle", "SiteCode", "Building", "LocationId",
- "WorkOrderType", "Service", "DocStatus", "Trade", "Problem", "Priority", "InternalWONumber", "Status"
+ WorkOrderFieldNames.LifecycleStatus,
+ WorkOrderFieldNames.AssignTo,
+ 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 IWorkOrderAuditService _audit;
- public SyncFieldMergePolicy(IWorkOrderFieldLockService fieldLocks, IWorkOrderAuditService audit)
+ public SyncFieldMergePolicy(IWorkOrderFieldLockService fieldLocks)
{
_fieldLocks = fieldLocks;
- _audit = audit;
}
public IReadOnlySet ShocOwnedFields => ShocOwnedFieldSet;
- public bool CanSyncOverwrite(int workOrderId, string fieldName)
- => !_fieldLocks.IsLockedAsync(workOrderId, fieldName).GetAwaiter().GetResult();
+ public async Task CanSyncOverwriteAsync(int workOrderId, string fieldName)
+ => !await _fieldLocks.IsLockedAsync(workOrderId, fieldName);
public async Task TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue)
{
@@ -41,52 +58,162 @@ namespace SeaHaven.Services.Implementation
return false;
}
- ApplyField(context.WorkOrder, fieldName, incomingValue);
- return true;
+ return TryApplyField(context.WorkOrder, fieldName, incomingValue);
}
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,
+ WorkOrderFieldNames.Description => wo.Description,
+ WorkOrderFieldNames.WorkerOrderTitle => wo.WorkerOrderTitle,
+ WorkOrderFieldNames.Status => wo.Status,
+ WorkOrderFieldNames.DueDate => wo.DueDate?.ToString("O"),
+ WorkOrderFieldNames.ScheduledDate => wo.ScheduledDate?.ToString("O"),
+ WorkOrderFieldNames.ScheduledEnd => wo.ScheduledEnd?.ToString("O"),
+ WorkOrderFieldNames.TargetWeek => wo.TargetWeek?.ToString("O"),
+ 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
};
- private static void ApplyField(WorkOrder wo, string fieldName, object incomingValue)
+ private static bool TryApplyField(WorkOrder wo, string fieldName, object incomingValue)
{
switch (fieldName)
{
- case "Description":
+ case WorkOrderFieldNames.Description:
wo.Description = incomingValue.ToString();
- break;
- case "WorkerOrderTitle":
+ return true;
+ case WorkOrderFieldNames.WorkerOrderTitle:
wo.WorkerOrderTitle = incomingValue.ToString();
- break;
- case "Status":
+ return true;
+ case WorkOrderFieldNames.Status:
wo.Status = incomingValue.ToString();
- break;
- case "DueDate" when incomingValue is DateTime dt:
- wo.DueDate = dt;
- break;
- case "SiteCode":
+ return true;
+ case WorkOrderFieldNames.DueDate when TryParseDateTime(incomingValue, out var dueDate):
+ wo.DueDate = dueDate;
+ return true;
+ 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();
- break;
- case "Building":
+ return true;
+ case WorkOrderFieldNames.Building:
wo.Building = incomingValue.ToString();
- break;
- case "Priority":
+ return true;
+ case WorkOrderFieldNames.Priority:
wo.Priority = incomingValue.ToString();
- break;
- case "Severity":
- wo.Severity = incomingValue.ToString();
- break;
+ return true;
+ case WorkOrderFieldNames.AssignTo:
+ wo.AssignTo = incomingValue.ToString();
+ 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(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);
+ }
}
}
diff --git a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs
index 4543c91..19df98b 100644
--- a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs
@@ -1,6 +1,7 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
+using SeaHaven.Services.Constants;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
@@ -18,11 +19,11 @@ namespace SeaHaven.Services.Implementation
public Task LogStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId)
=> 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)
=> 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)
=> LogAsync(workOrderId, AuditActionType.SyncRejected, AuditEventType.Sync,
@@ -63,7 +64,29 @@ namespace SeaHaven.Services.Implementation
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);
}
}
}
diff --git a/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs b/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs
index d132ea8..442fbb3 100644
--- a/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs
@@ -15,14 +15,20 @@ namespace SeaHaven.Services.Implementation
public async Task IsLockedAsync(int workOrderId, string fieldName)
{
+ if (IsTracked(workOrderId, fieldName))
+ return true;
+
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;
+ if (IsTracked(workOrderId, fieldName))
+ return;
+
+ if (await IsLockedAsync(workOrderId, fieldName))
+ return;
_context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
{
@@ -31,7 +37,11 @@ namespace SeaHaven.Services.Implementation
LockedAt = DateTime.UtcNow,
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));
}
}
diff --git a/SeaHaven.Services/Interfaces/ISyncFieldMergePolicy.cs b/SeaHaven.Services/Interfaces/ISyncFieldMergePolicy.cs
index 96b401d..12f9398 100644
--- a/SeaHaven.Services/Interfaces/ISyncFieldMergePolicy.cs
+++ b/SeaHaven.Services/Interfaces/ISyncFieldMergePolicy.cs
@@ -4,7 +4,7 @@ namespace SeaHaven.Services.Interfaces
{
IReadOnlySet ShocOwnedFields { get; }
- bool CanSyncOverwrite(int workOrderId, string fieldName);
+ Task CanSyncOverwriteAsync(int workOrderId, string fieldName);
Task TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue);
}
diff --git a/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs b/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs
index cd763f4..0a06141 100644
--- a/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs
+++ b/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs
@@ -1,4 +1,5 @@
using Data.SeaHavenIndustries.Enums;
+using SeaHaven.Services.Constants;
using SeaHaven.Services.Helpers;
namespace SeaHavenIndustries.Tests;
@@ -23,10 +24,10 @@ public class LifecycleStatusMapperTests
}
[Fact]
- public void FromLegacyStatus_Unknown_ReturnsIncomplete()
+ public void FromLegacyStatus_Unknown_ReturnsNull()
{
var result = LifecycleStatusMapper.FromLegacyStatus("MysteryStatus");
- Assert.Equal(LifecycleStatus.Incomplete, result);
+ Assert.Null(result);
}
[Theory]
diff --git a/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs b/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs
index e88f262..e9831d7 100644
--- a/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs
+++ b/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs
@@ -1,5 +1,7 @@
using Data.SeaHavenIndustries;
+using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
+using SeaHaven.Services.Constants;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
@@ -24,14 +26,14 @@ public class SyncFieldMergePolicyTests
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
{
WorkOrderId = 10,
- FieldName = "Description",
+ FieldName = WorkOrderFieldNames.Description,
LockedAt = DateTime.UtcNow
});
await context.SaveChangesAsync();
var locks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, locks);
- var policy = new SyncFieldMergePolicy(locks, audit);
+ var policy = new SyncFieldMergePolicy(locks);
var syncContext = new WorkOrderSyncContext
{
@@ -40,7 +42,7 @@ public class SyncFieldMergePolicyTests
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.Equal("SHOC edited", wo.Description);
@@ -61,7 +63,7 @@ public class SyncFieldMergePolicyTests
var locks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, locks);
- var policy = new SyncFieldMergePolicy(locks, audit);
+ var policy = new SyncFieldMergePolicy(locks);
var syncContext = new WorkOrderSyncContext
{
@@ -70,9 +72,80 @@ public class SyncFieldMergePolicyTests
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.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);
+ }
}
diff --git a/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs
index f3058c3..f7df5ed 100644
--- a/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs
@@ -1,6 +1,7 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
+using SeaHaven.Services.Constants;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
@@ -33,7 +34,7 @@ public class WorkOrderAuditServiceTests
Assert.Equal("StatusChanged", logs[0].Action);
Assert.Equal("Manual", logs[0].EventType);
Assert.Equal("Dispatcher", logs[0].ActorType);
- Assert.Equal("LifecycleStatus", logs[0].FieldName);
+ Assert.Equal(WorkOrderFieldNames.LifecycleStatus, logs[0].FieldName);
}
[Fact]
@@ -50,6 +51,6 @@ public class WorkOrderAuditServiceTests
var fieldLocks = await context.WorkOrderFieldLocks.ToListAsync();
Assert.Single(fieldLocks);
- Assert.Equal("LifecycleStatus", fieldLocks[0].FieldName);
+ Assert.Equal(WorkOrderFieldNames.LifecycleStatus, fieldLocks[0].FieldName);
}
}
diff --git a/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs
new file mode 100644
index 0000000..b598a4d
--- /dev/null
+++ b/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs
@@ -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()
+ .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);
+ }
+}