fix(work-orders): restore SH-185 Schedule On Past Due and assert IsAddOn audit

Past Due follows ScheduledDate so Due Date alone cannot set or clear it. Auto-schedule and reschedule tests now expect the third IsAddOn audit field.
This commit is contained in:
Arthur Bassi 2026-08-17 10:36:03 -03:00
parent fa05b22df6
commit c9a80f5c79
11 changed files with 83 additions and 75 deletions

View file

@ -73,8 +73,8 @@ namespace SeaHaven.DataServices.Helpers
if (overdue && !hasTypes) if (overdue && !hasTypes)
{ {
return query.Where(w => return query.Where(w =>
w.DueDate != null w.ScheduledDate != null
&& w.DueDate.Value.Date < today && w.ScheduledDate.Value.Date < today
&& w.LifecycleStatus != LifecycleStatus.Completed && w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled); && w.LifecycleStatus != LifecycleStatus.Canceled);
} }
@ -82,8 +82,8 @@ namespace SeaHaven.DataServices.Helpers
if (overdue) if (overdue)
{ {
return query.Where(w => return query.Where(w =>
(w.DueDate != null (w.ScheduledDate != null
&& w.DueDate.Value.Date < today && w.ScheduledDate.Value.Date < today
&& w.LifecycleStatus != LifecycleStatus.Completed && w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled) && w.LifecycleStatus != LifecycleStatus.Canceled)
|| (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value)) || (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))

View file

@ -61,8 +61,8 @@ namespace SeaHaven.DataServices.Helpers
|| (w.IsAddOn && ("add-on".Contains(s) || "addon".Contains(s))) || (w.IsAddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|| (w.WorkOrderType == WorkOrderType.Other && "other".Contains(s)) || (w.WorkOrderType == WorkOrderType.Other && "other".Contains(s))
|| ("overdue".Contains(s) || ("overdue".Contains(s)
&& w.DueDate != null && w.ScheduledDate != null
&& w.DueDate.Value.Date < today && w.ScheduledDate.Value.Date < today
&& w.LifecycleStatus != LifecycleStatus.Completed && w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled) && w.LifecycleStatus != LifecycleStatus.Canceled)
|| (w.LifecycleStatus == LifecycleStatus.Incomplete && "incomplete".Contains(s)) || (w.LifecycleStatus == LifecycleStatus.Incomplete && "incomplete".Contains(s))

View file

@ -1,4 +1,4 @@
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers namespace SeaHaven.Services.Helpers
@ -6,38 +6,38 @@ namespace SeaHaven.Services.Helpers
public static class WorkOrderDerivedFields public static class WorkOrderDerivedFields
{ {
/// <summary> /// <summary>
/// Past due overlay from DueDate (deadline) — never a WorkOrderType. /// Past due overlay from Schedule On — never a WorkOrderType. Matches SHOC board design (SH-185).
/// Independent of ScheduledDate (planned work / Schedule On). /// Changing Due Date alone must never trigger or clear it.
/// </summary> /// </summary>
public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null) public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null)
{ {
var today = asOf ?? DateOnly.FromDateTime(DateTime.UtcNow); var today = asOf ?? DateOnly.FromDateTime(DateTime.UtcNow);
if (workOrder.DueDate == null) if (workOrder.ScheduledDate == null)
return false; return false;
if (LifecycleStatusMapper.IsTerminal(workOrder.LifecycleStatus)) if (LifecycleStatusMapper.IsTerminal(workOrder.LifecycleStatus))
return false; return false;
return DateOnly.FromDateTime(workOrder.DueDate.Value.Date) < today; return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today;
} }
/// <summary> /// <summary>
/// Board read-model past-due check against DueDate. Compares UTC calendar dates; /// Board read-model past-due check against ScheduledDate. Compares UTC calendar dates;
/// business/location timezone support is planned for a later phase. /// business/location timezone support is planned for a later phase.
/// </summary> /// </summary>
public static bool IsPastDue( public static bool IsPastDue(
DateTime? dueDate, DateTime? scheduledDate,
LifecycleStatus? lifecycleStatus, LifecycleStatus? lifecycleStatus,
DateTime? utcNow = null) DateTime? utcNow = null)
{ {
if (!dueDate.HasValue) if (!scheduledDate.HasValue)
return false; return false;
if (LifecycleStatusMapper.IsTerminal(lifecycleStatus)) if (LifecycleStatusMapper.IsTerminal(lifecycleStatus))
return false; return false;
var today = (utcNow ?? DateTime.UtcNow).Date; var today = (utcNow ?? DateTime.UtcNow).Date;
return dueDate.Value.Date < today; return scheduledDate.Value.Date < today;
} }
/// <summary> /// <summary>

View file

@ -95,7 +95,7 @@ namespace SeaHaven.Services.Implementation
CarriedOver = row.CarriedOver, CarriedOver = row.CarriedOver,
IsAddOn = row.IsAddOn, IsAddOn = row.IsAddOn,
WorkOrderType = row.WorkOrderType, WorkOrderType = row.WorkOrderType,
IsPastDue = WorkOrderDerivedFields.IsPastDue(row.DueDate, row.LifecycleStatus, utcNow), IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow),
SiteCode = row.SiteCode, SiteCode = row.SiteCode,
LocationName = row.LocationName, LocationName = row.LocationName,
PocName = row.PocName, PocName = row.PocName,

View file

@ -61,7 +61,7 @@ namespace SeaHaven.Services.Implementation
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("CanceledReadOnly", "Work order is read-only in its current status."); throw new WorkOrderBoardValidationException("CanceledReadOnly", "Work order is read-only in its current status.");
var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.DueDate, workOrder.LifecycleStatus); var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.ScheduledDate, workOrder.LifecycleStatus);
if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(canonicalField, isPastDue)) if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(canonicalField, isPastDue))
throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Update due date first."); throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Update due date first.");

