Merge pull request #45 from Sea-Haven-Industries/feature/sh-126-wo-addon-isaddon
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions

feat(work-orders): recalculate IsAddOn on schedule set/change/clear (SH-184)
This commit is contained in:
Arthur Bassi 2026-08-18 10:01:23 -03:00 • committed by GitHub
commit 3e2450e5b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1221 additions and 39 deletions

View file

@ -0,0 +1,37 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260730150000_WoIsAddOn")]
public partial class WoIsAddOn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsAddOn",
table: "workOrders",
type: "bit",
nullable: false,
defaultValue: false);
// Legacy WorkOrderType.AddOn (7) → flag; do not remap type (real type unknown).
migrationBuilder.Sql(
"UPDATE workOrders SET IsAddOn = 1 WHERE WorkOrderType = 7;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsAddOn",
table: "workOrders");
}
}
}

View file

@ -0,0 +1,29 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
/// <summary>
/// SH-121 originally added <c>IsAddOn</c> for search facets. Column + legacy backfill
/// now live in <c>20260730150000_WoIsAddOn</c> (SH-184). This migration is a no-op so
/// both PRs can land without a duplicate column.
/// </summary>
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260813193000_SH121_BoardSearchFacets")]
public partial class SH121_BoardSearchFacets : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View file

@ -2458,6 +2458,9 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
b.Property<bool>("IsAddOn")
.HasColumnType("bit");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");

View file

@ -80,6 +80,8 @@ namespace Data.SeaHavenIndustries
// --- Analytics slice ---
public int RescheduleCount { get; set; }
public int CarriedOver { get; set; }
/// <summary>Recalculated on schedule set/change/clear (SH-184); create hint when unscheduled. Board/search facet.</summary>
public bool IsAddOn { get; set; }
public OperationalFlags OperationalFlags { get; set; }
// --- Completion slice ---

View file

@ -18,6 +18,7 @@ namespace SeaHaven.DataServices.Helpers
w.InternalWONumber,
w.RescheduleCount,
w.CarriedOver,
w.IsAddOn,
w.WorkOrderType,
w.SiteCode,
LocationName = w.Locations != null ? w.Locations.Name : null,
@ -99,6 +100,7 @@ namespace SeaHaven.DataServices.Helpers
w.InternalWONumber,
w.RescheduleCount,
w.CarriedOver,
w.IsAddOn,
w.WorkOrderType,
w.SiteCode,
w.LocationName,

View file

@ -66,8 +66,10 @@ namespace SeaHaven.DataServices.Helpers
var today = (utcNow ?? DateTime.UtcNow).Date;
var typeFilters = hasTypes ? types!.ToList() : new List<WorkOrderType>();
var includeAddOnFlag = typeFilters.Contains(WorkOrderType.AddOn);
// Keep past-due predicate inline — EF cannot translate local method calls.
// Types=AddOn also matches IsAddOn (legacy type 7 OR flag on a real type).
if (overdue && !hasTypes)
{
return query.Where(w =>
@ -84,10 +86,13 @@ namespace SeaHaven.DataServices.Helpers
&& w.ScheduledDate.Value.Date < today
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled)
|| (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value)));
|| (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))
|| (includeAddOnFlag && w.IsAddOn));
}
return query.Where(w => w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value));
return query.Where(w =>
(w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))
|| (includeAddOnFlag && w.IsAddOn));
}
public static IQueryable<WorkOrder> ApplyTypeFilter(

View file

@ -0,0 +1,166 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
namespace SeaHaven.DataServices.Helpers
{
/// <summary>
/// SH-121/SH-196 advanced search facets (severity, indicators, flag, internal WO#, uplift).
/// </summary>
public static class WorkOrderBoardSearchFacetFilters
{
private static readonly WorkOrderType[] SeverityEligibleTypes =
{
WorkOrderType.Emergency,
WorkOrderType.Reactive,
};
private static readonly string[] RevokedUpliftStatuses =
{
"Withdrawn",
"Cancelled",
};
public static IQueryable<WorkOrder> ApplySeverityFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<int>? severities)
{
if (severities == null || severities.Count == 0)
return query;
var allowed = severities
.Where(s => s is >= 1 and <= 5)
.Select(s => s.ToString())
.Distinct()
.ToList();
if (allowed.Count == 0)
return query;
return query.Where(w =>
w.WorkOrderType != null
&& SeverityEligibleTypes.Contains(w.WorkOrderType.Value)
&& w.Severity != null
&& allowed.Contains(w.Severity));
}
public static IQueryable<WorkOrder> ApplyRescheduledFilter(
IQueryable<WorkOrder> query,
bool rescheduled)
{
if (!rescheduled)
return query;
return query.Where(w => w.RescheduleCount >= 2);
}
public static IQueryable<WorkOrder> ApplyCarriedOverFilter(
IQueryable<WorkOrder> query,
bool carriedOver)
{
if (!carriedOver)
return query;
return query.Where(w => w.CarriedOver >= 2);
}
public static IQueryable<WorkOrder> ApplyAddOnFilter(
IQueryable<WorkOrder> query,
bool addOn)
{
if (!addOn)
return query;
return query.Where(w => w.IsAddOn);
}
public static IQueryable<WorkOrder> ApplyFlagColorFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<string>? flagColors)
{
if (flagColors == null || flagColors.Count == 0)
return query;
var normalized = flagColors
.Where(c => !string.IsNullOrWhiteSpace(c))
.Select(c => c.Trim().ToUpperInvariant())
.Distinct()
.ToList();
if (normalized.Count == 0)
return query;
return query.Where(w =>
w.FlagColor != null && normalized.Contains(w.FlagColor.ToUpper()));
}
public static IQueryable<WorkOrder> ApplyInternalOnlyFilter(
IQueryable<WorkOrder> query,
bool internalOnly)
{
if (!internalOnly)
return query;
return query.Where(w =>
w.InternalWONumber != null
&& EF.Functions.Like(w.InternalWONumber, "SH%"));
}
public static IQueryable<WorkOrder> ApplyUpliftFilter(
IQueryable<WorkOrder> query,
ApplicationDbContext context,
bool hasUplift,
IReadOnlyList<string>? upliftStatuses)
{
if (!hasUplift)
return query;
var mappedStatuses = MapUpliftStatuses(upliftStatuses);
return query.Where(w => context.DispatchUpliftRequests.Any(u =>
(u.IsDeleted == null || u.IsDeleted == false)
&& u.Status != "Expired"
&& !RevokedUpliftStatuses.Contains(u.Status)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.WorkOrderId == w.Id
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))
&& (mappedStatuses.Count == 0 || mappedStatuses.Contains(u.Status))));
}
internal static List<string> MapUpliftStatuses(IReadOnlyList<string>? upliftStatuses)
{
if (upliftStatuses == null || upliftStatuses.Count == 0)
return new List<string>();
var mapped = new List<string>();
foreach (var raw in upliftStatuses)
{
if (string.IsNullOrWhiteSpace(raw))
continue;
switch (raw.Trim().ToLowerInvariant())
{
case "pending":
mapped.Add("Pending");
mapped.Add("ChangesRequested");
break;
case "approved":
mapped.Add("Approved");
break;
case "auto_approved":
mapped.Add("NoApprovalRequired");
break;
case "rejected":
mapped.Add("Rejected");
mapped.Add("Denied");
break;
}
}
return mapped.Distinct(StringComparer.Ordinal).ToList();
}
}
}

