Compare commits

..

10 commits

Author SHA1 Message Date
Arthur Bassi
3e2450e5b6
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)
2026-08-18 10:01:23 -03:00
Arthur Bassi
4760f3fdd7 fix(work-orders): name Schedule On in PastDueStatusBlocked and register SH-121 no-op
The 422 still told dispatchers to update Due Date. Point the remedy at Schedule On and make the SH-121 successor visible to EF so G6 lineage is complete.
2026-08-18 09:19:27 -03:00
Arthur Bassi
c9a80f5c79 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.
2026-08-17 10:36:03 -03:00
Arthur Bassi
fa05b22df6 chore(work-orders): merge SH-121 facets and keep SH-184 IsAddOn migration 2026-08-17 10:28:03 -03:00
Arthur Bassi
17c2e968cd feat(work-orders): board search facets for SH-121/SH-196 2026-08-13 16:34:21 -03:00
arthur.bassi
1283bf7349 fix(tests): align IsAddOn board tests with account-scoped dev APIs
Pass ClaimsPrincipal and account resolver args required after rebase onto dev.
2026-08-13 13:45:25 -03:00
arthur.bassi
633d80cbc9 feat(work-orders): recalculate IsAddOn on schedule set/change/clear (SH-184)
Replace frozen-at-create behavior with authoritative server recalc and audit
on schedule patches; document cross-repo contract with FE PR #102.
2026-08-13 13:35:28 -03:00
Arthur Bassi
97e042f552 feat(work-orders): persist IsAddOn frozen at create (SH-126)
Add set-once IsAddOn with server cutoff at create, board DTO exposure, legacy type-7 backfill, and Types=AddOn search compat. Aligns with FE PR #61 frozen contract.
2026-08-13 13:30:09 -03:00
Arthur Bassi
f7ce2ee25d fix(work-orders): align Overdue type rejection test with dueDate message 2026-08-13 13:28:25 -03:00
Arthur Bassi
05dd262e80 fix(work-orders): derive Past Due from DueDate instead of ScheduledDate
Past Due must track the deadline (Due Date), not Schedule On. Keep dueDate and scheduledDate PATCH mutations independent so rescheduling alone does not clear Past Due.
2026-08-13 13:28:25 -03:00
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).