From 97e042f5524ad0bf1a69a86f184992d2b1bab767 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 30 Jul 2026 12:20:07 -0300 Subject: [PATCH] feat(work-orders): persist IsAddOn frozen at create (SH-126) Add set-once IsAddOn with server cutoff at create, board DTO exposure, legacy type-7 backfill, and Types=AddOn search compat. Aligns with FE PR #61 frozen contract. --- .../Migrations/20260730150000_WoIsAddOn.cs | 37 +++++ .../ApplicationDbContextModelSnapshot.cs | 3 + Data.SeaHavenIndustries/Models/WorkerOrder.cs | 2 + .../Helpers/WorkOrderBoardProjection.cs | 2 + .../Helpers/WorkOrderBoardQueryFilters.cs | 9 +- .../Helpers/WorkOrderBoardSearchFilter.cs | 1 + .../Interfaces/WorkOrderBoardModels.cs | 1 + SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 1 + .../DTOs/WorkOrderBoardRequestDTOs.cs | 5 + .../Helpers/WorkOrderAddonCutoff.cs | 77 ++++++++++ .../Helpers/WorkOrderBoardMutationRules.cs | 2 +- .../WorkOrderBoardCreateService.cs | 6 + .../Implementation/WorkOrderBoardService.cs | 1 + .../WorkOrderBoardUpdateService.cs | 44 +++++- .../Implementation/WorkOrderDetailService.cs | 1 + .../WorkOrderBoardCreateValidation.cs | 5 +- .../WorkOrderAddonCutoffTests.cs | 135 ++++++++++++++++++ .../WorkOrderBoardCreateServiceTests.cs | 73 ++++++++++ .../WorkOrderBoardSearchTests.cs | 54 +++++++ .../WorkOrderBoardServiceTests.cs | 29 ++++ .../WorkOrderBoardUpdateServiceTests.cs | 89 ++++++++++++ 21 files changed, 566 insertions(+), 11 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260730150000_WoIsAddOn.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderAddonCutoff.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderAddonCutoffTests.cs 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()); + } }