diff --git a/SeaHaven.Services/Helpers/WorkOrderAddonCutoff.cs b/SeaHaven.Services/Helpers/WorkOrderAddonCutoff.cs index db81e62..886b095 100644 --- a/SeaHaven.Services/Helpers/WorkOrderAddonCutoff.cs +++ b/SeaHaven.Services/Helpers/WorkOrderAddonCutoff.cs @@ -3,6 +3,7 @@ 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. + /// At creation the WO arrives when it is created; on a reschedule it arrives when it is moved. /// Create may accept a client hint when unscheduled; schedule present ignores override. /// public static class WorkOrderAddonCutoff @@ -36,14 +37,40 @@ namespace SeaHaven.Services.Helpers } /// - /// SH-184 authoritative recalc after schedule set/change/clear. No manual override on patch. - /// Cleared schedule yields false. + /// SH-412: a reschedule into another week makes the WO arrive in that week at the moment + /// of the reschedule, so Add-On compares (not the + /// creation time) with the new week's cutoff. A move within the same week keeps the + /// current value; a cleared schedule yields false. Matches the prototype's reschedule rule. /// - public static bool RecalculateIsAddOnFromSchedule( - DateTime createdAtUtc, + public static bool RecalculateIsAddOnOnReschedule( + bool currentIsAddOn, + DateTime? previousScheduledDate, + DateOnly? previousTargetWeek, DateTime? scheduledDate, - DateOnly? targetWeek) - => ComputeFromScheduleAnchor(createdAtUtc, scheduledDate, targetWeek, manualOverride: false); + DateOnly? targetWeek, + DateTime rescheduledAtUtc) + { + var anchor = ScheduleAnchor(scheduledDate, targetWeek); + if (!anchor.HasValue) + return false; + + var previousAnchor = ScheduleAnchor(previousScheduledDate, previousTargetWeek); + if (previousAnchor.HasValue + && WorkOrderBoardMutationRules.GetWeekStartMonday(previousAnchor.Value) + == WorkOrderBoardMutationRules.GetWeekStartMonday(anchor.Value)) + return currentIsAddOn; + + return NormalizeUtc(rescheduledAtUtc) > GetAddonCutoffUtc(anchor.Value); + } + + private static DateTime? ScheduleAnchor(DateTime? scheduledDate, DateOnly? targetWeek) + { + if (scheduledDate.HasValue) + return scheduledDate.Value; + if (targetWeek.HasValue) + return targetWeek.Value.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + return null; + } private static bool ComputeFromScheduleAnchor( DateTime createdAtUtc, @@ -51,12 +78,7 @@ namespace SeaHaven.Services.Helpers 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); - + var anchor = ScheduleAnchor(scheduledDate, targetWeek); if (!anchor.HasValue) return manualOverride ?? false; diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 25e56de..72b26ed 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -19,6 +19,7 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderUpliftService _upliftService; private readonly IUpliftDataService _upliftData; private readonly IServicesRegistryService? _servicesRegistryService; + private readonly TimeProvider _timeProvider; public WorkOrderBoardUpdateService( IWorkOrderBoardDataService boardDataService, @@ -26,7 +27,8 @@ namespace SeaHaven.Services.Implementation IWorkOrderAuditService auditService, IWorkOrderUpliftService upliftService, IUpliftDataService upliftData, - IServicesRegistryService? servicesRegistryService = null) + IServicesRegistryService? servicesRegistryService = null, + TimeProvider? timeProvider = null) { _boardDataService = boardDataService; _mutationData = mutationData; @@ -34,6 +36,7 @@ namespace SeaHaven.Services.Implementation _upliftService = upliftService; _upliftData = upliftData; _servicesRegistryService = servicesRegistryService; + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task PatchFieldAsync( @@ -753,9 +756,10 @@ namespace SeaHaven.Services.Implementation return FieldChange.ForField(auditField, old, newVal); } - private static List ApplyScheduledDate(WorkOrder workOrder, string? value, string auditField) + private List ApplyScheduledDate(WorkOrder workOrder, string? value, string auditField) { var previousDate = workOrder.ScheduledDate; + var previousTargetWeek = workOrder.TargetWeek; DateTime? parsed = null; if (!string.IsNullOrWhiteSpace(value)) { @@ -790,12 +794,14 @@ namespace SeaHaven.Services.Implementation } changes.AddRange(ApplyAutoScheduleSideEffects(workOrder)); - AppendIsAddOnRecalculation(workOrder, changes); + AppendIsAddOnRecalculation(workOrder, previousDate, previousTargetWeek, changes); return changes; } - private static List ApplyTargetWeek(WorkOrder workOrder, string? value, string auditField) + private List ApplyTargetWeek(WorkOrder workOrder, string? value, string auditField) { + var previousDate = workOrder.ScheduledDate; + var previousTargetWeek = workOrder.TargetWeek; DateOnly? parsed = null; if (!string.IsNullOrWhiteSpace(value)) { @@ -813,28 +819,33 @@ namespace SeaHaven.Services.Implementation changes.Add(FieldChange.ForField(auditField, old, newVal)); } - AppendIsAddOnRecalculation(workOrder, changes); + AppendIsAddOnRecalculation(workOrder, previousDate, previousTargetWeek, changes); return changes; } + // Toggling week-only mode keeps the scheduled week, so Add-On is unchanged (SH-412). 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); changes.AddRange(ApplyAutoScheduleSideEffects(workOrder)); return changes; } - private static void AppendIsAddOnRecalculation(WorkOrder workOrder, List changes) + private void AppendIsAddOnRecalculation( + WorkOrder workOrder, + DateTime? previousScheduledDate, + DateOnly? previousTargetWeek, + List changes) { - var createdAt = workOrder.CreatedDate ?? DateTime.UtcNow; var oldValue = workOrder.IsAddOn.ToString(); - var newIsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule( - createdAt, + var newIsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnOnReschedule( + workOrder.IsAddOn, + previousScheduledDate, + previousTargetWeek, workOrder.ScheduledDate, - workOrder.TargetWeek); + workOrder.TargetWeek, + _timeProvider.GetUtcNow().UtcDateTime); if (workOrder.IsAddOn == newIsAddOn) return; diff --git a/SeaHaven.Services/Implementation/WorkOrderService.cs b/SeaHaven.Services/Implementation/WorkOrderService.cs index 132cbe5..3670ad2 100644 --- a/SeaHaven.Services/Implementation/WorkOrderService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderService.cs @@ -22,6 +22,7 @@ namespace SeaHaven.Services.Implementation private readonly ICreateWorkOrderValidation _createValidator; private readonly IUpdateWorkOrderValidation _updateValidator; private readonly IWorkOrderAccountResolver _accountResolver; + private readonly TimeProvider _timeProvider; public WorkOrderService( IWorkOrderDataService workOrderDataService, @@ -31,7 +32,8 @@ namespace SeaHaven.Services.Implementation IFileStoragePort fileStorage, ICreateWorkOrderValidation createValidator, IUpdateWorkOrderValidation updateValidator, - IWorkOrderAccountResolver accountResolver) + IWorkOrderAccountResolver accountResolver, + TimeProvider? timeProvider = null) { _workOrderDataService = workOrderDataService; _commentDataService = commentDataService; @@ -41,6 +43,7 @@ namespace SeaHaven.Services.Implementation _createValidator = createValidator; _updateValidator = updateValidator; _accountResolver = accountResolver; + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task GetWorkOrderByIdAsync(int id) @@ -116,6 +119,8 @@ namespace SeaHaven.Services.Implementation var scheduledDateChanged = dto.ScheduledDate.HasValue && dto.ScheduledDate.Value.Date != existingWorkOrder.ScheduledDate?.Date; var previousIsAddOn = existingWorkOrder.IsAddOn; + var previousScheduledDate = existingWorkOrder.ScheduledDate; + var previousTargetWeek = existingWorkOrder.TargetWeek; UpdateWorkOrderDTO.UpdateEntity(dto, existingWorkOrder); if (scheduledDateChanged) { @@ -126,10 +131,13 @@ namespace SeaHaven.Services.Implementation changes.AddRange(scheduleChanges .Where(c => c.HasChanged && c.FieldName != WorkOrderBoardFieldNames.ScheduledDate) .Select(c => $"{c.FieldName}: {c.OldValue} → {c.NewValue}")); - existingWorkOrder.IsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule( - existingWorkOrder.CreatedDate ?? DateTime.UtcNow, + existingWorkOrder.IsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnOnReschedule( + previousIsAddOn, + previousScheduledDate, + previousTargetWeek, existingWorkOrder.ScheduledDate, - existingWorkOrder.TargetWeek); + existingWorkOrder.TargetWeek, + _timeProvider.GetUtcNow().UtcDateTime); if (existingWorkOrder.IsAddOn != previousIsAddOn) changes.Add($"IsAddOn: {previousIsAddOn} → {existingWorkOrder.IsAddOn}"); } diff --git a/SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs b/SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs index 040e235..9c33645 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs @@ -19,14 +19,16 @@ internal static class WorkOrderAccountTestHelpers public static WorkOrderBoardUpdateService BoardUpdateService( ApplicationDbContext context, IWorkOrderAuditService audit, - IServicesRegistryService? servicesRegistry = null) + IServicesRegistryService? servicesRegistry = null, + TimeProvider? timeProvider = null) => new( new WorkOrderBoardDataService(context), new WorkOrderBoardMutationDataService(context), audit, UpliftService(context), new UpliftDataService(context), - servicesRegistry); + servicesRegistry, + timeProvider); public static WorkOrderUpliftService UpliftService(ApplicationDbContext context) => new( diff --git a/SeaHavenIndustries.Tests/WorkOrderAddonCutoffTests.cs b/SeaHavenIndustries.Tests/WorkOrderAddonCutoffTests.cs index d860482..91b4623 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAddonCutoffTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAddonCutoffTests.cs @@ -114,21 +114,78 @@ public class WorkOrderAddonCutoffTests Assert.True(result); } + // Rescheduled on Wed 2026-06-24: the week of 06-22 is past its cutoff (Sat 06-20 23:59:59), + // the week of 06-29 is not (cutoff Sat 06-27 23:59:59). + private static readonly DateTime RescheduledAt = new(2026, 6, 24, 10, 0, 0, DateTimeKind.Utc); + [Fact] - public void RecalculateIsAddOnFromSchedule_NoSchedule_ReturnsFalse() + public void RecalculateOnReschedule_NoSchedule_ReturnsFalse() { - var created = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc); - Assert.False(WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule(created, null, null)); + Assert.False(WorkOrderAddonCutoff.RecalculateIsAddOnOnReschedule( + currentIsAddOn: true, + previousScheduledDate: new DateTime(2026, 6, 24), + previousTargetWeek: null, + scheduledDate: null, + targetWeek: null, + RescheduledAt)); } [Fact] - public void RecalculateIsAddOnFromSchedule_AfterCutoff_ReturnsTrue() + public void RecalculateOnReschedule_IntoWeekPastCutoff_TurnsOnForWorkOrderCreatedLongBefore() { - 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); + // SH-412: the WO was created weeks earlier; it arrives in the current week at the + // moment of the reschedule, which is already past that week's cutoff. + var result = WorkOrderAddonCutoff.RecalculateIsAddOnOnReschedule( + currentIsAddOn: false, + previousScheduledDate: new DateTime(2026, 7, 8), + previousTargetWeek: null, + scheduledDate: new DateTime(2026, 6, 25), + targetWeek: null, + RescheduledAt); + + Assert.True(result); + } + + [Fact] + public void RecalculateOnReschedule_IntoWeekBeforeCutoff_TurnsOff() + { + var result = WorkOrderAddonCutoff.RecalculateIsAddOnOnReschedule( + currentIsAddOn: true, + previousScheduledDate: new DateTime(2026, 6, 24), + previousTargetWeek: null, + scheduledDate: new DateTime(2026, 7, 1), + targetWeek: null, + RescheduledAt); + + Assert.False(result); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void RecalculateOnReschedule_WithinSameWeek_KeepsCurrentValue(bool currentIsAddOn) + { + var result = WorkOrderAddonCutoff.RecalculateIsAddOnOnReschedule( + currentIsAddOn, + previousScheduledDate: new DateTime(2026, 6, 22), + previousTargetWeek: null, + scheduledDate: new DateTime(2026, 6, 26), + targetWeek: null, + RescheduledAt); + + Assert.Equal(currentIsAddOn, result); + } + + [Fact] + public void RecalculateOnReschedule_FirstScheduleIntoWeekPastCutoff_TurnsOn() + { + var result = WorkOrderAddonCutoff.RecalculateIsAddOnOnReschedule( + currentIsAddOn: false, + previousScheduledDate: null, + previousTargetWeek: null, + scheduledDate: new DateTime(2026, 6, 26), + targetWeek: null, + RescheduledAt); Assert.True(result); } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 9901e3a..0cf861b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -13,7 +13,8 @@ namespace SeaHavenIndustries.Tests; public class WorkOrderBoardUpdateServiceTests { - private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut() + private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut( + TimeProvider? timeProvider = null) { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) @@ -23,10 +24,16 @@ public class WorkOrderBoardUpdateServiceTests var mutationData = new WorkOrderBoardMutationDataService(context); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); - var service = WorkOrderAccountTestHelpers.BoardUpdateService(context, audit); + var service = WorkOrderAccountTestHelpers.BoardUpdateService(context, audit, timeProvider: timeProvider); return (context, service); } + // Wed 2026-06-24: the week of 06-22 is past its cutoff, the week of 06-29 is not. + private sealed class FixedTimeProvider : TimeProvider + { + public override DateTimeOffset GetUtcNow() => new(2026, 6, 24, 10, 0, 0, TimeSpan.Zero); + } + private static string ToVersion(WorkOrder workOrder) => Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); @@ -2260,6 +2267,46 @@ public class WorkOrderBoardUpdateServiceTests Assert.True(persisted.IsAddOn); } + [Theory] + [InlineData("2026-07-08", false, "2026-06-25", true)] // into the current week, past its cutoff + [InlineData("2026-06-24", true, "2026-07-01", false)] // into next week, before its cutoff + [InlineData("2026-07-06", false, "2026-07-09", false)] // same week: unchanged + [InlineData("2026-06-22", true, "2026-06-26", true)] // same week: unchanged + public async Task PatchField_ScheduledDate_RecalculatesIsAddOnAtRescheduleTime( + string from, + bool isAddOn, + string to, + bool expectedIsAddOn) + { + var (context, service) = CreateSut(new FixedTimeProvider()); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.PM, + ScheduledDate = DateTime.Parse(from), + // Created weeks before any of these cutoffs: creation time must not decide (SH-412). + CreatedDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), + IsAddOn = isAddOn, + 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 = to, + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(expectedIsAddOn, result.IsAddOn); + var persisted = await context.workOrders.SingleAsync(w => w.Id == 1); + Assert.Equal(expectedIsAddOn, persisted.IsAddOn); + var addOnAudits = await context.WorkOrderAuditLogs.Where(a => a.FieldName == "IsAddOn").ToListAsync(); + Assert.Equal(isAddOn == expectedIsAddOn ? 0 : 1, addOnAudits.Count); + } + [Fact] public async Task PatchField_WorkOrderType_DoesNotRecalculateIsAddOn() { diff --git a/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs b/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs index 328e4f2..0748c9f 100644 --- a/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs @@ -49,6 +49,86 @@ public sealed class WorkOrderEditAddonRescheduleTests Assert.Contains(audit, x => x.FieldName?.Contains("RescheduleCount: 0 → 1", StringComparison.Ordinal) == true); } + [Fact] + public async Task EditWorkorder_RescheduleOlderWorkOrderIntoWeekPastCutoff_EnablesAndAuditsAddOn() + { + // SH-412 retest: the WO was created weeks before the target week's cutoff, so the + // creation-time rule kept Add-On off. It arrives in the week when it is rescheduled. + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + CreatedDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), + ScheduledDate = new DateTime(2026, 7, 8), + IsAddOn = false + }); + await context.SaveChangesAsync(); + + var result = await CreateController(context, new FixedTimeProvider()).Editworkorder(new EditWorkorder_DTO + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 25) + }); + + Assert.IsType(result); + var persisted = await context.workOrders.SingleAsync(x => x.Id == 1); + Assert.True(persisted.IsAddOn); + var audit = await context.WorkOrderAuditLogs.SingleAsync(x => x.WorkOrderId == 1); + Assert.Contains("IsAddOn: False → True", audit.FieldName); + } + + [Fact] + public async Task EditWorkorder_RescheduleIntoWeekBeforeCutoff_ClearsAndAuditsAddOn() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + CreatedDate = new DateTime(2026, 6, 23, 0, 0, 0, DateTimeKind.Utc), + ScheduledDate = new DateTime(2026, 6, 24), + IsAddOn = true + }); + await context.SaveChangesAsync(); + + var result = await CreateController(context, new FixedTimeProvider()).Editworkorder(new EditWorkorder_DTO + { + Id = 1, + ScheduledDate = new DateTime(2026, 7, 1) + }); + + Assert.IsType(result); + var persisted = await context.workOrders.SingleAsync(x => x.Id == 1); + Assert.False(persisted.IsAddOn); + var audit = await context.WorkOrderAuditLogs.SingleAsync(x => x.WorkOrderId == 1); + Assert.Contains("IsAddOn: True → False", audit.FieldName); + } + + [Fact] + public async Task EditWorkorder_RescheduleWithinSameWeek_KeepsAddOn() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + CreatedDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), + ScheduledDate = new DateTime(2026, 7, 6), + IsAddOn = false + }); + await context.SaveChangesAsync(); + + var result = await CreateController(context, new FixedTimeProvider()).Editworkorder(new EditWorkorder_DTO + { + Id = 1, + ScheduledDate = new DateTime(2026, 7, 9) + }); + + Assert.IsType(result); + var persisted = await context.workOrders.SingleAsync(x => x.Id == 1); + Assert.False(persisted.IsAddOn); + var audit = await context.WorkOrderAuditLogs.SingleAsync(x => x.WorkOrderId == 1); + Assert.DoesNotContain("IsAddOn", audit.FieldName); + } + [Fact] public async Task EditWorkorder_FirstSchedule_SetsOriginalWeekAndPromotesIncomplete() { @@ -145,7 +225,15 @@ public sealed class WorkOrderEditAddonRescheduleTests return new ApplicationDbContext(options); } - private static WorkOrderController CreateController(ApplicationDbContext context) + // Wed 2026-06-24: the week of 06-22 is past its cutoff, the week of 06-29 is not. + private sealed class FixedTimeProvider : TimeProvider + { + public override DateTimeOffset GetUtcNow() => new(2026, 6, 24, 10, 0, 0, TimeSpan.Zero); + } + + private static WorkOrderController CreateController( + ApplicationDbContext context, + TimeProvider? timeProvider = null) { var accountResolver = WorkOrderAccountTestHelpers.Resolver(context); var workOrderService = new WorkOrderService( @@ -156,7 +244,8 @@ public sealed class WorkOrderEditAddonRescheduleTests fileStorage: null!, new CreateWorkOrderValidation(), new UpdateWorkOrderValidation(), - accountResolver); + accountResolver, + timeProvider); return new WorkOrderController( workOrderService, accountResolver,