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/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..3ce4cc9 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.
+ 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 f6d7bd6..c3aca36 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.DueDate.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/WorkOrderBoardSearchFilter.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs
index 3149f5c..be14d8c 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.DueDate != null
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..5f1bbb9 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)
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/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 1e840a9..0389f4c 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.DueDate, row.LifecycleStatus, utcNow),
SiteCode = row.SiteCode,
diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
index e717efc..1306245 100644
--- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
@@ -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),
@@ -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/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs
index bd5f2bf..6c01b17 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
+ }, "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
+ }, "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
+ }, "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"
+ }, "actor-1"));
+
+ Assert.Contains(ex.Errors, e => e.PropertyName == "WorkOrderType");
+ }
}
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs
index 510da77..9ba1845 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs
@@ -613,4 +613,58 @@ 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);
+
+ 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 }
+ }, 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);
+ }
}
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs
index 3eca81d..9d82c8c 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs
@@ -412,6 +412,35 @@ 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));
+ var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto
+ {
+ WeekStart = weekStart
+ }, 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 dabf99d..ddab1d5 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
@@ -993,4 +993,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());
+ }
}