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> /// <summary>
/// Server-side Add-On cutoff: Saturday 23:59:59 UTC of the week prior to the schedule week. /// 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. /// 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. /// Create may accept a client hint when unscheduled; schedule present ignores override.
/// </summary> /// </summary>
public static class WorkOrderAddonCutoff public static class WorkOrderAddonCutoff
@ -36,14 +37,40 @@ namespace SeaHaven.Services.Helpers
} }
/// <summary> /// <summary>
/// SH-184 authoritative recalc after schedule set/change/clear. No manual override on patch. /// SH-412: a reschedule into another week makes the WO arrive in that week at the moment
/// Cleared schedule yields false. /// 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> /// </summary>
public static bool RecalculateIsAddOnFromSchedule( public static bool RecalculateIsAddOnOnReschedule(
DateTime createdAtUtc, bool currentIsAddOn,
DateTime? previousScheduledDate,
DateOnly? previousTargetWeek,
DateTime? scheduledDate, DateTime? scheduledDate,
DateOnly? targetWeek) DateOnly? targetWeek,
=> ComputeFromScheduleAnchor(createdAtUtc, scheduledDate, targetWeek, manualOverride: false); 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( private static bool ComputeFromScheduleAnchor(
DateTime createdAtUtc, DateTime createdAtUtc,
@ -51,12 +78,7 @@ namespace SeaHaven.Services.Helpers
DateOnly? targetWeek, DateOnly? targetWeek,
bool? manualOverride) bool? manualOverride)
{ {
DateTime? anchor = null; var anchor = ScheduleAnchor(scheduledDate, targetWeek);
if (scheduledDate.HasValue)
anchor = scheduledDate.Value;
else if (targetWeek.HasValue)
anchor = targetWeek.Value.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
if (!anchor.HasValue) if (!anchor.HasValue)
return manualOverride ?? false; return manualOverride ?? false;

View file

@ -19,6 +19,7 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderUpliftService _upliftService; private readonly IWorkOrderUpliftService _upliftService;
private readonly IUpliftDataService _upliftData; private readonly IUpliftDataService _upliftData;
private readonly IServicesRegistryService? _servicesRegistryService; private readonly IServicesRegistryService? _servicesRegistryService;
private readonly TimeProvider _timeProvider;
public WorkOrderBoardUpdateService( public WorkOrderBoardUpdateService(
IWorkOrderBoardDataService boardDataService, IWorkOrderBoardDataService boardDataService,
@ -26,7 +27,8 @@ namespace SeaHaven.Services.Implementation
IWorkOrderAuditService auditService, IWorkOrderAuditService auditService,
IWorkOrderUpliftService upliftService, IWorkOrderUpliftService upliftService,
IUpliftDataService upliftData, IUpliftDataService upliftData,
IServicesRegistryService? servicesRegistryService = null) IServicesRegistryService? servicesRegistryService = null,
TimeProvider? timeProvider = null)
{ {
_boardDataService = boardDataService; _boardDataService = boardDataService;
_mutationData = mutationData; _mutationData = mutationData;
@ -34,6 +36,7 @@ namespace SeaHaven.Services.Implementation
_upliftService = upliftService; _upliftService = upliftService;
_upliftData = upliftData; _upliftData = upliftData;
_servicesRegistryService = servicesRegistryService; _servicesRegistryService = servicesRegistryService;
_timeProvider = timeProvider ?? TimeProvider.System;
} }
public async Task<WorkOrderBoardRowDto> PatchFieldAsync( public async Task<WorkOrderBoardRowDto> PatchFieldAsync(
@ -753,9 +756,10 @@ namespace SeaHaven.Services.Implementation
return FieldChange.ForField(auditField, old, newVal); 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 previousDate = workOrder.ScheduledDate;
var previousTargetWeek = workOrder.TargetWeek;
DateTime? parsed = null; DateTime? parsed = null;
if (!string.IsNullOrWhiteSpace(value)) if (!string.IsNullOrWhiteSpace(value))
{ {
@ -790,12 +794,14 @@ namespace SeaHaven.Services.Implementation
} }
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder)); changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
AppendIsAddOnRecalculation(workOrder, changes); AppendIsAddOnRecalculation(workOrder, previousDate, previousTargetWeek, changes);
return 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; DateOnly? parsed = null;
if (!string.IsNullOrWhiteSpace(value)) if (!string.IsNullOrWhiteSpace(value))
{ {
@ -813,28 +819,33 @@ namespace SeaHaven.Services.Implementation
changes.Add(FieldChange.ForField(auditField, old, newVal)); changes.Add(FieldChange.ForField(auditField, old, newVal));
} }
AppendIsAddOnRecalculation(workOrder, changes); AppendIsAddOnRecalculation(workOrder, previousDate, previousTargetWeek, changes);
return 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) private static List<FieldChange> ApplyScheduleWeekOnly(WorkOrder workOrder, string? value, string auditField)
{ {
var change = ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly); var change = ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly);
var changes = new List<FieldChange> { change }; var changes = new List<FieldChange> { change };
if (change.HasChanged)
AppendIsAddOnRecalculation(workOrder, changes);
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder)); changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
return changes; 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 oldValue = workOrder.IsAddOn.ToString();
var newIsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule( var newIsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnOnReschedule(
createdAt, workOrder.IsAddOn,
previousScheduledDate,
previousTargetWeek,
workOrder.ScheduledDate, workOrder.ScheduledDate,
workOrder.TargetWeek); workOrder.TargetWeek,
_timeProvider.GetUtcNow().UtcDateTime);
if (workOrder.IsAddOn == newIsAddOn) if (workOrder.IsAddOn == newIsAddOn)
return; return;