View file

@ -58,6 +58,7 @@ namespace SeaHaven.DataServices.Helpers
|| (w.WorkOrderType == WorkOrderType.Inspection && "inspection".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Reactive && "reactive".Contains(s))
|| (w.WorkOrderType == WorkOrderType.AddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|| (w.IsAddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|| (w.WorkOrderType == WorkOrderType.Other && "other".Contains(s))
|| ("overdue".Contains(s)
&& w.ScheduledDate != null

View file

@ -28,6 +28,14 @@ namespace SeaHaven.DataServices.Implementation
baseQuery = WorkOrderBoardQueryFilters.ApplyPmTypeFilter(baseQuery, query.PmTypes);
baseQuery = WorkOrderBoardQueryFilters.ApplyVendorFilter(baseQuery, query.VendorIds);
baseQuery = WorkOrderBoardQueryFilters.ApplyDocStatusFilter(baseQuery, query.DocStatuses);
baseQuery = WorkOrderBoardSearchFacetFilters.ApplySeverityFilter(baseQuery, query.Severities);
baseQuery = WorkOrderBoardSearchFacetFilters.ApplyRescheduledFilter(baseQuery, query.Rescheduled);
baseQuery = WorkOrderBoardSearchFacetFilters.ApplyCarriedOverFilter(baseQuery, query.CarriedOver);
baseQuery = WorkOrderBoardSearchFacetFilters.ApplyAddOnFilter(baseQuery, query.AddOn);
baseQuery = WorkOrderBoardSearchFacetFilters.ApplyFlagColorFilter(baseQuery, query.FlagColors);
baseQuery = WorkOrderBoardSearchFacetFilters.ApplyInternalOnlyFilter(baseQuery, query.InternalOnly);
baseQuery = WorkOrderBoardSearchFacetFilters.ApplyUpliftFilter(
baseQuery, _context, query.HasUplift, query.UpliftStatuses);
var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search);
if (normalizedSearch != null)

View file

@ -120,7 +120,7 @@ namespace SeaHaven.DataServices.Implementation
var batch = await _context.workOrders
.Where(w => w.IsDeleted != true)
.Where(w => w.istemplate != true)
.Where(w => w.ScheduledDate != null)
.Where(w => w.DueDate != null)
.OrderBy(w => w.Id)
.Skip(skip)
.Take(batchSize)
@ -132,7 +132,7 @@ namespace SeaHaven.DataServices.Implementation
foreach (var workOrder in batch)
{
examinedCount++;
var shouldBePastDue = workOrder.ScheduledDate!.Value.Date < today
var shouldBePastDue = workOrder.DueDate!.Value.Date < today
&& !TerminalStatuses.Contains(workOrder.LifecycleStatus);
var hasFlag = workOrder.OperationalFlags.HasFlag(OperationalFlags.PastDue);

View file

@ -15,6 +15,14 @@ namespace SeaHaven.DataServices.Interfaces
IReadOnlyList<string>? PmTypes,
IReadOnlyList<int>? VendorIds,
IReadOnlyList<DocStatus>? DocStatuses,
IReadOnlyList<int>? Severities,
bool Rescheduled,
bool CarriedOver,
bool AddOn,
IReadOnlyList<string>? FlagColors,
bool InternalOnly,
bool HasUplift,
IReadOnlyList<string>? UpliftStatuses,
bool MyWorkOrders,
string? CurrentUserId,
int Page,

View file

@ -18,6 +18,7 @@ namespace SeaHaven.DataServices.Interfaces
string? InternalWONumber,
int RescheduleCount,
int CarriedOver,
bool IsAddOn,
WorkOrderType? WorkOrderType,
string? SiteCode,
string? LocationName,

View file

@ -27,6 +27,7 @@ namespace SeaHaven.Services.DTOs
public string? WoNumber { get; set; }
public int RescheduleCount { get; set; }
public int CarriedOver { get; set; }
public bool IsAddOn { get; set; }
public WorkOrderType? WorkOrderType { get; set; }
public bool IsPastDue { get; set; }
public string? SiteCode { get; set; }

View file

@ -14,6 +14,11 @@ namespace SeaHaven.Services.DTOs
{
public string? WoNumber { get; set; }
public WorkOrderType WorkOrderType { get; set; }
/// <summary>
/// Manual Add-On override when unscheduled. Ignored when ScheduledDate or TargetWeek is set
/// (server computes from cutoff).
/// </summary>
public bool? IsAddOn { get; set; }
public string? SiteCode { get; set; }
/// <summary>
/// Optional CRM customer name. Required when the caller is org-wide (no account_id)
@ -63,6 +68,14 @@ namespace SeaHaven.Services.DTOs
public List<string>? PmTypes { get; set; }
public List<int>? VendorIds { get; set; }
public List<DocStatus>? DocStatuses { get; set; }
public List<int>? Severities { get; set; }
public bool Rescheduled { get; set; }
public bool CarriedOver { get; set; }
public bool AddOn { get; set; }
public List<string>? FlagColors { get; set; }
public bool InternalOnly { get; set; }
public bool HasUplift { get; set; }
public List<string>? UpliftStatuses { get; set; }
public bool MyWorkOrders { get; set; }
public string? SortBy { get; set; }
public string? SortDir { get; set; }

View file

@ -0,0 +1,77 @@
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Server-side Add-On cutoff: Saturday 23:59:59 UTC of the week prior to the schedule week.
/// SH-184: recalculated whenever scheduling is set, changed, or cleared; audited on change.
/// Create may accept a client hint when unscheduled; schedule present ignores override.
/// </summary>
public static class WorkOrderAddonCutoff
{
/// <summary>
/// Cutoff = Monday-of(anchor) − 2 days, end of day UTC (prior-week Saturday 23:59:59).
/// </summary>
public static DateTime GetAddonCutoffUtc(DateTime anchor)
{
var monday = WorkOrderBoardMutationRules.GetWeekStartMonday(anchor);
var saturday = monday.Date.AddDays(-2);
return DateTime.SpecifyKind(
saturday.AddHours(23).AddMinutes(59).AddSeconds(59),
DateTimeKind.Utc);
}
public static DateTime GetAddonCutoffUtc(DateOnly anchor)
=> GetAddonCutoffUtc(anchor.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc));
/// <summary>
/// With schedule (ScheduledDate or TargetWeek): IsAddOn when createdAtUtc is after cutoff.
/// Without schedule: client <paramref name="manualOverride"/> (default false).
/// </summary>
public static bool ComputeIsAddOnAtCreation(
DateTime createdAtUtc,
DateTime? scheduledDate,
DateOnly? targetWeek,
bool? manualOverride)
{
return ComputeFromScheduleAnchor(createdAtUtc, scheduledDate, targetWeek, manualOverride);
}
/// <summary>
/// SH-184 authoritative recalc after schedule set/change/clear. No manual override on patch.
/// Cleared schedule yields false.
/// </summary>
public static bool RecalculateIsAddOnFromSchedule(
DateTime createdAtUtc,
DateTime? scheduledDate,
DateOnly? targetWeek)
=> ComputeFromScheduleAnchor(createdAtUtc, scheduledDate, targetWeek, manualOverride: false);
private static bool ComputeFromScheduleAnchor(
DateTime createdAtUtc,
DateTime? scheduledDate,
DateOnly? targetWeek,
bool? manualOverride)
{
DateTime? anchor = null;
if (scheduledDate.HasValue)
anchor = scheduledDate.Value;
else if (targetWeek.HasValue)
anchor = targetWeek.Value.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
if (!anchor.HasValue)
return manualOverride ?? false;
var cutoff = GetAddonCutoffUtc(anchor.Value);
var created = NormalizeUtc(createdAtUtc);
return created > cutoff;
}
private static DateTime NormalizeUtc(DateTime value)
{
if (value.Kind == DateTimeKind.Utc)
return value;
if (value.Kind == DateTimeKind.Local)
return value.ToUniversalTime();
return DateTime.SpecifyKind(value, DateTimeKind.Utc);
}
}
}

View file

@ -36,7 +36,7 @@ namespace SeaHaven.Services.Helpers
? DateOnly.FromDateTime(GetWeekStartMonday(scheduledDate.Value))
: null;
private static DateTime GetWeekStartMonday(DateTime date)
public static DateTime GetWeekStartMonday(DateTime date)
{
var diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7;
return date.Date.AddDays(-diff);

View file

@ -1,4 +1,4 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
@ -6,7 +6,8 @@ namespace SeaHaven.Services.Helpers
public static class WorkOrderDerivedFields
{
/// <summary>
/// Past due overlay — never a WorkOrderType. Matches SHOC board design.
/// Past due overlay from Schedule On — never a WorkOrderType. Matches SHOC board design (SH-185).
/// Changing Due Date alone must never trigger or clear it.
/// </summary>
public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null)
{
@ -21,7 +22,7 @@ namespace SeaHaven.Services.Helpers
}
/// <summary>
/// Board read-model past-due check. Phase 1 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.
/// </summary>
public static bool IsPastDue(

View file

@ -51,6 +51,14 @@ namespace SeaHaven.Services.Implementation
query.PmTypes,
query.VendorIds,
query.DocStatuses,
query.Severities,
query.Rescheduled,
query.CarriedOver,
query.AddOn,
query.FlagColors,
query.InternalOnly,
query.HasUplift,
query.UpliftStatuses,
query.MyWorkOrders,
currentUserId,
page,

View file

@ -106,6 +106,12 @@ namespace SeaHaven.Services.Implementation
WorkOrderBoardFieldMutations.SetInitialLifecycleStatus(workOrder);
workOrder.IsAddOn = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
workOrder.CreatedDate!.Value,
request.ScheduledDate,
request.TargetWeek,
request.IsAddOn);
var changes = new List<WorkOrderBoardFieldMutations.BoardFieldChange>
{
WorkOrderBoardFieldMutations.BoardFieldChange.Changed("InternalWONumber", "", woNumber),

View file

@ -93,6 +93,7 @@ namespace SeaHaven.Services.Implementation
WoNumber = row.InternalWONumber,
RescheduleCount = row.RescheduleCount,
CarriedOver = row.CarriedOver,
IsAddOn = row.IsAddOn,
WorkOrderType = row.WorkOrderType,
IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow),
SiteCode = row.SiteCode,

View file

@ -63,7 +63,7 @@ namespace SeaHaven.Services.Implementation
var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.ScheduledDate, workOrder.LifecycleStatus);
if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(canonicalField, isPastDue))
throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Reschedule first.");
throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Update Schedule On first.");
_mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion);
@ -147,8 +147,8 @@ namespace SeaHaven.Services.Implementation
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),
WorkOrderBoardFieldNames.TargetWeek => new List<FieldChange> { ApplyTargetWeek(workOrder, value, auditField) },
WorkOrderBoardFieldNames.ScheduleWeekOnly => new List<FieldChange> { ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly) },
WorkOrderBoardFieldNames.TargetWeek => ApplyTargetWeek(workOrder, value, auditField),
WorkOrderBoardFieldNames.ScheduleWeekOnly => ApplyScheduleWeekOnly(workOrder, value, auditField),
WorkOrderBoardFieldNames.VendorId => new List<FieldChange> { await ApplyVendorIdAsync(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value),
@ -232,7 +232,7 @@ namespace SeaHaven.Services.Implementation
if (string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase))
throw new WorkOrderBoardValidationException(
"InvalidValue",
"Overdue is not a work order type; it is derived from scheduledDate (isPastDue).");
"Overdue is not a work order type; it is derived from dueDate (isPastDue).");
if (!WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
@ -437,10 +437,11 @@ namespace SeaHaven.Services.Implementation
}
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
AppendIsAddOnRecalculation(workOrder, changes);
return changes;
}
private static FieldChange ApplyTargetWeek(WorkOrder workOrder, string? value, string auditField)
private static List<FieldChange> ApplyTargetWeek(WorkOrder workOrder, string? value, string auditField)
{
DateOnly? parsed = null;
if (!string.IsNullOrWhiteSpace(value))
@ -450,13 +451,42 @@ namespace SeaHaven.Services.Implementation
parsed = d;
}
var changes = new List<FieldChange>();
var old = workOrder.TargetWeek?.ToString("yyyy-MM-dd");
var newVal = parsed?.ToString("yyyy-MM-dd");
if (old == newVal)
return FieldChange.Unchanged(auditField);
if (old != newVal)
{
workOrder.TargetWeek = parsed;
changes.Add(FieldChange.ForField(auditField, old, newVal));
}
workOrder.TargetWeek = parsed;
return FieldChange.ForField(auditField, old, newVal);
AppendIsAddOnRecalculation(workOrder, changes);
return changes;
}
private static List<FieldChange> ApplyScheduleWeekOnly(WorkOrder workOrder, string? value, string auditField)
{
var change = ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly);
var changes = new List<FieldChange> { change };
if (change.HasChanged)
AppendIsAddOnRecalculation(workOrder, changes);
return changes;
}
private static void AppendIsAddOnRecalculation(WorkOrder workOrder, List<FieldChange> changes)
{
var createdAt = workOrder.CreatedDate ?? DateTime.UtcNow;
var oldValue = workOrder.IsAddOn.ToString();
var newIsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule(
createdAt,
workOrder.ScheduledDate,
workOrder.TargetWeek);
if (workOrder.IsAddOn == newIsAddOn)
return;
workOrder.IsAddOn = newIsAddOn;
changes.Add(FieldChange.ForField("IsAddOn", oldValue, newIsAddOn.ToString()));
}
private static FieldChange ApplyBoolField(

View file

@ -95,6 +95,7 @@ namespace SeaHaven.Services.Implementation
WoNumber = row.WoNumber,
RescheduleCount = row.RescheduleCount,
CarriedOver = row.CarriedOver,
IsAddOn = row.IsAddOn,
WorkOrderType = row.WorkOrderType,
IsPastDue = row.IsPastDue,
SiteCode = row.SiteCode,

View file

@ -1,4 +1,5 @@
using FluentValidation;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Validation
@ -12,7 +13,9 @@ namespace SeaHaven.Services.Validation
public WorkOrderBoardCreateValidation()
{
RuleFor(x => x.WorkOrderType)
.IsInEnum().WithMessage("workOrderType is required and must be a valid value.");
.IsInEnum().WithMessage("workOrderType is required and must be a valid value.")
.Must(t => t != WorkOrderType.AddOn)
.WithMessage("workOrderType AddOn is not allowed on create; use isAddOn with a real type.");
RuleFor(x => x.SiteCode)
.NotEmpty().WithMessage("siteCode is required.")

View file

@ -0,0 +1,135 @@
using SeaHaven.Services.Helpers;
namespace SeaHavenIndustries.Tests;
public class WorkOrderAddonCutoffTests
{
// Schedule week Mon 2026-06-22 → cutoff Sat 2026-06-20 23:59:59 UTC
private static readonly DateTime ScheduleMonday = new(2026, 6, 22, 0, 0, 0, DateTimeKind.Utc);
private static readonly DateTime ExpectedCutoff = new(2026, 6, 20, 23, 59, 59, DateTimeKind.Utc);
[Fact]
public void GetAddonCutoffUtc_FromScheduledDate_IsPriorSaturdayEndOfDay()
{
// Wednesday in schedule week
var cutoff = WorkOrderAddonCutoff.GetAddonCutoffUtc(new DateTime(2026, 6, 24, 15, 0, 0, DateTimeKind.Utc));
Assert.Equal(ExpectedCutoff, cutoff);
}
[Fact]
public void GetAddonCutoffUtc_FromTargetWeekMonday_IsPriorSaturdayEndOfDay()
{
var cutoff = WorkOrderAddonCutoff.GetAddonCutoffUtc(new DateOnly(2026, 6, 22));
Assert.Equal(ExpectedCutoff, cutoff);
}
[Fact]
public void GetAddonCutoffUtc_FromMondayAnchor_MatchesGetWeekStartMondayMinusTwo()
{
var monday = WorkOrderBoardMutationRules.GetWeekStartMonday(ScheduleMonday);
Assert.Equal(ScheduleMonday.Date, monday.Date);
Assert.Equal(ExpectedCutoff, WorkOrderAddonCutoff.GetAddonCutoffUtc(ScheduleMonday));
}
[Fact]
public void Compute_AfterCutoff_ReturnsTrue_IgnoresManualFalse()
{
var created = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc);
var result = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
created,
scheduledDate: new DateTime(2026, 6, 24),
targetWeek: null,
manualOverride: false);
Assert.True(result);
}
[Fact]
public void Compute_BeforeCutoff_ReturnsFalse_IgnoresManualTrue()
{
var created = new DateTime(2026, 6, 20, 23, 59, 59, DateTimeKind.Utc);
var result = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
created,
scheduledDate: new DateTime(2026, 6, 24),
targetWeek: null,
manualOverride: true);
Assert.False(result);
}
[Fact]
public void Compute_ExactlyAtCutoff_ReturnsFalse()
{
var result = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
ExpectedCutoff,
scheduledDate: ScheduleMonday,
targetWeek: null,
manualOverride: true);
Assert.False(result);
}
[Fact]
public void Compute_TargetWeekOnly_UsesCutoff()
{
var after = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
new DateTime(2026, 6, 21, 12, 0, 0, DateTimeKind.Utc),
scheduledDate: null,
targetWeek: new DateOnly(2026, 6, 22),
manualOverride: false);
var before = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
new DateTime(2026, 6, 19, 12, 0, 0, DateTimeKind.Utc),
scheduledDate: null,
targetWeek: new DateOnly(2026, 6, 22),
manualOverride: true);
Assert.True(after);
Assert.False(before);
}
[Fact]
public void Compute_NoSchedule_UsesManualOverride()
{
Assert.True(WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
DateTime.UtcNow, null, null, manualOverride: true));
Assert.False(WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
DateTime.UtcNow, null, null, manualOverride: false));
Assert.False(WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
DateTime.UtcNow, null, null, manualOverride: null));
}
[Fact]
public void Compute_ScheduledDatePreferredOverTargetWeek()
{
// Target week far future (cutoff after created) would be false;
// scheduled date near past (cutoff before created) → true.
var created = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc);
var result = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
created,
scheduledDate: new DateTime(2026, 6, 24),
targetWeek: new DateOnly(2099, 1, 5),
manualOverride: false);
Assert.True(result);
}
[Fact]
public void RecalculateIsAddOnFromSchedule_NoSchedule_ReturnsFalse()
{
var created = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc);
Assert.False(WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule(created, null, null));
}
[Fact]
public void RecalculateIsAddOnFromSchedule_AfterCutoff_ReturnsTrue()
{
var created = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc);
var result = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule(
created,
scheduledDate: new DateTime(2026, 6, 24),
targetWeek: null);
Assert.True(result);
}
}

