diff --git a/Data.SeaHavenIndustries/Migrations/20260730150000_WoIsAddOn.cs b/Data.SeaHavenIndustries/Migrations/20260730150000_WoIsAddOn.cs
new file mode 100644
index 0000000..92484c0
--- /dev/null
+++ b/Data.SeaHavenIndustries/Migrations/20260730150000_WoIsAddOn.cs
@@ -0,0 +1,37 @@
+using Data.SeaHavenIndustries;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Data.SeaHavenIndustries.Migrations
+{
+ ///
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260730150000_WoIsAddOn")]
+ public partial class WoIsAddOn : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "IsAddOn",
+ table: "workOrders",
+ type: "bit",
+ nullable: false,
+ defaultValue: false);
+
+ // Legacy WorkOrderType.AddOn (7) → flag; do not remap type (real type unknown).
+ migrationBuilder.Sql(
+ "UPDATE workOrders SET IsAddOn = 1 WHERE WorkOrderType = 7;");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "IsAddOn",
+ table: "workOrders");
+ }
+ }
+}
diff --git a/Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs b/Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs
new file mode 100644
index 0000000..c5fe2cd
--- /dev/null
+++ b/Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs
@@ -0,0 +1,29 @@
+using Data.SeaHavenIndustries;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Data.SeaHavenIndustries.Migrations
+{
+ ///
+ ///
+ /// SH-121 originally added IsAddOn for search facets. Column + legacy backfill
+ /// now live in 20260730150000_WoIsAddOn (SH-184). This migration is a no-op so
+ /// both PRs can land without a duplicate column.
+ ///
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260813193000_SH121_BoardSearchFacets")]
+ public partial class SH121_BoardSearchFacets : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ }
+ }
+}
diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs
index 813853c..e938918 100644
--- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -2458,6 +2458,9 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
+ b.Property("IsAddOn")
+ .HasColumnType("bit");
+
b.Property("IsDeleted")
.HasColumnType("bit");
diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs
index 59f2390..b2d1989 100644
--- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs
+++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs
@@ -80,6 +80,8 @@ namespace Data.SeaHavenIndustries
// --- Analytics slice ---
public int RescheduleCount { get; set; }
public int CarriedOver { get; set; }
+ /// Recalculated on schedule set/change/clear (SH-184); create hint when unscheduled. Board/search facet.
+ public bool IsAddOn { get; set; }
public OperationalFlags OperationalFlags { get; set; }
// --- Completion slice ---
diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
index 3d43fe3..2b31222 100644
--- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
+++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
@@ -18,6 +18,7 @@ namespace SeaHaven.DataServices.Helpers
w.InternalWONumber,
w.RescheduleCount,
w.CarriedOver,
+ w.IsAddOn,
w.WorkOrderType,
w.SiteCode,
LocationName = w.Locations != null ? w.Locations.Name : null,
@@ -99,6 +100,7 @@ namespace SeaHaven.DataServices.Helpers
w.InternalWONumber,
w.RescheduleCount,
w.CarriedOver,
+ w.IsAddOn,
w.WorkOrderType,
w.SiteCode,
w.LocationName,
diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs
index ba41941..44f965b 100644
--- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs
+++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs
@@ -66,8 +66,10 @@ namespace SeaHaven.DataServices.Helpers
var today = (utcNow ?? DateTime.UtcNow).Date;
var typeFilters = hasTypes ? types!.ToList() : new List();
+ var includeAddOnFlag = typeFilters.Contains(WorkOrderType.AddOn);
// Keep past-due predicate inline — EF cannot translate local method calls.
+ // Types=AddOn also matches IsAddOn (legacy type 7 OR flag on a real type).
if (overdue && !hasTypes)
{
return query.Where(w =>
@@ -84,10 +86,13 @@ namespace SeaHaven.DataServices.Helpers
&& w.ScheduledDate.Value.Date < today
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled)
- || (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value)));
+ || (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))
+ || (includeAddOnFlag && w.IsAddOn));
}
- return query.Where(w => w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value));
+ return query.Where(w =>
+ (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))
+ || (includeAddOnFlag && w.IsAddOn));
}
public static IQueryable ApplyTypeFilter(
diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs
new file mode 100644
index 0000000..08b2c2d
--- /dev/null
+++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs
@@ -0,0 +1,166 @@
+using Data.SeaHavenIndustries;
+using Data.SeaHavenIndustries.Enums;
+using Microsoft.EntityFrameworkCore;
+
+namespace SeaHaven.DataServices.Helpers
+{
+ ///
+ /// SH-121/SH-196 advanced search facets (severity, indicators, flag, internal WO#, uplift).
+ ///
+ public static class WorkOrderBoardSearchFacetFilters
+ {
+ private static readonly WorkOrderType[] SeverityEligibleTypes =
+ {
+ WorkOrderType.Emergency,
+ WorkOrderType.Reactive,
+ };
+
+ private static readonly string[] RevokedUpliftStatuses =
+ {
+ "Withdrawn",
+ "Cancelled",
+ };
+
+ public static IQueryable ApplySeverityFilter(
+ IQueryable query,
+ IReadOnlyList? severities)
+ {
+ if (severities == null || severities.Count == 0)
+ return query;
+
+ var allowed = severities
+ .Where(s => s is >= 1 and <= 5)
+ .Select(s => s.ToString())
+ .Distinct()
+ .ToList();
+
+ if (allowed.Count == 0)
+ return query;
+
+ return query.Where(w =>
+ w.WorkOrderType != null
+ && SeverityEligibleTypes.Contains(w.WorkOrderType.Value)
+ && w.Severity != null
+ && allowed.Contains(w.Severity));
+ }
+
+ public static IQueryable ApplyRescheduledFilter(
+ IQueryable query,
+ bool rescheduled)
+ {
+ if (!rescheduled)
+ return query;
+
+ return query.Where(w => w.RescheduleCount >= 2);
+ }
+
+ public static IQueryable ApplyCarriedOverFilter(
+ IQueryable query,
+ bool carriedOver)
+ {
+ if (!carriedOver)
+ return query;
+
+ return query.Where(w => w.CarriedOver >= 2);
+ }
+
+ public static IQueryable ApplyAddOnFilter(
+ IQueryable query,
+ bool addOn)
+ {
+ if (!addOn)
+ return query;
+
+ return query.Where(w => w.IsAddOn);
+ }
+
+ public static IQueryable ApplyFlagColorFilter(
+ IQueryable query,
+ IReadOnlyList? flagColors)
+ {
+ if (flagColors == null || flagColors.Count == 0)
+ return query;
+
+ var normalized = flagColors
+ .Where(c => !string.IsNullOrWhiteSpace(c))
+ .Select(c => c.Trim().ToUpperInvariant())
+ .Distinct()
+ .ToList();
+
+ if (normalized.Count == 0)
+ return query;
+
+ return query.Where(w =>
+ w.FlagColor != null && normalized.Contains(w.FlagColor.ToUpper()));
+ }
+
+ public static IQueryable ApplyInternalOnlyFilter(
+ IQueryable query,
+ bool internalOnly)
+ {
+ if (!internalOnly)
+ return query;
+
+ return query.Where(w =>
+ w.InternalWONumber != null
+ && EF.Functions.Like(w.InternalWONumber, "SH%"));
+ }
+
+ public static IQueryable ApplyUpliftFilter(
+ IQueryable query,
+ ApplicationDbContext context,
+ bool hasUplift,
+ IReadOnlyList? upliftStatuses)
+ {
+ if (!hasUplift)
+ return query;
+
+ var mappedStatuses = MapUpliftStatuses(upliftStatuses);
+
+ return query.Where(w => context.DispatchUpliftRequests.Any(u =>
+ (u.IsDeleted == null || u.IsDeleted == false)
+ && u.Status != "Expired"
+ && !RevokedUpliftStatuses.Contains(u.Status)
+ && u.Dispatch != null
+ && (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
+ && (
+ u.Dispatch.WorkOrderId == w.Id
+ || u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))
+ && (mappedStatuses.Count == 0 || mappedStatuses.Contains(u.Status))));
+ }
+
+ internal static List MapUpliftStatuses(IReadOnlyList? upliftStatuses)
+ {
+ if (upliftStatuses == null || upliftStatuses.Count == 0)
+ return new List();
+
+ var mapped = new List();
+ foreach (var raw in upliftStatuses)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ continue;
+
+ switch (raw.Trim().ToLowerInvariant())
+ {
+ case "pending":
+ mapped.Add("Pending");
+ mapped.Add("ChangesRequested");
+ break;
+ case "approved":
+ mapped.Add("Approved");
+ break;
+ case "auto_approved":
+ mapped.Add("NoApprovalRequired");
+ break;
+ case "rejected":
+ mapped.Add("Rejected");
+ mapped.Add("Denied");
+ break;
+ }
+ }
+
+ return mapped.Distinct(StringComparer.Ordinal).ToList();
+ }
+ }
+
+}
diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs
index d6cf28d..441802b 100644
--- a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs
+++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs
@@ -58,6 +58,7 @@ namespace SeaHaven.DataServices.Helpers
|| (w.WorkOrderType == WorkOrderType.Inspection && "inspection".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Reactive && "reactive".Contains(s))
|| (w.WorkOrderType == WorkOrderType.AddOn && ("add-on".Contains(s) || "addon".Contains(s)))
+ || (w.IsAddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|| (w.WorkOrderType == WorkOrderType.Other && "other".Contains(s))
|| ("overdue".Contains(s)
&& w.ScheduledDate != null
diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs
index de1f4d2..1d4892d 100644
--- a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs
+++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs
@@ -28,6 +28,14 @@ namespace SeaHaven.DataServices.Implementation
baseQuery = WorkOrderBoardQueryFilters.ApplyPmTypeFilter(baseQuery, query.PmTypes);
baseQuery = WorkOrderBoardQueryFilters.ApplyVendorFilter(baseQuery, query.VendorIds);
baseQuery = WorkOrderBoardQueryFilters.ApplyDocStatusFilter(baseQuery, query.DocStatuses);
+ baseQuery = WorkOrderBoardSearchFacetFilters.ApplySeverityFilter(baseQuery, query.Severities);
+ baseQuery = WorkOrderBoardSearchFacetFilters.ApplyRescheduledFilter(baseQuery, query.Rescheduled);
+ baseQuery = WorkOrderBoardSearchFacetFilters.ApplyCarriedOverFilter(baseQuery, query.CarriedOver);
+ baseQuery = WorkOrderBoardSearchFacetFilters.ApplyAddOnFilter(baseQuery, query.AddOn);
+ baseQuery = WorkOrderBoardSearchFacetFilters.ApplyFlagColorFilter(baseQuery, query.FlagColors);
+ baseQuery = WorkOrderBoardSearchFacetFilters.ApplyInternalOnlyFilter(baseQuery, query.InternalOnly);
+ baseQuery = WorkOrderBoardSearchFacetFilters.ApplyUpliftFilter(
+ baseQuery, _context, query.HasUplift, query.UpliftStatuses);
var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search);
if (normalizedSearch != null)
diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs
index 65d98bc..ca15639 100644
--- a/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs
+++ b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs
@@ -120,7 +120,7 @@ namespace SeaHaven.DataServices.Implementation
var batch = await _context.workOrders
.Where(w => w.IsDeleted != true)
.Where(w => w.istemplate != true)
- .Where(w => w.ScheduledDate != null)
+ .Where(w => w.DueDate != null)
.OrderBy(w => w.Id)
.Skip(skip)
.Take(batchSize)
@@ -132,7 +132,7 @@ namespace SeaHaven.DataServices.Implementation
foreach (var workOrder in batch)
{
examinedCount++;
- var shouldBePastDue = workOrder.ScheduledDate!.Value.Date < today
+ var shouldBePastDue = workOrder.DueDate!.Value.Date < today
&& !TerminalStatuses.Contains(workOrder.LifecycleStatus);
var hasFlag = workOrder.OperationalFlags.HasFlag(OperationalFlags.PastDue);
diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs
index d1ef77f..b965e86 100644
--- a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs
+++ b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs
@@ -15,6 +15,14 @@ namespace SeaHaven.DataServices.Interfaces
IReadOnlyList? PmTypes,
IReadOnlyList? VendorIds,
IReadOnlyList? DocStatuses,
+ IReadOnlyList? Severities,
+ bool Rescheduled,
+ bool CarriedOver,
+ bool AddOn,
+ IReadOnlyList? FlagColors,
+ bool InternalOnly,
+ bool HasUplift,
+ IReadOnlyList? UpliftStatuses,
bool MyWorkOrders,
string? CurrentUserId,
int Page,
diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
index ca27ca0..ad46340 100644
--- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
+++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
@@ -18,6 +18,7 @@ namespace SeaHaven.DataServices.Interfaces
string? InternalWONumber,
int RescheduleCount,
int CarriedOver,
+ bool IsAddOn,
WorkOrderType? WorkOrderType,
string? SiteCode,
string? LocationName,
diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
index de3fe67..1a2c9c2 100644
--- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
+++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
@@ -27,6 +27,7 @@ namespace SeaHaven.Services.DTOs
public string? WoNumber { get; set; }
public int RescheduleCount { get; set; }
public int CarriedOver { get; set; }
+ public bool IsAddOn { get; set; }
public WorkOrderType? WorkOrderType { get; set; }
public bool IsPastDue { get; set; }
public string? SiteCode { get; set; }
diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
index 3ca672c..0f7d976 100644
--- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
+++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
@@ -14,6 +14,11 @@ namespace SeaHaven.Services.DTOs
{
public string? WoNumber { get; set; }
public WorkOrderType WorkOrderType { get; set; }
+ ///
+ /// Manual Add-On override when unscheduled. Ignored when ScheduledDate or TargetWeek is set
+ /// (server computes from cutoff).
+ ///
+ public bool? IsAddOn { get; set; }
public string? SiteCode { get; set; }
///
/// Optional CRM customer name. Required when the caller is org-wide (no account_id)
@@ -63,6 +68,14 @@ namespace SeaHaven.Services.DTOs
public List? PmTypes { get; set; }
public List? VendorIds { get; set; }
public List? DocStatuses { get; set; }
+ public List? Severities { get; set; }
+ public bool Rescheduled { get; set; }
+ public bool CarriedOver { get; set; }
+ public bool AddOn { get; set; }
+ public List? FlagColors { get; set; }
+ public bool InternalOnly { get; set; }
+ public bool HasUplift { get; set; }
+ public List? UpliftStatuses { get; set; }
public bool MyWorkOrders { get; set; }
public string? SortBy { get; set; }
public string? SortDir { get; set; }
diff --git a/SeaHaven.Services/Helpers/WorkOrderAddonCutoff.cs b/SeaHaven.Services/Helpers/WorkOrderAddonCutoff.cs
new file mode 100644
index 0000000..db81e62
--- /dev/null
+++ b/SeaHaven.Services/Helpers/WorkOrderAddonCutoff.cs
@@ -0,0 +1,77 @@
+namespace SeaHaven.Services.Helpers
+{
+ ///
+ /// Server-side Add-On cutoff: Saturday 23:59:59 UTC of the week prior to the schedule week.
+ /// SH-184: recalculated whenever scheduling is set, changed, or cleared; audited on change.
+ /// Create may accept a client hint when unscheduled; schedule present ignores override.
+ ///
+ public static class WorkOrderAddonCutoff
+ {
+ ///
+ /// Cutoff = Monday-of(anchor) − 2 days, end of day UTC (prior-week Saturday 23:59:59).
+ ///
+ public static DateTime GetAddonCutoffUtc(DateTime anchor)
+ {
+ var monday = WorkOrderBoardMutationRules.GetWeekStartMonday(anchor);
+ var saturday = monday.Date.AddDays(-2);
+ return DateTime.SpecifyKind(
+ saturday.AddHours(23).AddMinutes(59).AddSeconds(59),
+ DateTimeKind.Utc);
+ }
+
+ public static DateTime GetAddonCutoffUtc(DateOnly anchor)
+ => GetAddonCutoffUtc(anchor.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc));
+
+ ///
+ /// With schedule (ScheduledDate or TargetWeek): IsAddOn when createdAtUtc is after cutoff.
+ /// Without schedule: client (default false).
+ ///
+ public static bool ComputeIsAddOnAtCreation(
+ DateTime createdAtUtc,
+ DateTime? scheduledDate,
+ DateOnly? targetWeek,
+ bool? manualOverride)
+ {
+ return ComputeFromScheduleAnchor(createdAtUtc, scheduledDate, targetWeek, manualOverride);
+ }
+
+ ///
+ /// SH-184 authoritative recalc after schedule set/change/clear. No manual override on patch.
+ /// Cleared schedule yields false.
+ ///
+ public static bool RecalculateIsAddOnFromSchedule(
+ DateTime createdAtUtc,
+ DateTime? scheduledDate,
+ DateOnly? targetWeek)
+ => ComputeFromScheduleAnchor(createdAtUtc, scheduledDate, targetWeek, manualOverride: false);
+
+ private static bool ComputeFromScheduleAnchor(
+ DateTime createdAtUtc,
+ DateTime? scheduledDate,
+ DateOnly? targetWeek,
+ bool? manualOverride)
+ {
+ DateTime? anchor = null;
+ if (scheduledDate.HasValue)
+ anchor = scheduledDate.Value;
+ else if (targetWeek.HasValue)
+ anchor = targetWeek.Value.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
+
+ if (!anchor.HasValue)
+ return manualOverride ?? false;
+
+ var cutoff = GetAddonCutoffUtc(anchor.Value);
+ var created = NormalizeUtc(createdAtUtc);
+ return created > cutoff;
+ }
+
+ private static DateTime NormalizeUtc(DateTime value)
+ {
+ if (value.Kind == DateTimeKind.Utc)
+ return value;
+ if (value.Kind == DateTimeKind.Local)
+ return value.ToUniversalTime();
+ return DateTime.SpecifyKind(value, DateTimeKind.Utc);
+ }
+ }
+}
diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs
index 3067947..3589d36 100644
--- a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs
+++ b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs
@@ -36,7 +36,7 @@ namespace SeaHaven.Services.Helpers
? DateOnly.FromDateTime(GetWeekStartMonday(scheduledDate.Value))
: null;
- private static DateTime GetWeekStartMonday(DateTime date)
+ public static DateTime GetWeekStartMonday(DateTime date)
{
var diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7;
return date.Date.AddDays(-diff);
diff --git a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs
index b64d16c..c7ac473 100644
--- a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs
+++ b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs
@@ -1,4 +1,4 @@
-using Data.SeaHavenIndustries;
+using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
@@ -6,7 +6,8 @@ namespace SeaHaven.Services.Helpers
public static class WorkOrderDerivedFields
{
///
- /// Past due overlay — never a WorkOrderType. Matches SHOC board design.
+ /// Past due overlay from Schedule On — never a WorkOrderType. Matches SHOC board design (SH-185).
+ /// Changing Due Date alone must never trigger or clear it.
///
public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null)
{
@@ -21,7 +22,7 @@ namespace SeaHaven.Services.Helpers
}
///
- /// Board read-model past-due check. Phase 1 compares UTC calendar dates;
+ /// Board read-model past-due check against ScheduledDate. Compares UTC calendar dates;
/// business/location timezone support is planned for a later phase.
///
public static bool IsPastDue(
diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs
index a4d332d..f1e79cf 100644
--- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs
@@ -51,6 +51,14 @@ namespace SeaHaven.Services.Implementation
query.PmTypes,
query.VendorIds,
query.DocStatuses,
+ query.Severities,
+ query.Rescheduled,
+ query.CarriedOver,
+ query.AddOn,
+ query.FlagColors,
+ query.InternalOnly,
+ query.HasUplift,
+ query.UpliftStatuses,
query.MyWorkOrders,
currentUserId,
page,
diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
index ee32e53..acba7d6 100644
--- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
@@ -106,6 +106,12 @@ namespace SeaHaven.Services.Implementation
WorkOrderBoardFieldMutations.SetInitialLifecycleStatus(workOrder);
+ workOrder.IsAddOn = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
+ workOrder.CreatedDate!.Value,
+ request.ScheduledDate,
+ request.TargetWeek,
+ request.IsAddOn);
+
var changes = new List
{
WorkOrderBoardFieldMutations.BoardFieldChange.Changed("InternalWONumber", "", woNumber),
diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs
index 04bf63c..a2bda0c 100644
--- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs
@@ -93,6 +93,7 @@ namespace SeaHaven.Services.Implementation
WoNumber = row.InternalWONumber,
RescheduleCount = row.RescheduleCount,
CarriedOver = row.CarriedOver,
+ IsAddOn = row.IsAddOn,
WorkOrderType = row.WorkOrderType,
IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow),
SiteCode = row.SiteCode,
diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
index 3e8c108..126d01e 100644
--- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
@@ -63,7 +63,7 @@ namespace SeaHaven.Services.Implementation
var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.ScheduledDate, workOrder.LifecycleStatus);
if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(canonicalField, isPastDue))
- throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Reschedule first.");
+ throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Update Schedule On first.");
_mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion);
@@ -147,8 +147,8 @@ namespace SeaHaven.Services.Implementation
WorkOrderBoardFieldNames.AssignTo => ApplyAssignTo(workOrder, value, auditField),
WorkOrderBoardFieldNames.DueDate => new List { ApplyDateField(value, auditField, v => workOrder.DueDate = v, () => workOrder.DueDate) },
WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField),
- WorkOrderBoardFieldNames.TargetWeek => new List { ApplyTargetWeek(workOrder, value, auditField) },
- WorkOrderBoardFieldNames.ScheduleWeekOnly => new List { ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly) },
+ WorkOrderBoardFieldNames.TargetWeek => ApplyTargetWeek(workOrder, value, auditField),
+ WorkOrderBoardFieldNames.ScheduleWeekOnly => ApplyScheduleWeekOnly(workOrder, value, auditField),
WorkOrderBoardFieldNames.VendorId => new List { await ApplyVendorIdAsync(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptDate => new List { ApplyApptDate(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value),
@@ -232,7 +232,7 @@ namespace SeaHaven.Services.Implementation
if (string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase))
throw new WorkOrderBoardValidationException(
"InvalidValue",
- "Overdue is not a work order type; it is derived from scheduledDate (isPastDue).");
+ "Overdue is not a work order type; it is derived from dueDate (isPastDue).");
if (!WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
@@ -437,10 +437,11 @@ namespace SeaHaven.Services.Implementation
}
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
+ AppendIsAddOnRecalculation(workOrder, changes);
return changes;
}
- private static FieldChange ApplyTargetWeek(WorkOrder workOrder, string? value, string auditField)
+ private static List ApplyTargetWeek(WorkOrder workOrder, string? value, string auditField)
{
DateOnly? parsed = null;
if (!string.IsNullOrWhiteSpace(value))
@@ -450,13 +451,42 @@ namespace SeaHaven.Services.Implementation
parsed = d;
}
+ var changes = new List();
var old = workOrder.TargetWeek?.ToString("yyyy-MM-dd");
var newVal = parsed?.ToString("yyyy-MM-dd");
- if (old == newVal)
- return FieldChange.Unchanged(auditField);
+ if (old != newVal)
+ {
+ workOrder.TargetWeek = parsed;
+ changes.Add(FieldChange.ForField(auditField, old, newVal));
+ }
- workOrder.TargetWeek = parsed;
- return FieldChange.ForField(auditField, old, newVal);
+ AppendIsAddOnRecalculation(workOrder, changes);
+ return changes;
+ }
+
+ private static List ApplyScheduleWeekOnly(WorkOrder workOrder, string? value, string auditField)
+ {
+ var change = ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly);
+ var changes = new List { change };
+ if (change.HasChanged)
+ AppendIsAddOnRecalculation(workOrder, changes);
+ return changes;
+ }
+
+ private static void AppendIsAddOnRecalculation(WorkOrder workOrder, List changes)
+ {
+ var createdAt = workOrder.CreatedDate ?? DateTime.UtcNow;
+ var oldValue = workOrder.IsAddOn.ToString();
+ var newIsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule(
+ createdAt,
+ workOrder.ScheduledDate,
+ workOrder.TargetWeek);
+
+ if (workOrder.IsAddOn == newIsAddOn)
+ return;
+
+ workOrder.IsAddOn = newIsAddOn;
+ changes.Add(FieldChange.ForField("IsAddOn", oldValue, newIsAddOn.ToString()));
}
private static FieldChange ApplyBoolField(
diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs
index 0bb6eac..9da5034 100644
--- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs
@@ -95,6 +95,7 @@ namespace SeaHaven.Services.Implementation
WoNumber = row.WoNumber,
RescheduleCount = row.RescheduleCount,
CarriedOver = row.CarriedOver,
+ IsAddOn = row.IsAddOn,
WorkOrderType = row.WorkOrderType,
IsPastDue = row.IsPastDue,
SiteCode = row.SiteCode,
diff --git a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs
index 8d0564d..8d7b1d5 100644
--- a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs
+++ b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs
@@ -1,4 +1,5 @@
using FluentValidation;
+using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Validation
@@ -12,7 +13,9 @@ namespace SeaHaven.Services.Validation
public WorkOrderBoardCreateValidation()
{
RuleFor(x => x.WorkOrderType)
- .IsInEnum().WithMessage("workOrderType is required and must be a valid value.");
+ .IsInEnum().WithMessage("workOrderType is required and must be a valid value.")
+ .Must(t => t != WorkOrderType.AddOn)
+ .WithMessage("workOrderType AddOn is not allowed on create; use isAddOn with a real type.");
RuleFor(x => x.SiteCode)
.NotEmpty().WithMessage("siteCode is required.")
diff --git a/SeaHavenIndustries.Tests/WorkOrderAddonCutoffTests.cs b/SeaHavenIndustries.Tests/WorkOrderAddonCutoffTests.cs
new file mode 100644
index 0000000..d860482
--- /dev/null
+++ b/SeaHavenIndustries.Tests/WorkOrderAddonCutoffTests.cs
@@ -0,0 +1,135 @@
+using SeaHaven.Services.Helpers;
+
+namespace SeaHavenIndustries.Tests;
+
+public class WorkOrderAddonCutoffTests
+{
+ // Schedule week Mon 2026-06-22 → cutoff Sat 2026-06-20 23:59:59 UTC
+ private static readonly DateTime ScheduleMonday = new(2026, 6, 22, 0, 0, 0, DateTimeKind.Utc);
+ private static readonly DateTime ExpectedCutoff = new(2026, 6, 20, 23, 59, 59, DateTimeKind.Utc);
+
+ [Fact]
+ public void GetAddonCutoffUtc_FromScheduledDate_IsPriorSaturdayEndOfDay()
+ {
+ // Wednesday in schedule week
+ var cutoff = WorkOrderAddonCutoff.GetAddonCutoffUtc(new DateTime(2026, 6, 24, 15, 0, 0, DateTimeKind.Utc));
+ Assert.Equal(ExpectedCutoff, cutoff);
+ }
+
+ [Fact]
+ public void GetAddonCutoffUtc_FromTargetWeekMonday_IsPriorSaturdayEndOfDay()
+ {
+ var cutoff = WorkOrderAddonCutoff.GetAddonCutoffUtc(new DateOnly(2026, 6, 22));
+ Assert.Equal(ExpectedCutoff, cutoff);
+ }
+
+ [Fact]
+ public void GetAddonCutoffUtc_FromMondayAnchor_MatchesGetWeekStartMondayMinusTwo()
+ {
+ var monday = WorkOrderBoardMutationRules.GetWeekStartMonday(ScheduleMonday);
+ Assert.Equal(ScheduleMonday.Date, monday.Date);
+ Assert.Equal(ExpectedCutoff, WorkOrderAddonCutoff.GetAddonCutoffUtc(ScheduleMonday));
+ }
+
+ [Fact]
+ public void Compute_AfterCutoff_ReturnsTrue_IgnoresManualFalse()
+ {
+ var created = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc);
+ var result = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
+ created,
+ scheduledDate: new DateTime(2026, 6, 24),
+ targetWeek: null,
+ manualOverride: false);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void Compute_BeforeCutoff_ReturnsFalse_IgnoresManualTrue()
+ {
+ var created = new DateTime(2026, 6, 20, 23, 59, 59, DateTimeKind.Utc);
+ var result = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
+ created,
+ scheduledDate: new DateTime(2026, 6, 24),
+ targetWeek: null,
+ manualOverride: true);
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void Compute_ExactlyAtCutoff_ReturnsFalse()
+ {
+ var result = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
+ ExpectedCutoff,
+ scheduledDate: ScheduleMonday,
+ targetWeek: null,
+ manualOverride: true);
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void Compute_TargetWeekOnly_UsesCutoff()
+ {
+ var after = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
+ new DateTime(2026, 6, 21, 12, 0, 0, DateTimeKind.Utc),
+ scheduledDate: null,
+ targetWeek: new DateOnly(2026, 6, 22),
+ manualOverride: false);
+
+ var before = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
+ new DateTime(2026, 6, 19, 12, 0, 0, DateTimeKind.Utc),
+ scheduledDate: null,
+ targetWeek: new DateOnly(2026, 6, 22),
+ manualOverride: true);
+
+ Assert.True(after);
+ Assert.False(before);
+ }
+
+ [Fact]
+ public void Compute_NoSchedule_UsesManualOverride()
+ {
+ Assert.True(WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
+ DateTime.UtcNow, null, null, manualOverride: true));
+ Assert.False(WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
+ DateTime.UtcNow, null, null, manualOverride: false));
+ Assert.False(WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
+ DateTime.UtcNow, null, null, manualOverride: null));
+ }
+
+ [Fact]
+ public void Compute_ScheduledDatePreferredOverTargetWeek()
+ {
+ // Target week far future (cutoff after created) would be false;
+ // scheduled date near past (cutoff before created) → true.
+ var created = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc);
+ var result = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
+ created,
+ scheduledDate: new DateTime(2026, 6, 24),
+ targetWeek: new DateOnly(2099, 1, 5),
+ manualOverride: false);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void RecalculateIsAddOnFromSchedule_NoSchedule_ReturnsFalse()
+ {
+ var created = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc);
+ Assert.False(WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule(created, null, null));
+ }
+
+ [Fact]
+ public void RecalculateIsAddOnFromSchedule_AfterCutoff_ReturnsTrue()
+ {
+ var created = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc);
+ var result = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule(
+ created,
+ scheduledDate: new DateTime(2026, 6, 24),
+ targetWeek: null);
+
+ Assert.True(result);
+ }
+}
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs
index 16f8e9e..d1014b6 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs
@@ -141,8 +141,11 @@ public class WorkOrderBoardConcurrencyTests
WorkOrderVersion = Convert.ToBase64String(wo!.RowVersion!)
}, "actor-1");
- var auditCount = await context.WorkOrderAuditLogs.CountAsync();
- Assert.Equal(2, auditCount);
+ var audits = await context.WorkOrderAuditLogs.ToListAsync();
+ Assert.Equal(3, audits.Count);
+ Assert.Contains(audits, a => a.FieldName == "IsAddOn");
+ Assert.Contains(audits, a => a.FieldName == "ScheduledDate");
+ Assert.Contains(audits, a => a.FieldName == "RescheduleCount");
}
[Fact]
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs
index bd5f2bf..16cd08d 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs
@@ -351,4 +351,77 @@ public class WorkOrderBoardCreateServiceTests
Assert.Equal("VendorNotFound", ex.Code);
}
+
+ [Fact]
+ public async Task Create_ScheduledAfterCutoff_SetsIsAddOnTrue_KeepsRealType()
+ {
+ // UtcNow (test run) is after cutoff for a 2026-06 schedule week.
+ var (context, service) = CreateSut();
+
+ var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
+ {
+ WorkOrderType = WorkOrderType.PM,
+ SiteCode = "BK5",
+ ScheduledDate = new DateTime(2026, 6, 24),
+ IsAddOn = false
+ }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
+
+ Assert.True(result.IsAddOn);
+ Assert.Equal(WorkOrderType.PM, result.WorkOrderType);
+ var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
+ Assert.True(wo.IsAddOn);
+ Assert.Equal(WorkOrderType.PM, wo.WorkOrderType);
+ }
+
+ [Fact]
+ public async Task Create_ScheduledBeforeCutoff_SetsIsAddOnFalse_IgnoresClientTrue()
+ {
+ // Far-future schedule → cutoff after UtcNow → not an add-on.
+ var (context, service) = CreateSut();
+
+ var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
+ {
+ WorkOrderType = WorkOrderType.Reactive,
+ SiteCode = "BK5",
+ ScheduledDate = new DateTime(2099, 3, 10),
+ IsAddOn = true
+ }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
+
+ Assert.False(result.IsAddOn);
+ Assert.Equal(WorkOrderType.Reactive, result.WorkOrderType);
+ var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
+ Assert.False(wo.IsAddOn);
+ }
+
+ [Fact]
+ public async Task Create_NoSchedule_ManualIsAddOnTrue()
+ {
+ var (context, service) = CreateSut();
+
+ var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
+ {
+ WorkOrderType = WorkOrderType.PM,
+ SiteCode = "BK5",
+ IsAddOn = true
+ }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
+
+ Assert.True(result.IsAddOn);
+ var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
+ Assert.True(wo.IsAddOn);
+ }
+
+ [Fact]
+ public async Task Create_WorkOrderTypeAddOn_ThrowsValidation()
+ {
+ var (_, service) = CreateSut();
+
+ var ex = await Assert.ThrowsAsync(() =>
+ service.CreateAsync(new WorkOrderBoardCreateRequestDto
+ {
+ WorkOrderType = WorkOrderType.AddOn,
+ SiteCode = "BK5"
+ }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
+
+ Assert.Contains(ex.Errors, e => e.PropertyName == "WorkOrderType");
+ }
}
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs
index 933aa80..4cda7f8 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs
@@ -22,6 +22,8 @@ public class WorkOrderBoardMutationRulesTests
WorkOrderBoardFieldNames.LifecycleStatus, isPastDue: true));
Assert.False(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(
WorkOrderBoardFieldNames.ScheduledDate, isPastDue: true));
+ Assert.False(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(
+ WorkOrderBoardFieldNames.DueDate, isPastDue: true));
}
[Fact]
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs
index 780c25c..9da4328 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs
@@ -342,7 +342,8 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 1,
WorkOrderType = WorkOrderType.PM,
- ScheduledDate = today.AddDays(-3),
+ DueDate = today.AddDays(-3),
+ ScheduledDate = today.AddDays(5),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
@@ -358,7 +359,8 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 3,
WorkOrderType = WorkOrderType.Reactive,
- ScheduledDate = today.AddDays(1),
+ DueDate = today.AddDays(1),
+ ScheduledDate = today.AddDays(-5),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000003"
});
@@ -377,7 +379,7 @@ public class WorkOrderAdvancedSearchServiceTests
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(1, result.TotalCount);
- Assert.Equal(1, result.Items.First().Id);
+ Assert.Equal(3, result.Items.First().Id);
Assert.True(result.Items.First().IsPastDue);
}
@@ -392,6 +394,7 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 1,
WorkOrderType = WorkOrderType.PM,
+ DueDate = today.AddDays(5),
ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
@@ -408,6 +411,7 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 3,
WorkOrderType = WorkOrderType.Reactive,
+ DueDate = today.AddDays(1),
ScheduledDate = today.AddDays(1),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000003"
@@ -444,7 +448,8 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 1,
WorkOrderType = WorkOrderType.Other,
- ScheduledDate = today.AddDays(5),
+ DueDate = today.AddDays(5),
+ ScheduledDate = today.AddDays(-5),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
@@ -452,7 +457,8 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 2,
WorkOrderType = WorkOrderType.PM,
- ScheduledDate = today.AddDays(-3),
+ DueDate = today.AddDays(-3),
+ ScheduledDate = today.AddDays(5),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
},
@@ -460,6 +466,7 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 3,
WorkOrderType = WorkOrderType.Reactive,
+ DueDate = today.AddDays(-2),
ScheduledDate = today.AddDays(-2),
LifecycleStatus = LifecycleStatus.Completed,
InternalWONumber = "10000000003"
@@ -468,6 +475,7 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 4,
WorkOrderType = WorkOrderType.Reactive,
+ DueDate = today.AddDays(-1),
ScheduledDate = today.AddDays(-1),
LifecycleStatus = LifecycleStatus.Canceled,
InternalWONumber = "10000000004"
@@ -490,9 +498,9 @@ public class WorkOrderAdvancedSearchServiceTests
var overdue = Assert.Single(result.Items);
Assert.Equal(0, result.Page);
- Assert.Equal(2, overdue.Id);
+ Assert.Equal(1, overdue.Id);
Assert.True(overdue.IsPastDue);
- Assert.DoesNotContain(result.Items, i => i.Id == 1);
+ Assert.DoesNotContain(result.Items, i => i.Id == 2);
Assert.DoesNotContain(result.Items, i => i.Id == 3);
Assert.DoesNotContain(result.Items, i => i.Id == 4);
}
@@ -605,4 +613,220 @@ public class WorkOrderAdvancedSearchServiceTests
Assert.Contains("sortBy", ex.Message);
}
+
+ [Fact]
+ public async Task SearchAsync_TypesAddOn_MatchesLegacyTypeOrIsAddOnFlag()
+ {
+ await using var context = CreateContext();
+ var today = DateTime.UtcNow.Date;
+
+ context.workOrders.AddRange(
+ new WorkOrder
+ {
+ Id = 1,
+ WorkOrderType = WorkOrderType.AddOn,
+ IsAddOn = false,
+ ScheduledDate = today.AddDays(1),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ InternalWONumber = "10000000001"
+ },
+ new WorkOrder
+ {
+ Id = 2,
+ WorkOrderType = WorkOrderType.PM,
+ IsAddOn = true,
+ ScheduledDate = today.AddDays(2),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ InternalWONumber = "10000000002"
+ },
+ new WorkOrder
+ {
+ Id = 3,
+ WorkOrderType = WorkOrderType.Reactive,
+ IsAddOn = false,
+ ScheduledDate = today.AddDays(3),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ InternalWONumber = "10000000003"
+ });
+
+ await context.SaveChangesAsync();
+
+ var dataService = new WorkOrderAdvancedSearchDataService(context);
+ var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
+ dataService,
+ WorkOrderAccountTestHelpers.Resolver(context));
+
+ var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
+ {
+ DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
+ DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
+ DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
+ Types = new List { WorkOrderType.AddOn }
+ }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
+
+ Assert.Equal(2, result.TotalCount);
+ Assert.Contains(result.Items, i => i.Id == 1 && i.WorkOrderType == WorkOrderType.AddOn);
+ Assert.Contains(result.Items, i => i.Id == 2 && i.IsAddOn && i.WorkOrderType == WorkOrderType.PM);
+ Assert.DoesNotContain(result.Items, i => i.Id == 3);
+ }
+
+ [Fact]
+ public async Task SearchAsync_SeverityFilter_ReturnsEmergencyReactiveOnly()
+ {
+ await using var context = CreateContext();
+ var weekStart = new DateOnly(2026, 6, 22);
+
+ context.workOrders.AddRange(
+ new WorkOrder
+ {
+ Id = 1,
+ WorkOrderType = WorkOrderType.Emergency,
+ Severity = "1",
+ ScheduledDate = new DateTime(2026, 6, 23),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ InternalWONumber = "10000000001",
+ },
+ new WorkOrder
+ {
+ Id = 2,
+ WorkOrderType = WorkOrderType.Reactive,
+ Severity = "2",
+ ScheduledDate = new DateTime(2026, 6, 24),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ InternalWONumber = "10000000002",
+ },
+ new WorkOrder
+ {
+ Id = 3,
+ WorkOrderType = WorkOrderType.PM,
+ Severity = "1",
+ ScheduledDate = new DateTime(2026, 6, 25),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ InternalWONumber = "10000000003",
+ });
+
+ await context.SaveChangesAsync();
+
+ var dataService = new WorkOrderAdvancedSearchDataService(context);
+ var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
+ dataService,
+ WorkOrderAccountTestHelpers.Resolver(context));
+
+ var result = await service.SearchAsync(
+ new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
+ {
+ DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
+ DateFrom = weekStart,
+ DateTo = weekStart.AddDays(6),
+ Severities = new List { 1 },
+ },
+ WorkOrderAccountTestHelpers.OrgWideAdmin(),
+ null);
+
+ Assert.Equal(1, result.TotalCount);
+ Assert.Equal(1, result.Items.Single().Id);
+ }
+
+ [Fact]
+ public async Task SearchAsync_InternalOnly_ReturnsShPrefixRows()
+ {
+ await using var context = CreateContext();
+ var weekStart = new DateOnly(2026, 6, 22);
+
+ context.workOrders.AddRange(
+ new WorkOrder
+ {
+ Id = 1,
+ InternalWONumber = "SH-1001",
+ ScheduledDate = new DateTime(2026, 6, 23),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ },
+ new WorkOrder
+ {
+ Id = 2,
+ InternalWONumber = "10000000002",
+ ScheduledDate = new DateTime(2026, 6, 24),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ });
+
+ await context.SaveChangesAsync();
+
+ var dataService = new WorkOrderAdvancedSearchDataService(context);
+ var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
+ dataService,
+ WorkOrderAccountTestHelpers.Resolver(context));
+
+ var result = await service.SearchAsync(
+ new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
+ {
+ DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
+ DateFrom = weekStart,
+ DateTo = weekStart.AddDays(6),
+ InternalOnly = true,
+ },
+ WorkOrderAccountTestHelpers.OrgWideAdmin(),
+ null);
+
+ Assert.Equal(1, result.TotalCount);
+ Assert.Equal("SH-1001", result.Items.Single().WoNumber);
+ }
+
+ [Fact]
+ public async Task SearchAsync_HasUpliftWithPendingStatus_FiltersRows()
+ {
+ await using var context = CreateContext();
+ var weekStart = new DateOnly(2026, 6, 22);
+
+ context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
+ context.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1, WorkOrderId = 1 });
+ context.Dispatches.Add(new Dispatch { Id = 11, VendorId = 1, WorkOrderId = 2 });
+ context.workOrders.AddRange(
+ new WorkOrder
+ {
+ Id = 1,
+ InternalWONumber = "10000000001",
+ ScheduledDate = new DateTime(2026, 6, 23),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ PrimaryDispatchId = 10,
+ },
+ new WorkOrder
+ {
+ Id = 2,
+ InternalWONumber = "10000000002",
+ ScheduledDate = new DateTime(2026, 6, 24),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ PrimaryDispatchId = 11,
+ });
+ context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
+ {
+ Id = 100,
+ DispatchId = 10,
+ RequestedNTE = 1500m,
+ Status = "Pending",
+ RequiredTier = 1,
+ NotificationStatus = "Pending",
+ });
+
+ await context.SaveChangesAsync();
+
+ var dataService = new WorkOrderAdvancedSearchDataService(context);
+ var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
+ dataService,
+ WorkOrderAccountTestHelpers.Resolver(context));
+
+ var result = await service.SearchAsync(
+ new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
+ {
+ DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
+ DateFrom = weekStart,
+ DateTo = weekStart.AddDays(6),
+ HasUplift = true,
+ UpliftStatuses = new List { "pending" },
+ },
+ WorkOrderAccountTestHelpers.OrgWideAdmin(),
+ null);
+
+ Assert.Equal(1, result.TotalCount);
+ Assert.Equal(1, result.Items.Single().Id);
+ }
}
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs
index 92c4d36..32a890c 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs
@@ -391,6 +391,7 @@ public class WorkOrderBoardServiceTests
{
Id = 1,
InternalWONumber = "10000000001",
+ DueDate = new DateTime(2026, 6, 20),
ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc),
LifecycleStatus = LifecycleStatus.Scheduled
});
@@ -411,6 +412,37 @@ public class WorkOrderBoardServiceTests
Assert.Equal("wednesday", response.Scheduled[0].DayGroup);
}
+ [Fact]
+ public async Task GetBoardAsync_MapsIsAddOn()
+ {
+ await using var context = CreateContext();
+ var weekStart = new DateOnly(2026, 6, 22);
+
+ context.workOrders.Add(new WorkOrder
+ {
+ Id = 1,
+ InternalWONumber = "10000000001",
+ WorkOrderType = WorkOrderType.PM,
+ IsAddOn = true,
+ 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.Single(response.Scheduled);
+ Assert.True(response.Scheduled[0].IsAddOn);
+ Assert.Equal(WorkOrderType.PM, response.Scheduled[0].WorkOrderType);
+ }
+
[Fact]
public async Task GetBoardAsync_InvalidWeekWindow_Throws()
{
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
index b775ee6..c76632a 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
@@ -55,7 +55,11 @@ public class WorkOrderBoardUpdateServiceTests
}, "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
- Assert.Equal(2, await context.WorkOrderAuditLogs.CountAsync());
+ var audits = await context.WorkOrderAuditLogs.ToListAsync();
+ Assert.Equal(3, audits.Count);
+ Assert.Contains(audits, a => a.FieldName == "IsAddOn");
+ Assert.Contains(audits, a => a.FieldName == "ScheduledDate");
+ Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString());
}
[Fact]
@@ -91,6 +95,7 @@ public class WorkOrderBoardUpdateServiceTests
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
+ DueDate = DateTime.UtcNow.Date.AddDays(5),
ScheduledDate = DateTime.UtcNow.Date.AddDays(-3),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
@@ -106,6 +111,149 @@ public class WorkOrderBoardUpdateServiceTests
}, "actor-1"));
Assert.Equal("PastDueStatusBlocked", ex.Code);
+ Assert.Contains("Schedule On", ex.Message, StringComparison.Ordinal);
+ Assert.DoesNotContain("due date", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task PatchField_FutureSchedule_ClearsPastDueAndAllowsStatusChange()
+ {
+ var (context, service) = CreateSut();
+ var wo = new WorkOrder
+ {
+ Id = 1,
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ DueDate = DateTime.UtcNow.Date.AddDays(-3),
+ ScheduledDate = DateTime.UtcNow.Date.AddDays(-1),
+ RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
+ };
+ context.workOrders.Add(wo);
+ await context.SaveChangesAsync();
+
+ var futureSchedule = DateTime.UtcNow.Date.AddDays(7).ToString("yyyy-MM-dd");
+ var row = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
+ {
+ Field = WorkOrderBoardFieldNames.ScheduledDate,
+ Value = futureSchedule,
+ WorkOrderVersion = ToVersion(wo)
+ }, "actor-1");
+
+ Assert.False(row.IsPastDue);
+ Assert.Equal(DateTime.UtcNow.Date.AddDays(7), row.ScheduledDate!.Value.Date);
+
+ wo = await context.workOrders.SingleAsync(w => w.Id == 1);
+ var afterStatus = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
+ {
+ Field = WorkOrderBoardFieldNames.LifecycleStatus,
+ Value = "Complete",
+ WorkOrderVersion = ToVersion(wo)
+ }, "actor-1");
+
+ Assert.Equal(LifecycleStatus.Completed, afterStatus.LifecycleStatus);
+ Assert.False(afterStatus.IsPastDue);
+ }
+
+ [Fact]
+ public async Task PatchField_DueDateAlone_DoesNotClearPastDue()
+ {
+ var (context, service) = CreateSut();
+ var wo = new WorkOrder
+ {
+ Id = 1,
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ DueDate = DateTime.UtcNow.Date.AddDays(-3),
+ ScheduledDate = DateTime.UtcNow.Date.AddDays(-1),
+ RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
+ };
+ context.workOrders.Add(wo);
+ await context.SaveChangesAsync();
+
+ var futureDue = DateTime.UtcNow.Date.AddDays(10).ToString("yyyy-MM-dd");
+ var afterDue = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
+ {
+ Field = WorkOrderBoardFieldNames.DueDate,
+ Value = futureDue,
+ WorkOrderVersion = ToVersion(wo)
+ }, "actor-1");
+
+ Assert.True(afterDue.IsPastDue);
+ Assert.Equal(DateTime.UtcNow.Date.AddDays(-1), afterDue.ScheduledDate!.Value.Date);
+
+ wo = await context.workOrders.SingleAsync(w => w.Id == 1);
+ var ex = await Assert.ThrowsAsync(() =>
+ service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
+ {
+ Field = WorkOrderBoardFieldNames.LifecycleStatus,
+ Value = "Complete",
+ WorkOrderVersion = ToVersion(wo)
+ }, "actor-1"));
+
+ Assert.Equal("PastDueStatusBlocked", ex.Code);
+ Assert.Contains("Schedule On", ex.Message, StringComparison.Ordinal);
+ Assert.DoesNotContain("due date", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task PatchField_DueDate_DoesNotChangeScheduledDate()
+ {
+ var (context, service) = CreateSut();
+ var scheduled = new DateTime(2026, 6, 20);
+ var wo = new WorkOrder
+ {
+ Id = 1,
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ DueDate = new DateTime(2026, 6, 18),
+ ScheduledDate = scheduled,
+ RescheduleCount = 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.DueDate,
+ Value = "2026-07-01",
+ WorkOrderVersion = ToVersion(wo)
+ }, "actor-1");
+
+ Assert.Equal(new DateTime(2026, 7, 1), result.DueDate!.Value.Date);
+ Assert.Equal(scheduled, result.ScheduledDate);
+ Assert.Equal(2, result.RescheduleCount);
+
+ var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
+ Assert.Equal(scheduled, persisted.ScheduledDate);
+ Assert.Equal(2, persisted.RescheduleCount);
+ }
+
+ [Fact]
+ public async Task PatchField_ScheduledDate_DoesNotChangeDueDate()
+ {
+ var (context, service) = CreateSut();
+ var due = new DateTime(2026, 6, 18);
+ var wo = new WorkOrder
+ {
+ Id = 1,
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ DueDate = due,
+ ScheduledDate = new DateTime(2026, 6, 20),
+ 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.ScheduledDate,
+ Value = "2026-06-25",
+ WorkOrderVersion = ToVersion(wo)
+ }, "actor-1");
+
+ Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate!.Value.Date);
+ Assert.Equal(due, result.DueDate);
+
+ var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
+ Assert.Equal(due, persisted.DueDate);
}
[Fact]
@@ -132,7 +280,7 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal("InvalidValue", ex.Code);
Assert.Contains("isPastDue", ex.Message, StringComparison.Ordinal);
- Assert.Contains("scheduledDate", ex.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("dueDate", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
@@ -853,4 +1001,93 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Null(result.CompletedDate);
Assert.Null(context.workOrders.Single().CompletedDate);
}
+
+ [Fact]
+ public async Task PatchField_ClearsIsAddOn_WhenScheduleCleared()
+ {
+ var (context, service) = CreateSut();
+ var wo = new WorkOrder
+ {
+ Id = 1,
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ WorkOrderType = WorkOrderType.PM,
+ ScheduledDate = new DateTime(2026, 6, 20),
+ CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc),
+ IsAddOn = true,
+ 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.ScheduledDate,
+ Value = "",
+ WorkOrderVersion = ToVersion(wo)
+ }, "actor-1");
+
+ Assert.False(result.IsAddOn);
+ var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
+ Assert.False(persisted.IsAddOn);
+ Assert.Single(await context.WorkOrderAuditLogs.Where(a => a.FieldName == "IsAddOn").ToListAsync());
+ }
+
+ [Fact]
+ public async Task PatchField_ScheduledDate_RecalculatesIsAddOn_AfterCrossWeekMove()
+ {
+ var (context, service) = CreateSut();
+ var wo = new WorkOrder
+ {
+ Id = 1,
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ WorkOrderType = WorkOrderType.PM,
+ ScheduledDate = new DateTime(2026, 6, 10),
+ CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc),
+ IsAddOn = false,
+ 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.ScheduledDate,
+ Value = "2026-06-25",
+ WorkOrderVersion = ToVersion(wo)
+ }, "actor-1");
+
+ Assert.True(result.IsAddOn);
+ var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
+ Assert.True(persisted.IsAddOn);
+ }
+
+ [Fact]
+ public async Task PatchField_WorkOrderType_DoesNotRecalculateIsAddOn()
+ {
+ var (context, service) = CreateSut();
+ var wo = new WorkOrder
+ {
+ Id = 1,
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ WorkOrderType = WorkOrderType.PM,
+ IsAddOn = true,
+ 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 = "Reactive",
+ WorkOrderVersion = ToVersion(wo)
+ }, "actor-1");
+
+ Assert.Equal(WorkOrderType.Reactive, result.WorkOrderType);
+ Assert.True(result.IsAddOn);
+ var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
+ Assert.Equal(WorkOrderType.Reactive, persisted.WorkOrderType);
+ Assert.True(persisted.IsAddOn);
+ Assert.Empty(await context.WorkOrderAuditLogs.Where(a => a.FieldName == "IsAddOn").ToListAsync());
+ }
}
diff --git a/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs
index 0d1f61c..2dd8c6b 100644
--- a/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs
@@ -6,28 +6,80 @@ namespace SeaHavenIndustries.Tests;
public class WorkOrderDerivedFieldsTests
{
+ private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.UtcNow);
+ private static readonly DateTime Past = DateTime.UtcNow.Date.AddDays(-2);
+ private static readonly DateTime Future = DateTime.UtcNow.Date.AddDays(3);
+ private static readonly DateTime TodayDate = DateTime.UtcNow.Date;
+
[Fact]
- public void IsPastDue_WhenScheduledBeforeTodayAndNotTerminal_ReturnsTrue()
+ public void IsPastDue_WhenSchedulePastAndDueFuture_ReturnsTrue()
{
var wo = new WorkOrder
{
- ScheduledDate = DateTime.UtcNow.AddDays(-2),
+ DueDate = Future,
+ ScheduledDate = Past,
LifecycleStatus = LifecycleStatus.Scheduled
};
- Assert.True(WorkOrderDerivedFields.IsPastDue(wo, DateOnly.FromDateTime(DateTime.UtcNow)));
+ Assert.True(WorkOrderDerivedFields.IsPastDue(wo, Today));
+ Assert.True(WorkOrderDerivedFields.IsPastDue(wo.ScheduledDate, wo.LifecycleStatus));
}
[Fact]
- public void IsPastDue_WhenCompleted_ReturnsFalse()
+ public void IsPastDue_WhenScheduleFutureAndDuePast_ReturnsFalse()
{
var wo = new WorkOrder
{
- ScheduledDate = DateTime.UtcNow.AddDays(-2),
- LifecycleStatus = LifecycleStatus.Completed
+ DueDate = Past,
+ ScheduledDate = Future,
+ LifecycleStatus = LifecycleStatus.Scheduled
};
- Assert.False(WorkOrderDerivedFields.IsPastDue(wo, DateOnly.FromDateTime(DateTime.UtcNow)));
+ Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today));
+ Assert.False(WorkOrderDerivedFields.IsPastDue(wo.ScheduledDate, wo.LifecycleStatus));
+ }
+
+ [Fact]
+ public void IsPastDue_WhenScheduledDateNull_ReturnsFalse()
+ {
+ var wo = new WorkOrder
+ {
+ DueDate = Past,
+ ScheduledDate = null,
+ LifecycleStatus = LifecycleStatus.Scheduled
+ };
+
+ Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today));
+ Assert.False(WorkOrderDerivedFields.IsPastDue(null, LifecycleStatus.Scheduled));
+ }
+
+ [Fact]
+ public void IsPastDue_WhenScheduleEqualsToday_ReturnsFalse()
+ {
+ var wo = new WorkOrder
+ {
+ DueDate = Past,
+ ScheduledDate = TodayDate,
+ LifecycleStatus = LifecycleStatus.Scheduled
+ };
+
+ Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today));
+ }
+
+ [Theory]
+ [InlineData(LifecycleStatus.Completed)]
+ [InlineData(LifecycleStatus.Canceled)]
+ public void IsPastDue_WhenTerminal_ReturnsFalse(LifecycleStatus status)
+ {
+ var wo = new WorkOrder
+ {
+ DueDate = Past,
+ ScheduledDate = Past,
+ LifecycleStatus = status
+ };
+
+ Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today));
+ Assert.False(WorkOrderDerivedFields.IsPastDue(Past, status));
}
[Fact]
diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs
index d6bd47e..1d7440e 100644
--- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs
@@ -111,6 +111,7 @@ public class WorkOrderDetailServiceTests
Id = 1,
InternalWONumber = "00000000001",
LifecycleStatus = LifecycleStatus.Scheduled,
+ DueDate = DateTime.UtcNow.AddDays(3),
ScheduledDate = DateTime.UtcNow.AddDays(-2),
Trade = "HVAC PM",
WorkOrderType = WorkOrderType.PM,
diff --git a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs
index bff60ca..c299e21 100644
--- a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs
@@ -240,12 +240,14 @@ public class WorkOrderWeekRolledTests
new WorkOrder
{
Id = 1,
+ DueDate = pastDate,
ScheduledDate = pastDate,
LifecycleStatus = LifecycleStatus.Scheduled
},
new WorkOrder
{
Id = 2,
+ DueDate = pastDate,
ScheduledDate = pastDate,
LifecycleStatus = LifecycleStatus.Completed,
OperationalFlags = OperationalFlags.PastDue
@@ -253,6 +255,7 @@ public class WorkOrderWeekRolledTests
new WorkOrder
{
Id = 3,
+ DueDate = futureDate,
ScheduledDate = futureDate,
LifecycleStatus = LifecycleStatus.Scheduled,
OperationalFlags = OperationalFlags.PastDue
@@ -307,6 +310,7 @@ public class WorkOrderWeekRolledTests
context.workOrders.Add(new WorkOrder
{
Id = 1,
+ DueDate = DateTime.UtcNow.Date.AddDays(5),
ScheduledDate = pastDate,
LifecycleStatus = LifecycleStatus.Scheduled,
OperationalFlags = OperationalFlags.None
@@ -319,7 +323,7 @@ public class WorkOrderWeekRolledTests
Assert.NotNull(row);
Assert.True(row!.IsPastDue);
- Assert.False(WorkOrderDerivedFields.IsPastDue(pastDate, LifecycleStatus.Scheduled) == false);
+ Assert.True(WorkOrderDerivedFields.IsPastDue(pastDate, LifecycleStatus.Scheduled));
}
[Fact]
@@ -331,6 +335,7 @@ public class WorkOrderWeekRolledTests
context.workOrders.Add(new WorkOrder
{
Id = 1,
+ DueDate = DateTime.UtcNow.Date.AddDays(-3),
ScheduledDate = futureDate,
LifecycleStatus = LifecycleStatus.Scheduled,
OperationalFlags = OperationalFlags.PastDue
diff --git a/docs/work-orders/phase-1/README.md b/docs/work-orders/phase-1/README.md
index 660bfb3..cc37218 100644
--- a/docs/work-orders/phase-1/README.md
+++ b/docs/work-orders/phase-1/README.md
@@ -88,10 +88,13 @@ Lista opções para o filtro/dropdown de assignee do board.
### isPastDue
```
-ScheduledDate < UTC hoje
-AND LifecycleStatus NOT IN (Complete, Canceled, Closed)
+DueDate != null
+AND DueDate < UTC hoje
+AND LifecycleStatus NOT IN (Completed, Canceled)
```
+`DueDate` null → `isPastDue = false`. Independent of `ScheduledDate` (Schedule On).
+
Implementação: `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs`
### Vendor / Appt
@@ -154,6 +157,18 @@ Endpoints legados (`GetWorkOrderList`, `GetWorkorderById`, etc.) **não foram al
---
+## Add-On indicator (SH-184)
+
+- Coluna `IsAddOn` persistida; legacy `WorkOrderType.AddOn` (7) backfilled.
+- **Create:** cutoff preview (scheduled) ou hint manual quando unscheduled (`request.IsAddOn`).
+- **Patch schedule** (`scheduledDate`, `targetWeek`, `scheduleWeekOnly`): servidor **recalcula** `IsAddOn` e audita mudanças.
+- **Schedule cleared:** `IsAddOn = false`.
+- Search `Types=AddOn` matches legacy type 7 **or** `IsAddOn`.
+
+Contrato FE: [shoc-frontend-new PR #102](https://github.com/Sea-Haven-Industries/shoc-frontend-new/pull/102).
+
+---
+
## Próximo passo
**Fase 2** — Inline edit + concorrência (dual RowVersion, audit por campo, PATCH granular).