fix(work-orders): align phase 2 status/type contract to SHOC frontend

Expand LifecycleStatus (EnRoute, OnSite, Rescheduled, Pending, PendingQuote) and WorkOrderType (Reactive, AddOn) to match the frontend prototype. Add FE label round-trip via LifecycleStatusMapper/WorkOrderTypeMapper, update derived fields, mutation rules and board unscheduled filter, and reactivate [Authorize] on WorkOrderController. Fix pre-existing dayGroup test expectation.
This commit is contained in:
Arthur Bassi 2026-07-07 13:11:22 -03:00
parent d040832b87
commit 673bc3b5a9
12 changed files with 158 additions and 64 deletions

View file

@ -22,7 +22,7 @@ using SeaHaven.DataServices.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
// [Authorize]
[Authorize]
[ApiController]
[Route("api/WorkOrder")]
[Route("api/workorders")]

View file

@ -10,21 +10,22 @@ namespace Data.SeaHavenIndustries.Enums
OnHold = 5
}
/// <summary>10 operational lifecycle values aligned with SHOC board.</summary>
/// <summary>10 operational lifecycle values aligned with SHOC board (FE labels).</summary>
public enum LifecycleStatus
{
Incomplete = 1,
Scheduled = 2,
InProgress = 3,
Complete = 4,
Canceled = 5,
OnHold = 6,
PendingVendor = 7,
PendingApproval = 8,
NeedsReview = 9,
Closed = 10
Pending = 2,
Scheduled = 3,
EnRoute = 4,
OnSite = 5,
InProgress = 6,
Completed = 7,
Rescheduled = 8,
Canceled = 9,
PendingQuote = 10
}
/// <summary>Board work-order types. Legacy values retained for backward compatibility.</summary>
public enum WorkOrderType
{
PO = 1,
@ -32,6 +33,8 @@ namespace Data.SeaHavenIndustries.Enums
Emergency = 3,
Project = 4,
Inspection = 5,
Reactive = 6,
AddOn = 7,
Other = 99
}

View file

@ -35,9 +35,8 @@ namespace SeaHaven.DataServices.Implementation
var unscheduledBase = baseQuery.Where(w =>
w.ScheduledDate == null
&& w.LifecycleStatus != LifecycleStatus.Complete
&& w.LifecycleStatus != LifecycleStatus.Canceled
&& w.LifecycleStatus != LifecycleStatus.Closed);
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled);
var scheduledTotal = await scheduledBase.CountAsync();

View file

@ -10,25 +10,76 @@ namespace SeaHaven.Services.Helpers
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,
"open" or "incomplete" => LifecycleStatus.Incomplete,
"pending" or "onhold" or "on hold" => LifecycleStatus.Pending,
"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
"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 (Enum.TryParse<LifecycleStatus>(value, true, out var parsed)) return parsed;
if (TryParseFeLabel(value, out var fromLabel))
return fromLabel;
if (Enum.TryParse<LifecycleStatus>(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;
}
}
}

View file

@ -7,8 +7,7 @@ namespace SeaHaven.Services.Helpers
private static readonly HashSet<LifecycleStatus?> ReadOnlyStatuses = new()
{
LifecycleStatus.Canceled,
LifecycleStatus.Closed,
LifecycleStatus.Complete
LifecycleStatus.Completed
};
public static bool IsReadOnly(LifecycleStatus? status) => ReadOnlyStatuses.Contains(status);

View file

@ -4,13 +4,6 @@ namespace SeaHaven.Services.Helpers
{
public static class WorkOrderDerivedFields
{
private static readonly HashSet<LifecycleStatus?> TerminalStatuses = new()
{
LifecycleStatus.Complete,
LifecycleStatus.Canceled,
LifecycleStatus.Closed
};
public static bool IsPastDue(
DateTime? scheduledDate,
LifecycleStatus? lifecycleStatus,
@ -19,7 +12,7 @@ namespace SeaHaven.Services.Helpers
if (!scheduledDate.HasValue)
return false;
if (TerminalStatuses.Contains(lifecycleStatus))
if (LifecycleStatusMapper.IsTerminal(lifecycleStatus))
return false;
var today = (utcNow ?? DateTime.UtcNow).Date;
@ -27,7 +20,7 @@ namespace SeaHaven.Services.Helpers
}
public static bool IsTerminal(LifecycleStatus? lifecycleStatus)
=> TerminalStatuses.Contains(lifecycleStatus);
=> LifecycleStatusMapper.IsTerminal(lifecycleStatus);
public static string? GetDayGroup(DateTime? scheduledDate)
{
@ -57,24 +50,6 @@ namespace SeaHaven.Services.Helpers
}
public static string GetLifecycleStatusLabel(LifecycleStatus? status)
{
if (!status.HasValue)
return "";
return status.Value switch
{
LifecycleStatus.Incomplete => "Incomplete",
LifecycleStatus.Scheduled => "Scheduled",
LifecycleStatus.InProgress => "In Progress",
LifecycleStatus.Complete => "Complete",
LifecycleStatus.Canceled => "Canceled",
LifecycleStatus.OnHold => "On Hold",
LifecycleStatus.PendingVendor => "Pending Vendor",
LifecycleStatus.PendingApproval => "Pending Approval",
LifecycleStatus.NeedsReview => "Needs Review",
LifecycleStatus.Closed => "Closed",
_ => status.Value.ToString()
};
}
=> status.HasValue ? LifecycleStatusMapper.ToFeLabel(status.Value) : "";
}
}