View file

@ -141,8 +141,11 @@ public class WorkOrderBoardConcurrencyTests
WorkOrderVersion = Convert.ToBase64String(wo!.RowVersion!)
}, "actor-1");
var auditCount = await context.WorkOrderAuditLogs.CountAsync();
Assert.Equal(2, auditCount);
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.FieldName == "RescheduleCount");
}
[Fact]

View file

@ -351,4 +351,77 @@ public class WorkOrderBoardCreateServiceTests
Assert.Equal("VendorNotFound", ex.Code);
}
[Fact]
public async Task Create_ScheduledAfterCutoff_SetsIsAddOnTrue_KeepsRealType()
{
// UtcNow (test run) is after cutoff for a 2026-06 schedule week.
var (context, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
ScheduledDate = new DateTime(2026, 6, 24),
IsAddOn = false
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.True(result.IsAddOn);
Assert.Equal(WorkOrderType.PM, result.WorkOrderType);
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
Assert.True(wo.IsAddOn);
Assert.Equal(WorkOrderType.PM, wo.WorkOrderType);
}
[Fact]
public async Task Create_ScheduledBeforeCutoff_SetsIsAddOnFalse_IgnoresClientTrue()
{
// Far-future schedule → cutoff after UtcNow → not an add-on.
var (context, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Reactive,
SiteCode = "BK5",
ScheduledDate = new DateTime(2099, 3, 10),
IsAddOn = true
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.False(result.IsAddOn);
Assert.Equal(WorkOrderType.Reactive, result.WorkOrderType);
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
Assert.False(wo.IsAddOn);
}
[Fact]
public async Task Create_NoSchedule_ManualIsAddOnTrue()
{
var (context, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
IsAddOn = true
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.True(result.IsAddOn);
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
Assert.True(wo.IsAddOn);
}
[Fact]
public async Task Create_WorkOrderTypeAddOn_ThrowsValidation()
{
var (_, service) = CreateSut();
var ex = await Assert.ThrowsAsync<FluentValidation.ValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.AddOn,
SiteCode = "BK5"
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
Assert.Contains(ex.Errors, e => e.PropertyName == "WorkOrderType");
}
}

View file

@ -22,6 +22,8 @@ public class WorkOrderBoardMutationRulesTests
WorkOrderBoardFieldNames.LifecycleStatus, isPastDue: true));
Assert.False(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(
WorkOrderBoardFieldNames.ScheduledDate, isPastDue: true));
Assert.False(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(
WorkOrderBoardFieldNames.DueDate, isPastDue: true));
}
[Fact]