View file

@ -22,6 +22,7 @@ namespace SeaHaven.Services.Implementation
private readonly ICreateWorkOrderValidation _createValidator; private readonly ICreateWorkOrderValidation _createValidator;
private readonly IUpdateWorkOrderValidation _updateValidator; private readonly IUpdateWorkOrderValidation _updateValidator;
private readonly IWorkOrderAccountResolver _accountResolver; private readonly IWorkOrderAccountResolver _accountResolver;
private readonly TimeProvider _timeProvider;
public WorkOrderService( public WorkOrderService(
IWorkOrderDataService workOrderDataService, IWorkOrderDataService workOrderDataService,
@ -31,7 +32,8 @@ namespace SeaHaven.Services.Implementation
IFileStoragePort fileStorage, IFileStoragePort fileStorage,
ICreateWorkOrderValidation createValidator, ICreateWorkOrderValidation createValidator,
IUpdateWorkOrderValidation updateValidator, IUpdateWorkOrderValidation updateValidator,
IWorkOrderAccountResolver accountResolver) IWorkOrderAccountResolver accountResolver,
TimeProvider? timeProvider = null)
{ {
_workOrderDataService = workOrderDataService; _workOrderDataService = workOrderDataService;
_commentDataService = commentDataService; _commentDataService = commentDataService;
@ -41,6 +43,7 @@ namespace SeaHaven.Services.Implementation
_createValidator = createValidator; _createValidator = createValidator;
_updateValidator = updateValidator; _updateValidator = updateValidator;
_accountResolver = accountResolver; _accountResolver = accountResolver;
_timeProvider = timeProvider ?? TimeProvider.System;
} }
public async Task<WorkOrderDTO?> GetWorkOrderByIdAsync(int id) public async Task<WorkOrderDTO?> GetWorkOrderByIdAsync(int id)
@ -116,6 +119,8 @@ namespace SeaHaven.Services.Implementation
var scheduledDateChanged = dto.ScheduledDate.HasValue var scheduledDateChanged = dto.ScheduledDate.HasValue
&& dto.ScheduledDate.Value.Date != existingWorkOrder.ScheduledDate?.Date; && dto.ScheduledDate.Value.Date != existingWorkOrder.ScheduledDate?.Date;
var previousIsAddOn = existingWorkOrder.IsAddOn; var previousIsAddOn = existingWorkOrder.IsAddOn;
var previousScheduledDate = existingWorkOrder.ScheduledDate;
var previousTargetWeek = existingWorkOrder.TargetWeek;
UpdateWorkOrderDTO.UpdateEntity(dto, existingWorkOrder); UpdateWorkOrderDTO.UpdateEntity(dto, existingWorkOrder);
if (scheduledDateChanged) if (scheduledDateChanged)
{ {
@ -126,10 +131,13 @@ namespace SeaHaven.Services.Implementation
changes.AddRange(scheduleChanges changes.AddRange(scheduleChanges
.Where(c => c.HasChanged && c.FieldName != WorkOrderBoardFieldNames.ScheduledDate) .Where(c => c.HasChanged && c.FieldName != WorkOrderBoardFieldNames.ScheduledDate)
.Select(c => $"{c.FieldName}: {c.OldValue} → {c.NewValue}")); .Select(c => $"{c.FieldName}: {c.OldValue} → {c.NewValue}"));
existingWorkOrder.IsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule( existingWorkOrder.IsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnOnReschedule(
existingWorkOrder.CreatedDate ?? DateTime.UtcNow, previousIsAddOn,
previousScheduledDate,
previousTargetWeek,
existingWorkOrder.ScheduledDate, existingWorkOrder.ScheduledDate,
existingWorkOrder.TargetWeek); existingWorkOrder.TargetWeek,
_timeProvider.GetUtcNow().UtcDateTime);
if (existingWorkOrder.IsAddOn != previousIsAddOn) if (existingWorkOrder.IsAddOn != previousIsAddOn)
changes.Add($"IsAddOn: {previousIsAddOn} → {existingWorkOrder.IsAddOn}"); changes.Add($"IsAddOn: {previousIsAddOn} → {existingWorkOrder.IsAddOn}");
} }

