mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-01 11:03:14 +00:00
Merge pull request #112 from Sea-Haven-Industries/feat/SH-186-status-auto-derivation
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging to Elastic Beanstalk (push) Blocked by required conditions
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging to Elastic Beanstalk (push) Blocked by required conditions
feat(work-orders): derive Scheduled from concrete Schedule On
This commit is contained in:
commit
492478f815
12 changed files with 548 additions and 72 deletions
|
|
@ -33,7 +33,7 @@ namespace SeaHaven.Services.Helpers
|
|||
}
|
||||
|
||||
if (requested == LifecycleStatus.Scheduled
|
||||
&& (!scheduledDate.HasValue || scheduleWeekOnly == true))
|
||||
&& !WorkOrderBoardMutationRules.HasConcreteSchedule(scheduledDate, scheduleWeekOnly))
|
||||
{
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"ScheduledRequiresDate",
|
||||
|
|
|
|||
|
|
@ -62,21 +62,7 @@ namespace SeaHaven.Services.Helpers
|
|||
}
|
||||
|
||||
public static List<BoardFieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
|
||||
{
|
||||
var changes = new List<BoardFieldChange>();
|
||||
if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(
|
||||
workOrder.LifecycleStatus,
|
||||
workOrder.ScheduledDate,
|
||||
workOrder.AssignTo,
|
||||
workOrder.ScheduleWeekOnly))
|
||||
return changes;
|
||||
|
||||
var old = workOrder.LifecycleStatus?.ToString();
|
||||
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
|
||||
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Scheduled);
|
||||
changes.Add(BoardFieldChange.StatusChanged("LifecycleStatus", old, LifecycleStatus.Scheduled.ToString()));
|
||||
return changes;
|
||||
}
|
||||
=> WorkOrderDerivedFields.ApplyLifecycleFromSchedule(workOrder);
|
||||
|
||||
public static BoardFieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -46,7 +46,13 @@ namespace SeaHaven.Services.Helpers
|
|||
=> field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue;
|
||||
|
||||
/// <summary>
|
||||
/// SHOC rule: Incomplete + specific scheduled date + assignee → Scheduled.
|
||||
/// Concrete Schedule On: a calendar day is set and the row is not week-only.
|
||||
/// </summary>
|
||||
public static bool HasConcreteSchedule(DateTime? scheduledDate, bool? scheduleWeekOnly)
|
||||
=> scheduledDate.HasValue && scheduleWeekOnly != true;
|
||||
|
||||
/// <summary>
|
||||
/// Incomplete or Pending + concrete scheduled date → Scheduled. Assignee is not required.
|
||||
/// Week-only targets do not auto-schedule (same as <see cref="WorkOrderDerivedFields.ApplyAutoScheduleIfEligible"/>).
|
||||
/// </summary>
|
||||
public static bool ShouldAutoSchedule(
|
||||
|
|
@ -54,10 +60,27 @@ namespace SeaHaven.Services.Helpers
|
|||
DateTime? scheduledDate,
|
||||
string? assignTo,
|
||||
bool? scheduleWeekOnly = null)
|
||||
=> status == LifecycleStatus.Incomplete
|
||||
&& scheduleWeekOnly != true
|
||||
&& scheduledDate.HasValue
|
||||
&& !string.IsNullOrWhiteSpace(assignTo);
|
||||
{
|
||||
_ = assignTo;
|
||||
return ShouldPromoteToScheduled(status, scheduledDate, scheduleWeekOnly);
|
||||
}
|
||||
|
||||
public static bool ShouldPromoteToScheduled(
|
||||
LifecycleStatus? status,
|
||||
DateTime? scheduledDate,
|
||||
bool? scheduleWeekOnly = null)
|
||||
=> (status == LifecycleStatus.Incomplete || status == LifecycleStatus.Pending)
|
||||
&& HasConcreteSchedule(scheduledDate, scheduleWeekOnly);
|
||||
|
||||
/// <summary>
|
||||
/// Scheduled without a concrete date (cleared or week-only) → Pending. Later statuses are ignored.
|
||||
/// </summary>
|
||||
public static bool ShouldDemoteFromScheduled(
|
||||
LifecycleStatus? status,
|
||||
DateTime? scheduledDate,
|
||||
bool? scheduleWeekOnly = null)
|
||||
=> status == LifecycleStatus.Scheduled
|
||||
&& !HasConcreteSchedule(scheduledDate, scheduleWeekOnly);
|
||||
|
||||
public static bool IsReschedule(DateTime? previousDate, DateTime? newDate)
|
||||
=> previousDate.HasValue
|
||||
|
|
|
|||
|
|
@ -50,25 +50,50 @@ namespace SeaHaven.Services.Helpers
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHOC rule: specific scheduled date + assignee + Incomplete → Scheduled.
|
||||
/// Week-only targets do not auto-schedule.
|
||||
/// Promote Incomplete/Pending to Scheduled, or demote Scheduled to Pending, from Schedule On.
|
||||
/// Assignee is not required. Week-only is not a concrete date.
|
||||
/// </summary>
|
||||
public static List<WorkOrderBoardFieldMutations.BoardFieldChange> ApplyLifecycleFromSchedule(WorkOrder workOrder)
|
||||
{
|
||||
var changes = new List<WorkOrderBoardFieldMutations.BoardFieldChange>();
|
||||
LifecycleStatus? next = null;
|
||||
if (WorkOrderBoardMutationRules.ShouldPromoteToScheduled(
|
||||
workOrder.LifecycleStatus,
|
||||
workOrder.ScheduledDate,
|
||||
workOrder.ScheduleWeekOnly))
|
||||
{
|
||||
next = LifecycleStatus.Scheduled;
|
||||
}
|
||||
else if (WorkOrderBoardMutationRules.ShouldDemoteFromScheduled(
|
||||
workOrder.LifecycleStatus,
|
||||
workOrder.ScheduledDate,
|
||||
workOrder.ScheduleWeekOnly))
|
||||
{
|
||||
next = LifecycleStatus.Pending;
|
||||
}
|
||||
|
||||
if (next == null || workOrder.LifecycleStatus == next)
|
||||
return changes;
|
||||
|
||||
var old = workOrder.LifecycleStatus?.ToString();
|
||||
workOrder.LifecycleStatus = next;
|
||||
workOrder.Status = GetLifecycleStatusLabel(next);
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.StatusChanged(
|
||||
"LifecycleStatus",
|
||||
old,
|
||||
next.Value.ToString()));
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies schedule-driven lifecycle (promote or demote). True when the row becomes Scheduled.
|
||||
/// </summary>
|
||||
public static bool ApplyAutoScheduleIfEligible(WorkOrder workOrder)
|
||||
{
|
||||
if (workOrder.LifecycleStatus != LifecycleStatus.Incomplete)
|
||||
return false;
|
||||
|
||||
if (workOrder.ScheduleWeekOnly == true)
|
||||
return false;
|
||||
|
||||
if (workOrder.ScheduledDate == null)
|
||||
return false;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(workOrder.AssignTo))
|
||||
return false;
|
||||
|
||||
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
|
||||
return true;
|
||||
var before = workOrder.LifecycleStatus;
|
||||
ApplyLifecycleFromSchedule(workOrder);
|
||||
return before != LifecycleStatus.Scheduled
|
||||
&& workOrder.LifecycleStatus == LifecycleStatus.Scheduled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -175,6 +175,8 @@ namespace SeaHaven.Services.Implementation
|
|||
if (request.ScheduledDate.HasValue)
|
||||
changes.AddRange(WorkOrderBoardFieldMutations.ApplyScheduledDate(workOrder, request.ScheduledDate.Value.Date));
|
||||
|
||||
changes.AddRange(WorkOrderBoardFieldMutations.ApplyAutoScheduleSideEffects(workOrder));
|
||||
|
||||
Dispatch? dispatch = null;
|
||||
if (hasVendorDispatch)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderBoardFieldNames.WorkOrderType => ApplyWorkOrderType(workOrder, value, auditField),
|
||||
WorkOrderBoardFieldNames.Severity => new List<FieldChange> { ApplySeverity(workOrder, value, auditField) },
|
||||
WorkOrderBoardFieldNames.SiteCode => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) },
|
||||
WorkOrderBoardFieldNames.LifecycleStatus => new List<FieldChange> { ApplyLifecycleStatus(workOrder, value, auditField) },
|
||||
WorkOrderBoardFieldNames.LifecycleStatus => ApplyLifecycleStatus(workOrder, value, auditField),
|
||||
WorkOrderBoardFieldNames.AssignTo => ApplyAssignTo(workOrder, value, auditField),
|
||||
WorkOrderBoardFieldNames.DueDate => new List<FieldChange> { ApplyDateField(value, auditField, v => workOrder.DueDate = v, () => workOrder.DueDate) },
|
||||
WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField),
|
||||
|
|
@ -503,7 +503,7 @@ namespace SeaHaven.Services.Implementation
|
|||
return normalized;
|
||||
}
|
||||
|
||||
private static FieldChange ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField)
|
||||
private static List<FieldChange> ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField)
|
||||
{
|
||||
var parsed = LifecycleStatusMapper.ParseLifecycleStatus(value);
|
||||
if (parsed == null)
|
||||
|
|
@ -516,16 +516,31 @@ namespace SeaHaven.Services.Implementation
|
|||
"Replace the provisional SH work order number before marking the work order Completed.");
|
||||
}
|
||||
|
||||
if (parsed == LifecycleStatus.Scheduled
|
||||
&& !WorkOrderBoardMutationRules.HasConcreteSchedule(
|
||||
workOrder.ScheduledDate,
|
||||
workOrder.ScheduleWeekOnly))
|
||||
{
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"ScheduledRequiresDate",
|
||||
"Scheduled requires a concrete scheduledDate when scheduleWeekOnly is not true.");
|
||||
}
|
||||
|
||||
var changes = new List<FieldChange>();
|
||||
var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status;
|
||||
if (workOrder.LifecycleStatus == parsed)
|
||||
return FieldChange.Unchanged(auditField);
|
||||
if (workOrder.LifecycleStatus != parsed)
|
||||
{
|
||||
workOrder.LifecycleStatus = parsed;
|
||||
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(parsed);
|
||||
if (workOrder.LegacyStatus == null && workOrder.Status != null)
|
||||
workOrder.LegacyStatus = workOrder.Status;
|
||||
|
||||
workOrder.LifecycleStatus = parsed;
|
||||
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(parsed);
|
||||
if (workOrder.LegacyStatus == null && workOrder.Status != null)
|
||||
workOrder.LegacyStatus = workOrder.Status;
|
||||
changes.Add(FieldChange.StatusChanged(auditField, old, parsed.ToString()));
|
||||
}
|
||||
|
||||
return FieldChange.StatusChanged(auditField, old, parsed.ToString());
|
||||
// why: Incomplete/Pending patched while a concrete date already exists must still promote.
|
||||
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
|
||||
return changes;
|
||||
}
|
||||
|
||||
private static List<FieldChange> ApplyAssignTo(WorkOrder workOrder, string? value, string auditField)
|
||||
|
|
@ -637,6 +652,7 @@ namespace SeaHaven.Services.Implementation
|
|||
var changes = new List<FieldChange> { change };
|
||||
if (change.HasChanged)
|
||||
AppendIsAddOnRecalculation(workOrder, changes);
|
||||
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
|
||||
return changes;
|
||||
}
|
||||
|
||||
|
|
@ -785,21 +801,12 @@ namespace SeaHaven.Services.Implementation
|
|||
}
|
||||
|
||||
private static List<FieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
|
||||
{
|
||||
var changes = new List<FieldChange>();
|
||||
if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(
|
||||
workOrder.LifecycleStatus,
|
||||
workOrder.ScheduledDate,
|
||||
workOrder.AssignTo,
|
||||
workOrder.ScheduleWeekOnly))
|
||||
return changes;
|
||||
=> WorkOrderBoardFieldMutations.ApplyAutoScheduleSideEffects(workOrder)
|
||||
.Select(ToFieldChange)
|
||||
.ToList();
|
||||
|
||||
var old = workOrder.LifecycleStatus?.ToString();
|
||||
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
|
||||
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Scheduled);
|
||||
changes.Add(FieldChange.StatusChanged("LifecycleStatus", old, LifecycleStatus.Scheduled.ToString()));
|
||||
return changes;
|
||||
}
|
||||
private static FieldChange ToFieldChange(WorkOrderBoardFieldMutations.BoardFieldChange change)
|
||||
=> new(change.FieldName, change.OldValue, change.NewValue, change.Action, change.DispatchId, change.HasChanged);
|
||||
|
||||
private static bool RowVersionsMatch(byte[]? current, byte[] expected)
|
||||
=> current != null && current.AsSpan().SequenceEqual(expected);
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ public class WorkOrderBoardConcurrencyTests
|
|||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
ScheduledDate = new DateTime(2027, 6, 25),
|
||||
PrimaryDispatchId = 10,
|
||||
RowVersion = new byte[] { 8, 0, 0, 0, 0, 0, 0, 8 }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -106,6 +106,40 @@ public class WorkOrderBoardCreateServiceTests
|
|||
Assert.Equal("DuplicateWoNumber", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_AutoSchedulesWhenDateWithoutAssignee()
|
||||
{
|
||||
var (_, service) = CreateSut();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
LocationId = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 25)
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
|
||||
Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PendingWithDate_PromotesToScheduled()
|
||||
{
|
||||
var (_, service) = CreateSut();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
LocationId = 1,
|
||||
LifecycleStatus = LifecycleStatus.Pending,
|
||||
ScheduledDate = new DateTime(2026, 6, 25)
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_AutoSchedulesWhenDateAndAssignee()
|
||||
{
|
||||
|
|
@ -140,6 +174,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
|
||||
Assert.True(result.ScheduleWeekOnly);
|
||||
Assert.Equal(new DateOnly(2026, 6, 22), result.TargetWeek);
|
||||
Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -695,7 +730,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
{
|
||||
var (_, service) = CreateSut();
|
||||
|
||||
await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
|
|
@ -703,6 +738,28 @@ public class WorkOrderBoardCreateServiceTests
|
|||
LocationId = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
|
||||
|
||||
Assert.Equal("ScheduledRequiresDate", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithScheduledLifecycleWeekOnly_Throws()
|
||||
{
|
||||
var (_, service) = CreateSut();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
LocationId = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
ScheduledDate = new DateTime(2026, 7, 15),
|
||||
ScheduleWeekOnly = true,
|
||||
TargetWeek = new DateOnly(2026, 7, 13),
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
|
||||
|
||||
Assert.Equal("ScheduledRequiresDate", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.Services.Helpers;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
|
@ -50,4 +51,33 @@ public class WorkOrderBoardFieldMutationsTests
|
|||
Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), workOrder.ScheduledStart);
|
||||
Assert.Equal(new DateTime(2026, 6, 25, 11, 0, 0), workOrder.ScheduledEnd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyScheduledDate_IncompleteWithoutAssignee_PromotesToScheduled()
|
||||
{
|
||||
var workOrder = new WorkOrder { LifecycleStatus = LifecycleStatus.Incomplete };
|
||||
|
||||
var changes = WorkOrderBoardFieldMutations.ApplyScheduledDate(
|
||||
workOrder,
|
||||
new DateTime(2026, 6, 25));
|
||||
|
||||
Assert.Equal(LifecycleStatus.Scheduled, workOrder.LifecycleStatus);
|
||||
Assert.Contains(changes, c => c.Action == AuditActionType.StatusChanged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyScheduledDate_ClearsScheduled_DemotesToPending()
|
||||
{
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
ScheduledDate = new DateTime(2026, 6, 25)
|
||||
};
|
||||
|
||||
var changes = WorkOrderBoardFieldMutations.ApplyScheduledDate(workOrder, parsed: null);
|
||||
|
||||
Assert.Equal(LifecycleStatus.Pending, workOrder.LifecycleStatus);
|
||||
Assert.Contains(changes, c => c.Action == AuditActionType.StatusChanged
|
||||
&& c.NewValue == LifecycleStatus.Pending.ToString());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,12 +27,21 @@ public class WorkOrderBoardMutationRulesTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldAutoSchedule_WhenIncompleteWithDateAndAssignee()
|
||||
public void ShouldAutoSchedule_WhenIncompleteWithDate_DoesNotRequireAssignee()
|
||||
{
|
||||
Assert.True(WorkOrderBoardMutationRules.ShouldAutoSchedule(
|
||||
LifecycleStatus.Incomplete,
|
||||
new DateTime(2026, 6, 25),
|
||||
"dispatcher-1"));
|
||||
assignTo: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldAutoSchedule_WhenPendingWithConcreteDate()
|
||||
{
|
||||
Assert.True(WorkOrderBoardMutationRules.ShouldAutoSchedule(
|
||||
LifecycleStatus.Pending,
|
||||
new DateTime(2026, 6, 25),
|
||||
assignTo: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -41,7 +50,7 @@ public class WorkOrderBoardMutationRulesTests
|
|||
Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule(
|
||||
LifecycleStatus.Scheduled,
|
||||
new DateTime(2026, 6, 25),
|
||||
"dispatcher-1"));
|
||||
assignTo: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -50,10 +59,52 @@ public class WorkOrderBoardMutationRulesTests
|
|||
Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule(
|
||||
LifecycleStatus.Incomplete,
|
||||
new DateTime(2026, 6, 25),
|
||||
"dispatcher-1",
|
||||
assignTo: null,
|
||||
scheduleWeekOnly: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldAutoSchedule_FalseWhenInProgress()
|
||||
{
|
||||
Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule(
|
||||
LifecycleStatus.InProgress,
|
||||
new DateTime(2026, 6, 25),
|
||||
assignTo: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldDemoteFromScheduled_WhenDateCleared()
|
||||
{
|
||||
Assert.True(WorkOrderBoardMutationRules.ShouldDemoteFromScheduled(
|
||||
LifecycleStatus.Scheduled,
|
||||
scheduledDate: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldDemoteFromScheduled_WhenWeekOnly()
|
||||
{
|
||||
Assert.True(WorkOrderBoardMutationRules.ShouldDemoteFromScheduled(
|
||||
LifecycleStatus.Scheduled,
|
||||
new DateTime(2026, 6, 25),
|
||||
scheduleWeekOnly: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldDemoteFromScheduled_FalseWhenConcreteDateRemains()
|
||||
{
|
||||
Assert.False(WorkOrderBoardMutationRules.ShouldDemoteFromScheduled(
|
||||
LifecycleStatus.Scheduled,
|
||||
new DateTime(2026, 6, 25)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldDemoteFromScheduled_FalseWhenInProgress()
|
||||
{
|
||||
Assert.False(WorkOrderBoardMutationRules.ShouldDemoteFromScheduled(
|
||||
LifecycleStatus.InProgress,
|
||||
scheduledDate: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanPatchBoardField_AllowsOnlyFlagColorWhenCompleted()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -62,7 +62,6 @@ public class WorkOrderBoardUpdateServiceTests
|
|||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
AssignTo = "user-a",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
|
|
@ -83,6 +82,261 @@ public class WorkOrderBoardUpdateServiceTests
|
|||
Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_PendingWithDate_PromotesToScheduled()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Pending,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ScheduledDate,
|
||||
Value = "2026-06-25",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_ClearsScheduledDate_DemotesToPending()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
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 = null,
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Pending, result.LifecycleStatus);
|
||||
Assert.Null(result.ScheduledDate);
|
||||
var audits = await context.WorkOrderAuditLogs.ToListAsync();
|
||||
Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString());
|
||||
Assert.Contains(audits, a => a.FieldName == "LifecycleStatus" && a.NewValue == LifecycleStatus.Pending.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_ScheduleWeekOnly_DemotesScheduledToPending()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
ScheduleWeekOnly = false,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ScheduleWeekOnly,
|
||||
Value = "true",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Pending, result.LifecycleStatus);
|
||||
Assert.True(result.ScheduleWeekOnly);
|
||||
var audits = await context.WorkOrderAuditLogs.ToListAsync();
|
||||
Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_ScheduleWeekOnlyFalse_PromotesPendingToScheduled()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Pending,
|
||||
ScheduledDate = new DateTime(2026, 12, 15),
|
||||
ScheduleWeekOnly = true,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ScheduleWeekOnly,
|
||||
Value = "false",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
|
||||
Assert.False(result.ScheduleWeekOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_LifecycleStatusScheduledWithoutDate_Throws()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.LifecycleStatus,
|
||||
Value = "Scheduled",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("ScheduledRequiresDate", ex.Code);
|
||||
Assert.Equal(LifecycleStatus.Incomplete, (await context.workOrders.SingleAsync()).LifecycleStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_LifecycleStatusScheduledWithWeekOnly_Throws()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Pending,
|
||||
ScheduledDate = DateTime.UtcNow.Date.AddDays(14),
|
||||
ScheduleWeekOnly = true,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.LifecycleStatus,
|
||||
Value = "Scheduled",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("ScheduledRequiresDate", ex.Code);
|
||||
Assert.Equal(LifecycleStatus.Pending, (await context.workOrders.SingleAsync()).LifecycleStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_LifecycleStatusScheduledWithConcreteDate_Persists()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
ScheduledDate = DateTime.UtcNow.Date.AddDays(14),
|
||||
ScheduleWeekOnly = false,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.LifecycleStatus,
|
||||
Value = "Scheduled",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_LifecycleStatusPendingWithConcreteDate_PromotesToScheduled()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
ScheduledDate = DateTime.UtcNow.Date.AddDays(14),
|
||||
ScheduleWeekOnly = false,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.LifecycleStatus,
|
||||
Value = "Pending",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_InProgress_ClearDate_DoesNotChangeStatus()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.InProgress,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
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 = null,
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.InProgress, result.LifecycleStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_EnRoute_ClearDate_DoesNotChangeStatus()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.EnRoute,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
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 = null,
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.EnRoute, result.LifecycleStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_IncrementsRescheduleCount()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -83,19 +83,48 @@ public class WorkOrderDerivedFieldsTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyAutoSchedule_WhenDateAndAssignee_PromotesToScheduled()
|
||||
public void ApplyAutoSchedule_WhenDateWithoutAssignee_PromotesToScheduled()
|
||||
{
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
AssignTo = "dispatcher-1"
|
||||
ScheduledDate = new DateTime(2026, 6, 25)
|
||||
};
|
||||
|
||||
Assert.True(WorkOrderDerivedFields.ApplyAutoScheduleIfEligible(wo));
|
||||
Assert.Equal(LifecycleStatus.Scheduled, wo.LifecycleStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyLifecycleFromSchedule_PendingWithDate_PromotesToScheduled()
|
||||
{
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
LifecycleStatus = LifecycleStatus.Pending,
|
||||
ScheduledDate = new DateTime(2026, 6, 25)
|
||||
};
|
||||
|
||||
var changes = WorkOrderDerivedFields.ApplyLifecycleFromSchedule(wo);
|
||||
Assert.Equal(LifecycleStatus.Scheduled, wo.LifecycleStatus);
|
||||
Assert.Single(changes);
|
||||
Assert.Equal(AuditActionType.StatusChanged, changes[0].Action);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyLifecycleFromSchedule_ScheduledWithoutDate_DemotesToPending()
|
||||
{
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
ScheduledDate = null
|
||||
};
|
||||
|
||||
var changes = WorkOrderDerivedFields.ApplyLifecycleFromSchedule(wo);
|
||||
Assert.Equal(LifecycleStatus.Pending, wo.LifecycleStatus);
|
||||
Assert.Single(changes);
|
||||
Assert.Equal(LifecycleStatus.Pending.ToString(), changes[0].NewValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyAutoSchedule_WeekOnly_DoesNotPromote()
|
||||
{
|
||||
|
|
@ -103,7 +132,6 @@ public class WorkOrderDerivedFieldsTests
|
|||
{
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
AssignTo = "dispatcher-1",
|
||||
ScheduleWeekOnly = true,
|
||||
TargetWeek = new DateOnly(2026, 6, 22)
|
||||
};
|
||||
|
|
@ -112,13 +140,25 @@ public class WorkOrderDerivedFieldsTests
|
|||
Assert.Equal(LifecycleStatus.Incomplete, wo.LifecycleStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyLifecycleFromSchedule_InProgress_UnchangedWhenDateCleared()
|
||||
{
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
LifecycleStatus = LifecycleStatus.InProgress,
|
||||
ScheduledDate = null
|
||||
};
|
||||
|
||||
Assert.Empty(WorkOrderDerivedFields.ApplyLifecycleFromSchedule(wo));
|
||||
Assert.Equal(LifecycleStatus.InProgress, wo.LifecycleStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetInitialLifecycleStatus_NewWo_StartsIncompleteThenAutoSchedules()
|
||||
{
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
AssignTo = "dispatcher-1"
|
||||
ScheduledDate = new DateTime(2026, 6, 25)
|
||||
};
|
||||
|
||||
WorkOrderDerivedFields.SetInitialLifecycleStatus(wo);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue