fix: recalculate add-on at reschedule time, not creation time

A reschedule moves the work order into its new week at the moment of the
reschedule, so Add-On now compares that moment with the new week's cutoff,
matching the prototype. Moves within the same week keep the current value.
Previously an older work order moved into a week already past its cutoff
stayed off because it was created before that cutoff (SH-412).
This commit is contained in:
Alexandre Brandizzi 2026-10-01 16:10:19 -03:00
parent c655f753b2
commit b9f9725c73
7 changed files with 279 additions and 43 deletions

View file

@ -3,6 +3,7 @@ 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.
/// 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.
/// </summary>
public static class WorkOrderAddonCutoff
@ -36,14 +37,40 @@ namespace SeaHaven.Services.Helpers
}
/// <summary>
/// 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 <paramref name="rescheduledAtUtc"/> (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.
/// </summary>
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;

View file

@ -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<WorkOrderBoardRowDto> PatchFieldAsync(
@ -753,9 +756,10 @@ namespace SeaHaven.Services.Implementation
return FieldChange.ForField(auditField, old, newVal);
}
private static List<FieldChange> ApplyScheduledDate(WorkOrder workOrder, string? value, string auditField)
private List<FieldChange> 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<FieldChange> ApplyTargetWeek(WorkOrder workOrder, string? value, string auditField)
private List<FieldChange> 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<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);
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
return changes;
}
private static void AppendIsAddOnRecalculation(WorkOrder workOrder, List<FieldChange> changes)
private void AppendIsAddOnRecalculation(
WorkOrder workOrder,
DateTime? previousScheduledDate,
DateOnly? previousTargetWeek,
List<FieldChange> 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;

View file

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

View file

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

View file

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

View file

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

View file

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