View file

@ -342,7 +342,8 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 1,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = today.AddDays(-3),
DueDate = today.AddDays(-3),
ScheduledDate = today.AddDays(5),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
@ -358,7 +359,8 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 3,
WorkOrderType = WorkOrderType.Reactive,
ScheduledDate = today.AddDays(1),
DueDate = today.AddDays(1),
ScheduledDate = today.AddDays(-5),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000003"
});
@ -377,7 +379,7 @@ public class WorkOrderAdvancedSearchServiceTests
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
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);
}
@ -392,6 +394,7 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 1,
WorkOrderType = WorkOrderType.PM,
DueDate = today.AddDays(5),
ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
@ -408,6 +411,7 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 3,
WorkOrderType = WorkOrderType.Reactive,
DueDate = today.AddDays(1),
ScheduledDate = today.AddDays(1),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000003"
@ -444,7 +448,8 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 1,
WorkOrderType = WorkOrderType.Other,
ScheduledDate = today.AddDays(5),
DueDate = today.AddDays(5),
ScheduledDate = today.AddDays(-5),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
@ -452,7 +457,8 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 2,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = today.AddDays(-3),
DueDate = today.AddDays(-3),
ScheduledDate = today.AddDays(5),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
},
@ -460,6 +466,7 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 3,
WorkOrderType = WorkOrderType.Reactive,
DueDate = today.AddDays(-2),
ScheduledDate = today.AddDays(-2),
LifecycleStatus = LifecycleStatus.Completed,
InternalWONumber = "10000000003"
@ -468,6 +475,7 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 4,
WorkOrderType = WorkOrderType.Reactive,
DueDate = today.AddDays(-1),
ScheduledDate = today.AddDays(-1),
LifecycleStatus = LifecycleStatus.Canceled,
InternalWONumber = "10000000004"
@ -490,9 +498,9 @@ public class WorkOrderAdvancedSearchServiceTests
var overdue = Assert.Single(result.Items);
Assert.Equal(0, result.Page);
Assert.Equal(2, overdue.Id);
Assert.Equal(1, overdue.Id);
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 == 4);
}
@ -605,4 +613,220 @@ public class WorkOrderAdvancedSearchServiceTests
Assert.Contains("sortBy", ex.Message);
}
[Fact]
public async Task SearchAsync_TypesAddOn_MatchesLegacyTypeOrIsAddOnFlag()
{
await using var context = CreateContext();
var today = DateTime.UtcNow.Date;
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
WorkOrderType = WorkOrderType.AddOn,
IsAddOn = false,
ScheduledDate = today.AddDays(1),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
WorkOrderType = WorkOrderType.PM,
IsAddOn = true,
ScheduledDate = today.AddDays(2),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
},
new WorkOrder
{
Id = 3,
WorkOrderType = WorkOrderType.Reactive,
IsAddOn = false,
ScheduledDate = today.AddDays(3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000003"
});
await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
dataService,
WorkOrderAccountTestHelpers.Resolver(context));
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Types = new List<WorkOrderType> { WorkOrderType.AddOn }
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(2, result.TotalCount);
Assert.Contains(result.Items, i => i.Id == 1 && i.WorkOrderType == WorkOrderType.AddOn);
Assert.Contains(result.Items, i => i.Id == 2 && i.IsAddOn && i.WorkOrderType == WorkOrderType.PM);
Assert.DoesNotContain(result.Items, i => i.Id == 3);
}
[Fact]
public async Task SearchAsync_SeverityFilter_ReturnsEmergencyReactiveOnly()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
WorkOrderType = WorkOrderType.Emergency,
Severity = "1",
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001",
},
new WorkOrder
{
Id = 2,
WorkOrderType = WorkOrderType.Reactive,
Severity = "2",
ScheduledDate = new DateTime(2026, 6, 24),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002",
},
new WorkOrder
{
Id = 3,
WorkOrderType = WorkOrderType.PM,
Severity = "1",
ScheduledDate = new DateTime(2026, 6, 25),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000003",
});
await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
dataService,
WorkOrderAccountTestHelpers.Resolver(context));
var result = await service.SearchAsync(
new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = weekStart,
DateTo = weekStart.AddDays(6),
Severities = new List<int> { 1 },
},
WorkOrderAccountTestHelpers.OrgWideAdmin(),
null);
Assert.Equal(1, result.TotalCount);
Assert.Equal(1, result.Items.Single().Id);
}
[Fact]
public async Task SearchAsync_InternalOnly_ReturnsShPrefixRows()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
InternalWONumber = "SH-1001",
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
},
new WorkOrder
{
Id = 2,
InternalWONumber = "10000000002",
ScheduledDate = new DateTime(2026, 6, 24),
LifecycleStatus = LifecycleStatus.Scheduled,
});
await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
dataService,
WorkOrderAccountTestHelpers.Resolver(context));
var result = await service.SearchAsync(
new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = weekStart,
DateTo = weekStart.AddDays(6),
InternalOnly = true,
},
WorkOrderAccountTestHelpers.OrgWideAdmin(),
null);
Assert.Equal(1, result.TotalCount);
Assert.Equal("SH-1001", result.Items.Single().WoNumber);
}
[Fact]
public async Task SearchAsync_HasUpliftWithPendingStatus_FiltersRows()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1, WorkOrderId = 1 });
context.Dispatches.Add(new Dispatch { Id = 11, VendorId = 1, WorkOrderId = 2 });
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
},
new WorkOrder
{
Id = 2,
InternalWONumber = "10000000002",
ScheduledDate = new DateTime(2026, 6, 24),
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 11,
});
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = 10,
RequestedNTE = 1500m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending",
});
await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
dataService,
WorkOrderAccountTestHelpers.Resolver(context));
var result = await service.SearchAsync(
new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = weekStart,
DateTo = weekStart.AddDays(6),
HasUplift = true,
UpliftStatuses = new List<string> { "pending" },
},
WorkOrderAccountTestHelpers.OrgWideAdmin(),
null);
Assert.Equal(1, result.TotalCount);
Assert.Equal(1, result.Items.Single().Id);
}
}