View file

@ -141,8 +141,11 @@ public class WorkOrderBoardConcurrencyTests
WorkOrderVersion = Convert.ToBase64String(wo!.RowVersion!) WorkOrderVersion = Convert.ToBase64String(wo!.RowVersion!)
}, "actor-1"); }, "actor-1");
var auditCount = await context.WorkOrderAuditLogs.CountAsync(); var audits = await context.WorkOrderAuditLogs.ToListAsync();
Assert.Equal(2, auditCount); Assert.Equal(3, audits.Count);
Assert.Contains(audits, a => a.FieldName == "IsAddOn");
Assert.Contains(audits, a => a.FieldName == "ScheduledDate");
Assert.Contains(audits, a => a.FieldName == "RescheduleCount");
} }
[Fact] [Fact]

View file

@ -379,7 +379,7 @@ public class WorkOrderAdvancedSearchServiceTests
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(1, result.TotalCount); Assert.Equal(1, result.TotalCount);
Assert.Equal(1, result.Items.First().Id); Assert.Equal(3, result.Items.First().Id);
Assert.True(result.Items.First().IsPastDue); Assert.True(result.Items.First().IsPastDue);
} }
@ -394,8 +394,8 @@ public class WorkOrderAdvancedSearchServiceTests
{ {
Id = 1, Id = 1,
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
DueDate = today.AddDays(-3), DueDate = today.AddDays(5),
ScheduledDate = today.AddDays(5), ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled, LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001" InternalWONumber = "10000000001"
}, },
@ -498,9 +498,9 @@ public class WorkOrderAdvancedSearchServiceTests
var overdue = Assert.Single(result.Items); var overdue = Assert.Single(result.Items);
Assert.Equal(0, result.Page); Assert.Equal(0, result.Page);
Assert.Equal(2, overdue.Id); Assert.Equal(1, overdue.Id);
Assert.True(overdue.IsPastDue); Assert.True(overdue.IsPastDue);
Assert.DoesNotContain(result.Items, i => i.Id == 1); Assert.DoesNotContain(result.Items, i => i.Id == 2);
Assert.DoesNotContain(result.Items, i => i.Id == 3); Assert.DoesNotContain(result.Items, i => i.Id == 3);
Assert.DoesNotContain(result.Items, i => i.Id == 4); Assert.DoesNotContain(result.Items, i => i.Id == 4);
} }

View file

