mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
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.
This commit is contained in:
parent
f7ce2ee25d
commit
97e042f552
21 changed files with 566 additions and 11 deletions
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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.</summary>
|
||||
public bool IsAddOn { get; set; }
|
||||
public OperationalFlags OperationalFlags { get; set; }
|
||||
|
||||
// --- Completion slice ---
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.DueDate.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(
|
||||
|
|
|
|||
|
|
@ -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.DueDate != null
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
string? InternalWONumber,
|
||||
int RescheduleCount,
|
||||
int CarriedOver,
|
||||
bool IsAddOn,
|
||||
WorkOrderType? WorkOrderType,
|
||||
string? SiteCode,
|
||||
string? LocationName,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
77
SeaHaven.Services/Helpers/WorkOrderAddonCutoff.cs
Normal file
77
SeaHaven.Services/Helpers/WorkOrderAddonCutoff.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.DueDate, row.LifecycleStatus, utcNow),
|
||||
SiteCode = row.SiteCode,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
135
SeaHavenIndustries.Tests/WorkOrderAddonCutoffTests.cs
Normal file
135
SeaHavenIndustries.Tests/WorkOrderAddonCutoffTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}, "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
|
||||
}, "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
|
||||
}, "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"
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Contains(ex.Errors, e => e.PropertyName == "WorkOrderType");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -613,4 +613,58 @@ 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);
|
||||
|
||||
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 }
|
||||
}, 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -412,6 +412,35 @@ 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));
|
||||
var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto
|
||||
{
|
||||
WeekStart = weekStart
|
||||
}, 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -993,4 +993,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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue