diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index aaf1778..84129ed 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -82,7 +82,8 @@ namespace SeaHaven.DataServices.Helpers w.FlagColor, w.PrimaryDispatchId, w.RowVersion, - DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null + DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null, + w.Severity }) .ToListAsync(cancellationToken); @@ -155,7 +156,8 @@ namespace SeaHaven.DataServices.Helpers w.HasAvetaDocument, w.OriginalDate, w.OriginalWeek, - isUnscheduled); + isUnscheduled, + w.Severity); }).ToList(); } diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index da84f94..46f7f65 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -63,7 +63,8 @@ namespace SeaHaven.DataServices.Interfaces bool HasAvetaDocument, DateOnly? OriginalDate, DateOnly? OriginalWeek, - bool IsUnscheduled); + bool IsUnscheduled, + string? Severity = null); public record WorkOrderBoardQueryResult( IReadOnlyList ScheduledRows, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index c4704ea..6f4af86 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -82,6 +82,8 @@ namespace SeaHaven.Services.DTOs public DateTime? CompletedDate { get; set; } /// Board flag color (#RRGGBB). Null = no flag. Distinct from Color (dispatcher avatar). public string? FlagColor { get; set; } + /// Severity 1–5 for Emergency/Reactive. Null when unset or not applicable. + public int? Severity { get; set; } public int? PrimaryDispatchId { get; set; } public byte[]? RowVersion { get; set; } public byte[]? DispatchRowVersion { get; set; } diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 2f66e06..e53adb1 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -53,6 +53,8 @@ namespace SeaHaven.Services.DTOs public List? AdditionalContacts { get; set; } public string? TechPhone { get; set; } public string? VendorNotes { get; set; } + /// Severity 1–5 for Emergency/Reactive. Ignored for other types. + public int? Severity { get; set; } } /// Advanced search / filter query for the board. diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs index 4056197..52eb5b5 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs @@ -22,6 +22,7 @@ namespace SeaHaven.Services.Helpers public const string AdditionalContacts = "additionalContacts"; public const string FlagColor = "flagColor"; public const string AvetaRequired = "avetaRequired"; + public const string Severity = "severity"; public const string RescheduleCount = "RescheduleCount"; private static readonly HashSet DispatchFields = new(StringComparer.OrdinalIgnoreCase) @@ -52,7 +53,8 @@ namespace SeaHaven.Services.Helpers ExtraServices, AdditionalContacts, FlagColor, - AvetaRequired + AvetaRequired, + Severity }; public static bool IsEditable(string field) => Canonicalize(field) != null; @@ -96,6 +98,7 @@ namespace SeaHaven.Services.Helpers AdditionalContacts => "AdditionalContacts", FlagColor => "FlagColor", AvetaRequired => "AvetaRequired", + Severity => "Severity", _ => canonical }; } diff --git a/SeaHaven.Services/Helpers/WorkOrderSeverityRules.cs b/SeaHaven.Services/Helpers/WorkOrderSeverityRules.cs new file mode 100644 index 0000000..9266825 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderSeverityRules.cs @@ -0,0 +1,34 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderSeverityRules + { + public static bool TypeRequiresSeverity(WorkOrderType? type) + => type is WorkOrderType.Emergency or WorkOrderType.Reactive; + + public static bool TryNormalize(string? value, out string? normalized) + { + normalized = null; + if (string.IsNullOrWhiteSpace(value)) + return true; + + var trimmed = value.Trim(); + if (trimmed is "1" or "2" or "3" or "4" or "5") + { + normalized = trimmed; + return true; + } + + return false; + } + + public static int? ParseLevel(string? stored) + { + if (!TryNormalize(stored, out var normalized) || normalized == null) + return null; + + return int.Parse(normalized, System.Globalization.CultureInfo.InvariantCulture); + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index 536054f..03e4062 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -74,11 +74,14 @@ namespace SeaHaven.Services.Implementation throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist."); } + var severity = ResolveSeverity(request); + var workOrder = new WorkOrder { InternalWONumber = woNumber, WorkerOrderNumber = woNumber, WorkOrderType = request.WorkOrderType, + Severity = severity, SiteCode = siteCode, Customer = customer, AccountId = accountId, @@ -128,6 +131,9 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardFieldMutations.BoardFieldChange.Changed("LifecycleStatus", "", workOrder.LifecycleStatus?.ToString() ?? LifecycleStatus.Incomplete.ToString()) }; + if (workOrder.Severity != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Severity", "", workOrder.Severity)); + if (workOrder.Description != null) changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Description", "", workOrder.Description)); if (workOrder.Trade != null) @@ -276,6 +282,20 @@ namespace SeaHaven.Services.Implementation return json; } + private static string? ResolveSeverity(WorkOrderBoardCreateRequestDto request) + { + if (!request.Severity.HasValue) + return null; + + if (!WorkOrderSeverityRules.TryNormalize(request.Severity.Value.ToString(), out var normalized)) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid severity: {request.Severity}"); + + if (normalized == null || !WorkOrderSeverityRules.TypeRequiresSeverity(request.WorkOrderType)) + return null; + + return normalized; + } + private static string? TrimOrNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 8e8aa7d..96f178d 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -139,6 +139,7 @@ namespace SeaHaven.Services.Implementation DocStatus = row.DocStatus, CompletedDate = row.CompletedDate, FlagColor = row.FlagColor, + Severity = WorkOrderSeverityRules.ParseLevel(row.Severity), PrimaryDispatchId = row.PrimaryDispatchId, RowVersion = row.RowVersion, DispatchRowVersion = row.DispatchRowVersion, diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 01ab004..631b2c0 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -180,7 +180,8 @@ namespace SeaHaven.Services.Implementation return field switch { WorkOrderBoardFieldNames.WoNumber => new List { await ApplyWoNumber(workOrder, value, auditField, cancellationToken) }, - WorkOrderBoardFieldNames.WorkOrderType => new List { ApplyWorkOrderType(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.WorkOrderType => ApplyWorkOrderType(workOrder, value, auditField), + WorkOrderBoardFieldNames.Severity => new List { ApplySeverity(workOrder, value, auditField) }, WorkOrderBoardFieldNames.SiteCode => new List { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) }, WorkOrderBoardFieldNames.LifecycleStatus => new List { ApplyLifecycleStatus(workOrder, value, auditField) }, WorkOrderBoardFieldNames.AssignTo => ApplyAssignTo(workOrder, value, auditField), @@ -345,7 +346,7 @@ namespace SeaHaven.Services.Implementation return FieldChange.ForField(auditField, old, normalized); } - private static FieldChange ApplyWorkOrderType(WorkOrder workOrder, string? value, string auditField) + private static List ApplyWorkOrderType(WorkOrder workOrder, string? value, string auditField) { if (string.IsNullOrWhiteSpace(value)) throw new WorkOrderBoardValidationException("InvalidValue", "workOrderType value is required."); @@ -358,12 +359,38 @@ namespace SeaHaven.Services.Implementation if (!WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed)) throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}"); + var changes = new List(); var old = workOrder.WorkOrderType?.ToString(); - if (workOrder.WorkOrderType == parsed) + if (workOrder.WorkOrderType != parsed) + { + workOrder.WorkOrderType = parsed; + changes.Add(FieldChange.ForField(auditField, old, parsed.ToString())); + } + + if (!WorkOrderSeverityRules.TypeRequiresSeverity(parsed) && workOrder.Severity != null) + { + var previousSeverity = workOrder.Severity; + workOrder.Severity = null; + changes.Add(FieldChange.ForField("Severity", previousSeverity, null)); + } + + if (changes.Count == 0) + changes.Add(FieldChange.Unchanged(auditField)); + + return changes; + } + + private static FieldChange ApplySeverity(WorkOrder workOrder, string? value, string auditField) + { + if (!WorkOrderSeverityRules.TryNormalize(value, out var normalized)) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid severity: {value}"); + + var old = workOrder.Severity; + if (old == normalized) return FieldChange.Unchanged(auditField); - workOrder.WorkOrderType = parsed; - return FieldChange.ForField(auditField, old, parsed.ToString()); + workOrder.Severity = normalized; + return FieldChange.ForField(auditField, old, normalized); } private static FieldChange ApplyStringField( diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index ff2f3d6..cdf7559 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -704,4 +704,55 @@ public class WorkOrderBoardCreateServiceTests LifecycleStatus = LifecycleStatus.Scheduled, }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); } + + [Fact] + public async Task Create_EmergencyWithSeverity_Persists() + { + var (context, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.Emergency, + SiteCode = "BK5", + LocationId = 1, + Severity = 2 + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + Assert.Equal(2, result.Severity); + Assert.Equal("2", (await context.workOrders.SingleAsync()).Severity); + } + + [Fact] + public async Task Create_PmWithSeverity_Ignores() + { + var (context, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + LocationId = 1, + Severity = 2 + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + Assert.Null(result.Severity); + Assert.Null((await context.workOrders.SingleAsync()).Severity); + } + + [Fact] + public async Task Create_SeverityOutOfRange_ThrowsInvalidValue() + { + var (_, service) = CreateSut(); + + var ex = await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.Emergency, + SiteCode = "BK5", + LocationId = 1, + Severity = 6 + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); + + Assert.Equal("InvalidValue", ex.Code); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index 3588666..ed3ed09 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -1024,4 +1024,60 @@ public class WorkOrderBoardServiceTests OriginalDate: null, OriginalWeek: null, IsUnscheduled: false); + + [Fact] + public async Task GetBoardAsync_MapsNumericSeverity() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + WorkOrderType = WorkOrderType.Emergency, + Severity = "2", + ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc), + LifecycleStatus = LifecycleStatus.Scheduled + }); + await context.SaveChangesAsync(); + + var boardService = new WorkOrderBoardService( + new WorkOrderBoardDataService(context), + WorkOrderAccountTestHelpers.Resolver(context)); + var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto + { + WeekStart = weekStart + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(2, response.Scheduled[0].Severity); + } + + [Fact] + public async Task GetBoardAsync_MapsNonNumericSeverityAsNull() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + WorkOrderType = WorkOrderType.Reactive, + Severity = "Sev 2", + ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc), + LifecycleStatus = LifecycleStatus.Scheduled + }); + await context.SaveChangesAsync(); + + var boardService = new WorkOrderBoardService( + new WorkOrderBoardDataService(context), + WorkOrderAccountTestHelpers.Resolver(context)); + var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto + { + WeekStart = weekStart + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Null(response.Scheduled[0].Severity); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 423fa8f..f37686e 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -1879,4 +1879,147 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal("InvalidValue", ex.Code); } + + [Fact] + public async Task PatchField_Severity_PersistsAndAudits() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + WorkOrderType = WorkOrderType.PM, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.Severity, + Value = "2", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(2, result.Severity); + Assert.Equal("2", (await context.workOrders.FindAsync(1))!.Severity); + var audits = await context.WorkOrderAuditLogs.Where(a => a.FieldName == "Severity").ToListAsync(); + Assert.Single(audits); + Assert.Equal("2", audits[0].NewValue); + } + + [Fact] + public async Task PatchField_SeverityEmpty_Clears() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + WorkOrderType = WorkOrderType.Emergency, + Severity = "1", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.Severity, + Value = "", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Null(result.Severity); + Assert.Null((await context.workOrders.FindAsync(1))!.Severity); + } + + [Theory] + [InlineData("0")] + [InlineData("6")] + [InlineData("abc")] + public async Task PatchField_SeverityInvalid_ThrowsInvalidValue(string value) + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.Severity, + Value = value, + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("InvalidValue", ex.Code); + } + + [Fact] + public async Task PatchField_WorkOrderTypeToPm_ClearsSeverity() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + WorkOrderType = WorkOrderType.Emergency, + Severity = "2", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.WorkOrderType, + Value = "PM", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(WorkOrderType.PM, result.WorkOrderType); + Assert.Null(result.Severity); + Assert.Null((await context.workOrders.FindAsync(1))!.Severity); + Assert.Contains(await context.WorkOrderAuditLogs.ToListAsync(), a => a.FieldName == "Severity" && a.NewValue == ""); + } + + [Fact] + public async Task PatchField_SeverityThenWorkOrderType_MatchesFrontendSequence() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + WorkOrderType = WorkOrderType.PM, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var afterSeverity = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.Severity, + Value = "2", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + var updated = await context.workOrders.FindAsync(1); + var afterType = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.WorkOrderType, + Value = "Emergency", + WorkOrderVersion = ToVersion(updated!) + }, "actor-1"); + + Assert.Equal(2, afterSeverity.Severity); + Assert.Equal(WorkOrderType.Emergency, afterType.WorkOrderType); + Assert.Equal(2, afterType.Severity); + } }