@ -55,7 +55,11 @@ public class WorkOrderBoardUpdateServiceTests
}, "actor-1"); }, "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus); Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
Assert.Equal(2, await context.WorkOrderAuditLogs.CountAsync()); var audits = await context.WorkOrderAuditLogs.ToListAsync();
Assert.Equal(3, audits.Count);
Assert.Contains(audits, a => a.FieldName == "IsAddOn");
Assert.Contains(audits, a => a.FieldName == "ScheduledDate");
Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString());
} }
[Fact] [Fact]
@ -91,8 +95,8 @@ public class WorkOrderBoardUpdateServiceTests
{ {
Id = 1, Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled, LifecycleStatus = LifecycleStatus.Scheduled,
DueDate = DateTime.UtcNow.Date.AddDays(-3), DueDate = DateTime.UtcNow.Date.AddDays(5),
ScheduledDate = DateTime.UtcNow.Date.AddDays(5), ScheduledDate = DateTime.UtcNow.Date.AddDays(-3),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
}; };
context.workOrders.Add(wo); context.workOrders.Add(wo);
@ -110,7 +114,7 @@ public class WorkOrderBoardUpdateServiceTests
} }
[Fact] [Fact]
public async Task PatchField_RescheduleAlone_DoesNotClearPastDueBlock() public async Task PatchField_FutureSchedule_ClearsPastDueAndAllowsStatusChange()
{ {
var (context, service) = CreateSut(); var (context, service) = CreateSut();
var wo = new WorkOrder var wo = new WorkOrder
@ -132,23 +136,23 @@ public class WorkOrderBoardUpdateServiceTests
WorkOrderVersion = ToVersion(wo) WorkOrderVersion = ToVersion(wo)
}, "actor-1"); }, "actor-1");
Assert.True(row.IsPastDue); Assert.False(row.IsPastDue);
Assert.Equal(DateTime.UtcNow.Date.AddDays(7), row.ScheduledDate!.Value.Date); Assert.Equal(DateTime.UtcNow.Date.AddDays(7), row.ScheduledDate!.Value.Date);
wo = await context.workOrders.SingleAsync(w => w.Id == 1); wo = await context.workOrders.SingleAsync(w => w.Id == 1);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => var afterStatus = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto {
{ Field = WorkOrderBoardFieldNames.LifecycleStatus,
Field = WorkOrderBoardFieldNames.LifecycleStatus, Value = "Complete",
Value = "Complete", WorkOrderVersion = ToVersion(wo)
WorkOrderVersion = ToVersion(wo) }, "actor-1");
}, "actor-1"));
Assert.Equal("PastDueStatusBlocked", ex.Code); Assert.Equal(LifecycleStatus.Completed, afterStatus.LifecycleStatus);
Assert.False(afterStatus.IsPastDue);
} }
[Fact] [Fact]
public async Task PatchField_FutureDueDate_ClearsPastDueAndAllowsStatusChange() public async Task PatchField_DueDateAlone_DoesNotClearPastDue()
{ {
var (context, service) = CreateSut(); var (context, service) = CreateSut();
var wo = new WorkOrder var wo = new WorkOrder
@ -170,19 +174,19 @@ public class WorkOrderBoardUpdateServiceTests
WorkOrderVersion = ToVersion(wo) WorkOrderVersion = ToVersion(wo)
}, "actor-1"); }, "actor-1");
Assert.False(afterDue.IsPastDue); Assert.True(afterDue.IsPastDue);
Assert.Equal(DateTime.UtcNow.Date.AddDays(-1), afterDue.ScheduledDate!.Value.Date); Assert.Equal(DateTime.UtcNow.Date.AddDays(-1), afterDue.ScheduledDate!.Value.Date);
wo = await context.workOrders.SingleAsync(w => w.Id == 1); wo = await context.workOrders.SingleAsync(w => w.Id == 1);
var afterStatus = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
{ service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
Field = WorkOrderBoardFieldNames.LifecycleStatus, {
Value = "Complete", Field = WorkOrderBoardFieldNames.LifecycleStatus,
WorkOrderVersion = ToVersion(wo) Value = "Complete",
}, "actor-1"); WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal(LifecycleStatus.Completed, afterStatus.LifecycleStatus); Assert.Equal("PastDueStatusBlocked", ex.Code);
Assert.False(afterStatus.IsPastDue);
} }
[Fact] [Fact]

View file