View file

@ -0,0 +1,47 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderTypeMapper
{
/// <summary>Board FE types. Overdue is not a type — use <see cref="WorkOrderDerivedFields.IsPastDue(System.DateTime?, LifecycleStatus?, System.DateTime?)"/>.</summary>
public static bool TryParseFeLabel(string? label, out WorkOrderType type)
{
type = default;
if (string.IsNullOrWhiteSpace(label)) return false;
return label.Trim().ToLowerInvariant() switch
{
"pm" => Set(WorkOrderType.PM, out type),
"reactive" => Set(WorkOrderType.Reactive, out type),
"emergency" => Set(WorkOrderType.Emergency, out type),
"add-on" or "addon" => Set(WorkOrderType.AddOn, out type),
"po" => Set(WorkOrderType.PO, out type),
"project" => Set(WorkOrderType.Project, out type),
"inspection" => Set(WorkOrderType.Inspection, out type),
"other" => Set(WorkOrderType.Other, out type),
"overdue" => false,
_ => Enum.TryParse(label, true, out type)
};
}
public static string ToFeLabel(WorkOrderType type) => type switch
{
WorkOrderType.PM => "PM",
WorkOrderType.Reactive => "Reactive",
WorkOrderType.Emergency => "Emergency",
WorkOrderType.AddOn => "Add-On",
WorkOrderType.PO => "PO",
WorkOrderType.Project => "Project",
WorkOrderType.Inspection => "Inspection",
WorkOrderType.Other => "Other",
_ => type.ToString()
};
private static bool Set(WorkOrderType value, out WorkOrderType type)
{
type = value;
return true;
}
}
}

View file

@ -222,7 +222,7 @@ namespace SeaHaven.Services.Implementation
if (string.IsNullOrWhiteSpace(value))
throw new WorkOrderBoardValidationException("InvalidValue", "workOrderType value is required.");
if (!Enum.TryParse<WorkOrderType>(value, true, out var parsed))
if (!WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
var old = workOrder.WorkOrderType?.ToString();

View file

@ -9,9 +9,9 @@ public class LifecycleStatusMapperTests
[InlineData("Open", LifecycleStatus.Incomplete)]
[InlineData("InProgress", LifecycleStatus.InProgress)]
[InlineData("In Progress", LifecycleStatus.InProgress)]
[InlineData("Completed", LifecycleStatus.Complete)]
[InlineData("Completed", LifecycleStatus.Completed)]
[InlineData("Cancelled", LifecycleStatus.Canceled)]
[InlineData("On Hold", LifecycleStatus.OnHold)]
[InlineData("On Hold", LifecycleStatus.Pending)]
[InlineData("Scheduled", LifecycleStatus.Scheduled)]
public void FromLegacyStatus_MapsKnownValues(string input, LifecycleStatus expected)
{
@ -19,10 +19,31 @@ public class LifecycleStatusMapperTests
Assert.Equal(expected, result);
}
[Theory]
[InlineData("Incomplete", LifecycleStatus.Incomplete)]
[InlineData("En Route", LifecycleStatus.EnRoute)]
[InlineData("On Site", LifecycleStatus.OnSite)]
[InlineData("Rescheduled", LifecycleStatus.Rescheduled)]
[InlineData("Pending Quote", LifecycleStatus.PendingQuote)]
public void ParseLifecycleStatus_MapsFeLabels(string input, LifecycleStatus expected)
{
var result = LifecycleStatusMapper.ParseLifecycleStatus(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData(LifecycleStatus.EnRoute, "En Route")]
[InlineData(LifecycleStatus.OnSite, "On Site")]
[InlineData(LifecycleStatus.PendingQuote, "Pending Quote")]
public void ToFeLabel_ReturnsFrontendLabels(LifecycleStatus status, string expected)
{
Assert.Equal(expected, LifecycleStatusMapper.ToFeLabel(status));
}
[Fact]
public void FromLegacyStatus_Unknown_ReturnsNeedsReview()
public void FromLegacyStatus_Unknown_ReturnsNull()
{
var result = LifecycleStatusMapper.FromLegacyStatus("MysteryStatus");
Assert.Equal(LifecycleStatus.NeedsReview, result);
Assert.Null(result);
}
}

View file

@ -7,8 +7,7 @@ public class WorkOrderBoardMutationRulesTests
{
[Theory]
[InlineData(LifecycleStatus.Canceled, true)]
[InlineData(LifecycleStatus.Closed, true)]
[InlineData(LifecycleStatus.Complete, true)]
[InlineData(LifecycleStatus.Completed, true)]
[InlineData(LifecycleStatus.Scheduled, false)]
[InlineData(LifecycleStatus.Incomplete, false)]
public void IsReadOnly_ReflectsTerminalStatuses(LifecycleStatus status, bool expected)

View file

@ -193,7 +193,7 @@ public class WorkOrderBoardServiceTests
Assert.Equal(1, response.Counts.Total);
Assert.Single(response.Scheduled);
Assert.True(response.Scheduled[0].IsPastDue);
Assert.Equal("wednesday", response.Scheduled[0].DayGroup);
Assert.Equal("monday", response.Scheduled[0].DayGroup);
}
[Fact]

View file

@ -34,7 +34,7 @@ public class WorkOrderDerivedFieldsTests
{
var result = WorkOrderDerivedFields.IsPastDue(
Today.AddDays(-3),
LifecycleStatus.Complete,
LifecycleStatus.Completed,
Today);
Assert.False(result);