View file

@ -391,6 +391,7 @@ public class WorkOrderBoardServiceTests
{
Id = 1,
InternalWONumber = "10000000001",
DueDate = new DateTime(2026, 6, 20),
ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc),
LifecycleStatus = LifecycleStatus.Scheduled
});
@ -411,6 +412,37 @@ public class WorkOrderBoardServiceTests
Assert.Equal("wednesday", response.Scheduled[0].DayGroup);
}
[Fact]
public async Task GetBoardAsync_MapsIsAddOn()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
WorkOrderType = WorkOrderType.PM,
IsAddOn = true,
ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc),
LifecycleStatus = LifecycleStatus.Scheduled
});
await context.SaveChangesAsync();
var boardService = new WorkOrderBoardService(
new WorkOrderBoardDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto
{
WeekStart = weekStart
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Single(response.Scheduled);
Assert.True(response.Scheduled[0].IsAddOn);
Assert.Equal(WorkOrderType.PM, response.Scheduled[0].WorkOrderType);
}
[Fact]
public async Task GetBoardAsync_InvalidWeekWindow_Throws()
{

View file

@ -55,7 +55,11 @@ public class WorkOrderBoardUpdateServiceTests
}, "actor-1");
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]
@ -91,6 +95,7 @@ public class WorkOrderBoardUpdateServiceTests
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
DueDate = DateTime.UtcNow.Date.AddDays(5),
ScheduledDate = DateTime.UtcNow.Date.AddDays(-3),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
@ -106,6 +111,149 @@ public class WorkOrderBoardUpdateServiceTests
}, "actor-1"));
Assert.Equal("PastDueStatusBlocked", ex.Code);
Assert.Contains("Schedule On", ex.Message, StringComparison.Ordinal);
Assert.DoesNotContain("due date", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task PatchField_FutureSchedule_ClearsPastDueAndAllowsStatusChange()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
DueDate = DateTime.UtcNow.Date.AddDays(-3),
ScheduledDate = DateTime.UtcNow.Date.AddDays(-1),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var futureSchedule = DateTime.UtcNow.Date.AddDays(7).ToString("yyyy-MM-dd");
var row = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ScheduledDate,
Value = futureSchedule,
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.False(row.IsPastDue);
Assert.Equal(DateTime.UtcNow.Date.AddDays(7), row.ScheduledDate!.Value.Date);
wo = await context.workOrders.SingleAsync(w => w.Id == 1);
var afterStatus = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Complete",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Completed, afterStatus.LifecycleStatus);
Assert.False(afterStatus.IsPastDue);
}
[Fact]
public async Task PatchField_DueDateAlone_DoesNotClearPastDue()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
DueDate = DateTime.UtcNow.Date.AddDays(-3),
ScheduledDate = DateTime.UtcNow.Date.AddDays(-1),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var futureDue = DateTime.UtcNow.Date.AddDays(10).ToString("yyyy-MM-dd");
var afterDue = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.DueDate,
Value = futureDue,
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.True(afterDue.IsPastDue);
Assert.Equal(DateTime.UtcNow.Date.AddDays(-1), afterDue.ScheduledDate!.Value.Date);
wo = await context.workOrders.SingleAsync(w => w.Id == 1);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Complete",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("PastDueStatusBlocked", ex.Code);
Assert.Contains("Schedule On", ex.Message, StringComparison.Ordinal);
Assert.DoesNotContain("due date", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task PatchField_DueDate_DoesNotChangeScheduledDate()
{
var (context, service) = CreateSut();
var scheduled = new DateTime(2026, 6, 20);
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
DueDate = new DateTime(2026, 6, 18),
ScheduledDate = scheduled,
RescheduleCount = 2,
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.DueDate,
Value = "2026-07-01",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(new DateTime(2026, 7, 1), result.DueDate!.Value.Date);
Assert.Equal(scheduled, result.ScheduledDate);
Assert.Equal(2, result.RescheduleCount);
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal(scheduled, persisted.ScheduledDate);
Assert.Equal(2, persisted.RescheduleCount);
}
[Fact]
public async Task PatchField_ScheduledDate_DoesNotChangeDueDate()
{
var (context, service) = CreateSut();
var due = new DateTime(2026, 6, 18);
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
DueDate = due,
ScheduledDate = new DateTime(2026, 6, 20),
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(new DateTime(2026, 6, 25), result.ScheduledDate!.Value.Date);
Assert.Equal(due, result.DueDate);
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal(due, persisted.DueDate);
}
[Fact]
@ -132,7 +280,7 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal("InvalidValue", ex.Code);
Assert.Contains("isPastDue", ex.Message, StringComparison.Ordinal);
Assert.Contains("scheduledDate", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("dueDate", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
@ -853,4 +1001,93 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Null(result.CompletedDate);
Assert.Null(context.workOrders.Single().CompletedDate);
}
[Fact]
public async Task PatchField_ClearsIsAddOn_WhenScheduleCleared()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = new DateTime(2026, 6, 20),
CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc),
IsAddOn = 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.ScheduledDate,
Value = "",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.False(result.IsAddOn);
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.False(persisted.IsAddOn);
Assert.Single(await context.WorkOrderAuditLogs.Where(a => a.FieldName == "IsAddOn").ToListAsync());
}
[Fact]
public async Task PatchField_ScheduledDate_RecalculatesIsAddOn_AfterCrossWeekMove()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = new DateTime(2026, 6, 10),
CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc),
IsAddOn = 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.ScheduledDate,
Value = "2026-06-25",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.True(result.IsAddOn);
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.True(persisted.IsAddOn);
}
[Fact]
public async Task PatchField_WorkOrderType_DoesNotRecalculateIsAddOn()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.PM,
IsAddOn = 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.WorkOrderType,
Value = "Reactive",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(WorkOrderType.Reactive, result.WorkOrderType);
Assert.True(result.IsAddOn);
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal(WorkOrderType.Reactive, persisted.WorkOrderType);
Assert.True(persisted.IsAddOn);
Assert.Empty(await context.WorkOrderAuditLogs.Where(a => a.FieldName == "IsAddOn").ToListAsync());
}
}

