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.
This commit is contained in:
Arthur Bassi 2026-07-30 12:20:07 -03:00 • committed by arthur.bassi
parent f7ce2ee25d
commit 97e042f552
21 changed files with 566 additions and 11 deletions

View file

@ -0,0 +1,37 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260730150000_WoIsAddOn")]
public partial class WoIsAddOn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
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;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsAddOn",
table: "workOrders");
}
}
}

View file

@ -2458,6 +2458,9 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
b.Property<bool>("IsAddOn")
.HasColumnType("bit");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");

View file

@ -80,6 +80,8 @@ namespace Data.SeaHavenIndustries
// --- Analytics slice ---
public int RescheduleCount { get; set; }
public int CarriedOver { get; set; }
/// <summary>Recalculated on schedule set/change/clear (SH-184); create hint when unscheduled.</summary>
public bool IsAddOn { get; set; }
public OperationalFlags OperationalFlags { get; set; }
// --- Completion slice ---

View file

@ -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,

View file

@ -66,8 +66,10 @@ namespace SeaHaven.DataServices.Helpers
var today = (utcNow ?? DateTime.UtcNow).Date;
var typeFilters = hasTypes ? types!.ToList() : new List<WorkOrderType>();
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<WorkOrder> ApplyTypeFilter(

View file

@ -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

View file

@ -18,6 +18,7 @@ namespace SeaHaven.DataServices.Interfaces
string? InternalWONumber,
int RescheduleCount,
int CarriedOver,
bool IsAddOn,
WorkOrderType? WorkOrderType,
string? SiteCode,
string? LocationName,

View file

@ -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; }

View file

@ -14,6 +14,11 @@ namespace SeaHaven.Services.DTOs
{
public string? WoNumber { get; set; }
public WorkOrderType WorkOrderType { get; set; }
/// <summary>
/// Manual Add-On override when unscheduled. Ignored when ScheduledDate or TargetWeek is set
/// (server computes from cutoff).
/// </summary>
public bool? IsAddOn { get; set; }
public string? SiteCode { get; set; }
/// <summary>
/// Optional CRM customer name. Required when the caller is org-wide (no account_id)

View file

@ -0,0 +1,77 @@
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// 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.
/// </summary>
public static class WorkOrderAddonCutoff
{
/// <summary>
/// Cutoff = Monday-of(anchor) − 2 days, end of day UTC (prior-week Saturday 23:59:59).
/// </summary>
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));
/// <summary>
/// With schedule (ScheduledDate or TargetWeek): IsAddOn when createdAtUtc is after cutoff.
/// Without schedule: client <paramref name="manualOverride"/> (default false).
/// </summary>
public static bool ComputeIsAddOnAtCreation(
DateTime createdAtUtc,
DateTime? scheduledDate,
DateOnly? targetWeek,
bool? manualOverride)
{
return ComputeFromScheduleAnchor(createdAtUtc, scheduledDate, targetWeek, manualOverride);
}
/// <summary>
/// SH-184 authoritative recalc after schedule set/change/clear. No manual override on patch.
/// Cleared schedule yields false.
/// </summary>
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);
}
}
}

View file

@ -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);

View file

@ -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>
{
WorkOrderBoardFieldMutations.BoardFieldChange.Changed("InternalWONumber", "", woNumber),

View file

@ -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,

View file

@ -147,8 +147,8 @@ namespace SeaHaven.Services.Implementation
WorkOrderBoardFieldNames.AssignTo => ApplyAssignTo(workOrder, value, auditField),
WorkOrderBoardFieldNames.DueDate => new List<FieldChange> { ApplyDateField(value, auditField, v => workOrder.DueDate = v, () => workOrder.DueDate) },
WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField),
WorkOrderBoardFieldNames.TargetWeek => new List<FieldChange> { ApplyTargetWeek(workOrder, value, auditField) },
WorkOrderBoardFieldNames.ScheduleWeekOnly => new List<FieldChange> { 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<FieldChange> { await ApplyVendorIdAsync(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { 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<FieldChange> 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<FieldChange>();
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<FieldChange> ApplyScheduleWeekOnly(WorkOrder workOrder, string? value, string auditField)
{
var change = ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly);
var changes = new List<FieldChange> { change };
if (change.HasChanged)
AppendIsAddOnRecalculation(workOrder, changes);
return changes;
}
private static void AppendIsAddOnRecalculation(WorkOrder workOrder, List<FieldChange> 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(

View file

@ -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,

View file

@ -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.")

View file

@ -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);
}
}

View file

@ -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<FluentValidation.ValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.AddOn,
SiteCode = "BK5"
}, "actor-1"));
Assert.Contains(ex.Errors, e => e.PropertyName == "WorkOrderType");
}
}

View file

@ -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> { 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);
}
}

View file

@ -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()
{

View file

@ -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());
}
}