View file

@ -19,14 +19,16 @@ internal static class WorkOrderAccountTestHelpers
public static WorkOrderBoardUpdateService BoardUpdateService( public static WorkOrderBoardUpdateService BoardUpdateService(
ApplicationDbContext context, ApplicationDbContext context,
IWorkOrderAuditService audit, IWorkOrderAuditService audit,
IServicesRegistryService? servicesRegistry = null) IServicesRegistryService? servicesRegistry = null,
TimeProvider? timeProvider = null)
=> new( => new(
new WorkOrderBoardDataService(context), new WorkOrderBoardDataService(context),
new WorkOrderBoardMutationDataService(context), new WorkOrderBoardMutationDataService(context),
audit, audit,
UpliftService(context), UpliftService(context),
new UpliftDataService(context), new UpliftDataService(context),
servicesRegistry); servicesRegistry,
timeProvider);
public static WorkOrderUpliftService UpliftService(ApplicationDbContext context) public static WorkOrderUpliftService UpliftService(ApplicationDbContext context)
=> new( => new(

View file

@ -114,21 +114,78 @@ public class WorkOrderAddonCutoffTests
Assert.True(result); 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] [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.RecalculateIsAddOnOnReschedule(
Assert.False(WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule(created, null, null)); currentIsAddOn: true,
previousScheduledDate: new DateTime(2026, 6, 24),
previousTargetWeek: null,
scheduledDate: null,
targetWeek: null,
RescheduledAt));
} }
[Fact] [Fact]
public void RecalculateIsAddOnFromSchedule_AfterCutoff_ReturnsTrue() public void RecalculateOnReschedule_IntoWeekPastCutoff_TurnsOnForWorkOrderCreatedLongBefore()
{ {
var created = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc); // SH-412: the WO was created weeks earlier; it arrives in the current week at the
var result = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule( // moment of the reschedule, which is already past that week's cutoff.
created, var result = WorkOrderAddonCutoff.RecalculateIsAddOnOnReschedule(
scheduledDate: new DateTime(2026, 6, 24), currentIsAddOn: false,
targetWeek: null); 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); Assert.True(result);
} }

View file

@ -13,7 +13,8 @@ namespace SeaHavenIndustries.Tests;
public class WorkOrderBoardUpdateServiceTests 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>() var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
@ -23,10 +24,16 @@ public class WorkOrderBoardUpdateServiceTests
var mutationData = new WorkOrderBoardMutationDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); 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); 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) private static string ToVersion(WorkOrder workOrder)
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); => 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); 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] [Fact]
public async Task PatchField_WorkOrderType_DoesNotRecalculateIsAddOn() 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); 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] [Fact]
public async Task EditWorkorder_FirstSchedule_SetsOriginalWeekAndPromotesIncomplete() public async Task EditWorkorder_FirstSchedule_SetsOriginalWeekAndPromotesIncomplete()
{ {
@ -145,7 +225,15 @@ public sealed class WorkOrderEditAddonRescheduleTests
return new ApplicationDbContext(options); 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 accountResolver = WorkOrderAccountTestHelpers.Resolver(context);
var workOrderService = new WorkOrderService( var workOrderService = new WorkOrderService(
@ -156,7 +244,8 @@ public sealed class WorkOrderEditAddonRescheduleTests
fileStorage: null!, fileStorage: null!,
new CreateWorkOrderValidation(), new CreateWorkOrderValidation(),
new UpdateWorkOrderValidation(), new UpdateWorkOrderValidation(),
accountResolver); accountResolver,
timeProvider);
return new WorkOrderController( return new WorkOrderController(
workOrderService, workOrderService,
accountResolver, accountResolver,