View file

@ -6,28 +6,80 @@ namespace SeaHavenIndustries.Tests;
public class WorkOrderDerivedFieldsTests
{
private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.UtcNow);
private static readonly DateTime Past = DateTime.UtcNow.Date.AddDays(-2);
private static readonly DateTime Future = DateTime.UtcNow.Date.AddDays(3);
private static readonly DateTime TodayDate = DateTime.UtcNow.Date;
[Fact]
public void IsPastDue_WhenScheduledBeforeTodayAndNotTerminal_ReturnsTrue()
public void IsPastDue_WhenSchedulePastAndDueFuture_ReturnsTrue()
{
var wo = new WorkOrder
{
ScheduledDate = DateTime.UtcNow.AddDays(-2),
DueDate = Future,
ScheduledDate = Past,
LifecycleStatus = LifecycleStatus.Scheduled
};
Assert.True(WorkOrderDerivedFields.IsPastDue(wo, DateOnly.FromDateTime(DateTime.UtcNow)));
Assert.True(WorkOrderDerivedFields.IsPastDue(wo, Today));
Assert.True(WorkOrderDerivedFields.IsPastDue(wo.ScheduledDate, wo.LifecycleStatus));
}
[Fact]
public void IsPastDue_WhenCompleted_ReturnsFalse()
public void IsPastDue_WhenScheduleFutureAndDuePast_ReturnsFalse()
{
var wo = new WorkOrder
{
ScheduledDate = DateTime.UtcNow.AddDays(-2),
LifecycleStatus = LifecycleStatus.Completed
DueDate = Past,
ScheduledDate = Future,
LifecycleStatus = LifecycleStatus.Scheduled
};
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, DateOnly.FromDateTime(DateTime.UtcNow)));
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
};
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today));
Assert.False(WorkOrderDerivedFields.IsPastDue(null, LifecycleStatus.Scheduled));
}
[Fact]
public void IsPastDue_WhenScheduleEqualsToday_ReturnsFalse()
{
var wo = new WorkOrder
{
DueDate = Past,
ScheduledDate = TodayDate,
LifecycleStatus = LifecycleStatus.Scheduled
};
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today));
}
[Theory]
[InlineData(LifecycleStatus.Completed)]
[InlineData(LifecycleStatus.Canceled)]
public void IsPastDue_WhenTerminal_ReturnsFalse(LifecycleStatus status)
{
var wo = new WorkOrder
{
DueDate = Past,
ScheduledDate = Past,
LifecycleStatus = status
};
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today));
Assert.False(WorkOrderDerivedFields.IsPastDue(Past, status));
}
[Fact]

View file

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

View file

@ -240,12 +240,14 @@ public class WorkOrderWeekRolledTests
new WorkOrder
{
Id = 1,
DueDate = pastDate,
ScheduledDate = pastDate,
LifecycleStatus = LifecycleStatus.Scheduled
},
new WorkOrder
{
Id = 2,
DueDate = pastDate,
ScheduledDate = pastDate,
LifecycleStatus = LifecycleStatus.Completed,
OperationalFlags = OperationalFlags.PastDue
@ -253,6 +255,7 @@ public class WorkOrderWeekRolledTests
new WorkOrder
{
Id = 3,
DueDate = futureDate,
ScheduledDate = futureDate,
LifecycleStatus = LifecycleStatus.Scheduled,
OperationalFlags = OperationalFlags.PastDue
@ -307,6 +310,7 @@ public class WorkOrderWeekRolledTests
context.workOrders.Add(new WorkOrder
{
Id = 1,
DueDate = DateTime.UtcNow.Date.AddDays(5),
ScheduledDate = pastDate,
LifecycleStatus = LifecycleStatus.Scheduled,
OperationalFlags = OperationalFlags.None
@ -319,7 +323,7 @@ public class WorkOrderWeekRolledTests
Assert.NotNull(row);
Assert.True(row!.IsPastDue);
Assert.False(WorkOrderDerivedFields.IsPastDue(pastDate, LifecycleStatus.Scheduled) == false);
Assert.True(WorkOrderDerivedFields.IsPastDue(pastDate, LifecycleStatus.Scheduled));
}
[Fact]
@ -331,6 +335,7 @@ public class WorkOrderWeekRolledTests
context.workOrders.Add(new WorkOrder
{
Id = 1,
DueDate = DateTime.UtcNow.Date.AddDays(-3),
ScheduledDate = futureDate,
LifecycleStatus = LifecycleStatus.Scheduled,
OperationalFlags = OperationalFlags.PastDue

View file

@ -88,10 +88,13 @@ Lista opções para o filtro/dropdown de assignee do board.
### isPastDue
```
ScheduledDate < UTC hoje
AND LifecycleStatus NOT IN (Complete, Canceled, Closed)
DueDate != null
AND DueDate < UTC hoje
AND LifecycleStatus NOT IN (Completed, Canceled)
```
`DueDate` null → `isPastDue = false`. Independent of `ScheduledDate` (Schedule On).
Implementação: `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs`
### Vendor / Appt
@ -154,6 +157,18 @@ Endpoints legados (`GetWorkOrderList`, `GetWorkorderById`, etc.) **não foram al
---
## Add-On indicator (SH-184)
- Coluna `IsAddOn` persistida; legacy `WorkOrderType.AddOn` (7) backfilled.
- **Create:** cutoff preview (scheduled) ou hint manual quando unscheduled (`request.IsAddOn`).
- **Patch schedule** (`scheduledDate`, `targetWeek`, `scheduleWeekOnly`): servidor **recalcula** `IsAddOn` e audita mudanças.
- **Schedule cleared:** `IsAddOn = false`.
- Search `Types=AddOn` matches legacy type 7 **or** `IsAddOn`.
Contrato FE: [shoc-frontend-new PR #102](https://github.com/Sea-Haven-Industries/shoc-frontend-new/pull/102).
---
## Próximo passo
**Fase 2** — Inline edit + concorrência (dual RowVersion, audit por campo, PATCH granular).