@ -12,21 +12,7 @@ public class WorkOrderDerivedFieldsTests
private static readonly DateTime TodayDate = DateTime.UtcNow.Date; private static readonly DateTime TodayDate = DateTime.UtcNow.Date;
[Fact] [Fact]
public void IsPastDue_WhenDuePastAndScheduleFuture_ReturnsTrue() public void IsPastDue_WhenSchedulePastAndDueFuture_ReturnsTrue()
{
var wo = new WorkOrder
{
DueDate = Past,
ScheduledDate = Future,
LifecycleStatus = LifecycleStatus.Scheduled
};
Assert.True(WorkOrderDerivedFields.IsPastDue(wo, Today));
Assert.True(WorkOrderDerivedFields.IsPastDue(wo.DueDate, wo.LifecycleStatus));
}
[Fact]
public void IsPastDue_WhenDueFutureAndSchedulePast_ReturnsFalse()
{ {
var wo = new WorkOrder var wo = new WorkOrder
{ {
@ -35,16 +21,31 @@ public class WorkOrderDerivedFieldsTests
LifecycleStatus = LifecycleStatus.Scheduled LifecycleStatus = LifecycleStatus.Scheduled
}; };
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today)); Assert.True(WorkOrderDerivedFields.IsPastDue(wo, Today));
Assert.True(WorkOrderDerivedFields.IsPastDue(wo.ScheduledDate, wo.LifecycleStatus));
} }
[Fact] [Fact]
public void IsPastDue_WhenDueDateNull_ReturnsFalse() public void IsPastDue_WhenScheduleFutureAndDuePast_ReturnsFalse()
{ {
var wo = new WorkOrder var wo = new WorkOrder
{ {
DueDate = null, DueDate = Past,
ScheduledDate = Past, ScheduledDate = Future,
LifecycleStatus = LifecycleStatus.Scheduled
};
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today));
Assert.False(WorkOrderDerivedFields.IsPastDue(wo.ScheduledDate, wo.LifecycleStatus));
}
[Fact]
public void IsPastDue_WhenScheduledDateNull_ReturnsFalse()
{
var wo = new WorkOrder
{
DueDate = Past,
ScheduledDate = null,
LifecycleStatus = LifecycleStatus.Scheduled LifecycleStatus = LifecycleStatus.Scheduled
}; };
@ -53,12 +54,12 @@ public class WorkOrderDerivedFieldsTests
} }
[Fact] [Fact]
public void IsPastDue_WhenDueEqualsToday_ReturnsFalse() public void IsPastDue_WhenScheduleEqualsToday_ReturnsFalse()
{ {
var wo = new WorkOrder var wo = new WorkOrder
{ {
DueDate = TodayDate, DueDate = Past,
ScheduledDate = Past, ScheduledDate = TodayDate,
LifecycleStatus = LifecycleStatus.Scheduled LifecycleStatus = LifecycleStatus.Scheduled
}; };

View file

@ -111,8 +111,8 @@ public class WorkOrderDetailServiceTests
Id = 1, Id = 1,
InternalWONumber = "00000000001", InternalWONumber = "00000000001",
LifecycleStatus = LifecycleStatus.Scheduled, LifecycleStatus = LifecycleStatus.Scheduled,
DueDate = DateTime.UtcNow.AddDays(-2), DueDate = DateTime.UtcNow.AddDays(3),
ScheduledDate = DateTime.UtcNow.AddDays(3), ScheduledDate = DateTime.UtcNow.AddDays(-2),
Trade = "HVAC PM", Trade = "HVAC PM",
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
DocStatus = DocStatus.No, DocStatus = DocStatus.No,

View file

@ -310,8 +310,8 @@ public class WorkOrderWeekRolledTests
context.workOrders.Add(new WorkOrder context.workOrders.Add(new WorkOrder
{ {
Id = 1, Id = 1,
DueDate = pastDate, DueDate = DateTime.UtcNow.Date.AddDays(5),
ScheduledDate = DateTime.UtcNow.Date.AddDays(5), ScheduledDate = pastDate,
LifecycleStatus = LifecycleStatus.Scheduled, LifecycleStatus = LifecycleStatus.Scheduled,
OperationalFlags = OperationalFlags.None OperationalFlags = OperationalFlags.None
}); });
@ -335,8 +335,8 @@ public class WorkOrderWeekRolledTests
context.workOrders.Add(new WorkOrder context.workOrders.Add(new WorkOrder
{ {
Id = 1, Id = 1,
DueDate = futureDate, DueDate = DateTime.UtcNow.Date.AddDays(-3),
ScheduledDate = DateTime.UtcNow.Date.AddDays(-3), ScheduledDate = futureDate,
LifecycleStatus = LifecycleStatus.Scheduled, LifecycleStatus = LifecycleStatus.Scheduled,
OperationalFlags = OperationalFlags.PastDue OperationalFlags = OperationalFlags.PastDue
}); });