mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-03 20:13:22 +00:00
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:
parent
c655f753b2
commit
b9f9725c73
7 changed files with 279 additions and 43 deletions
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue