diff --git a/Data.SeaHavenIndustries/Migrations/20260730150000_WoIsAddOn.cs b/Data.SeaHavenIndustries/Migrations/20260730150000_WoIsAddOn.cs new file mode 100644 index 0000000..92484c0 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260730150000_WoIsAddOn.cs @@ -0,0 +1,37 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260730150000_WoIsAddOn")] + public partial class WoIsAddOn : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + 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;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsAddOn", + table: "workOrders"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs b/Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs new file mode 100644 index 0000000..c5fe2cd --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs @@ -0,0 +1,29 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + /// + /// SH-121 originally added IsAddOn for search facets. Column + legacy backfill + /// now live in 20260730150000_WoIsAddOn (SH-184). This migration is a no-op so + /// both PRs can land without a duplicate column. + /// + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260813193000_SH121_BoardSearchFacets")] + public partial class SH121_BoardSearchFacets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 813853c..e938918 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2458,6 +2458,9 @@ namespace Data.SeaHavenIndustries.Migrations .HasMaxLength(11) .HasColumnType("nvarchar(11)"); + b.Property("IsAddOn") + .HasColumnType("bit"); + b.Property("IsDeleted") .HasColumnType("bit"); diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 59f2390..b2d1989 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -80,6 +80,8 @@ namespace Data.SeaHavenIndustries // --- Analytics slice --- public int RescheduleCount { get; set; } public int CarriedOver { get; set; } + /// Recalculated on schedule set/change/clear (SH-184); create hint when unscheduled. Board/search facet. + public bool IsAddOn { get; set; } public OperationalFlags OperationalFlags { get; set; } // --- Completion slice --- diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index 3d43fe3..2b31222 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -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, diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index ba41941..44f965b 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -66,8 +66,10 @@ namespace SeaHaven.DataServices.Helpers var today = (utcNow ?? DateTime.UtcNow).Date; var typeFilters = hasTypes ? types!.ToList() : new List(); + 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 ApplyTypeFilter( diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs new file mode 100644 index 0000000..08b2c2d --- /dev/null +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs @@ -0,0 +1,166 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; + +namespace SeaHaven.DataServices.Helpers +{ + /// + /// SH-121/SH-196 advanced search facets (severity, indicators, flag, internal WO#, uplift). + /// + public static class WorkOrderBoardSearchFacetFilters + { + private static readonly WorkOrderType[] SeverityEligibleTypes = + { + WorkOrderType.Emergency, + WorkOrderType.Reactive, + }; + + private static readonly string[] RevokedUpliftStatuses = + { + "Withdrawn", + "Cancelled", + }; + + public static IQueryable ApplySeverityFilter( + IQueryable query, + IReadOnlyList? 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 ApplyRescheduledFilter( + IQueryable query, + bool rescheduled) + { + if (!rescheduled) + return query; + + return query.Where(w => w.RescheduleCount >= 2); + } + + public static IQueryable ApplyCarriedOverFilter( + IQueryable query, + bool carriedOver) + { + if (!carriedOver) + return query; + + return query.Where(w => w.CarriedOver >= 2); + } + + public static IQueryable ApplyAddOnFilter( + IQueryable query, + bool addOn) + { + if (!addOn) + return query; + + return query.Where(w => w.IsAddOn); + } + + public static IQueryable ApplyFlagColorFilter( + IQueryable query, + IReadOnlyList? 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 ApplyInternalOnlyFilter( + IQueryable query, + bool internalOnly) + { + if (!internalOnly) + return query; + + return query.Where(w => + w.InternalWONumber != null + && EF.Functions.Like(w.InternalWONumber, "SH%")); + } + + public static IQueryable ApplyUpliftFilter( + IQueryable query, + ApplicationDbContext context, + bool hasUplift, + IReadOnlyList? 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 MapUpliftStatuses(IReadOnlyList? upliftStatuses) + { + if (upliftStatuses == null || upliftStatuses.Count == 0) + return new List(); + + var mapped = new List(); + 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(); + } + } + +} diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs index d6cf28d..441802b 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs @@ -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 diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs index de1f4d2..1d4892d 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs @@ -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) diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs index 65d98bc..ca15639 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs @@ -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); diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs index d1ef77f..b965e86 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs @@ -15,6 +15,14 @@ namespace SeaHaven.DataServices.Interfaces IReadOnlyList? PmTypes, IReadOnlyList? VendorIds, IReadOnlyList? DocStatuses, + IReadOnlyList? Severities, + bool Rescheduled, + bool CarriedOver, + bool AddOn, + IReadOnlyList? FlagColors, + bool InternalOnly, + bool HasUplift, + IReadOnlyList? UpliftStatuses, bool MyWorkOrders, string? CurrentUserId, int Page, diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index ca27ca0..ad46340 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -18,6 +18,7 @@ namespace SeaHaven.DataServices.Interfaces string? InternalWONumber, int RescheduleCount, int CarriedOver, + bool IsAddOn, WorkOrderType? WorkOrderType, string? SiteCode, string? LocationName, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index de3fe67..1a2c9c2 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -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; } diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 3ca672c..0f7d976 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -14,6 +14,11 @@ namespace SeaHaven.Services.DTOs { public string? WoNumber { get; set; } public WorkOrderType WorkOrderType { get; set; } + /// + /// Manual Add-On override when unscheduled. Ignored when ScheduledDate or TargetWeek is set + /// (server computes from cutoff). + /// + public bool? IsAddOn { get; set; } public string? SiteCode { get; set; } /// /// Optional CRM customer name. Required when the caller is org-wide (no account_id) @@ -63,6 +68,14 @@ namespace SeaHaven.Services.DTOs public List? PmTypes { get; set; } public List? VendorIds { get; set; } public List? DocStatuses { get; set; } + public List? Severities { get; set; } + public bool Rescheduled { get; set; } + public bool CarriedOver { get; set; } + public bool AddOn { get; set; } + public List? FlagColors { get; set; } + public bool InternalOnly { get; set; } + public bool HasUplift { get; set; } + public List? UpliftStatuses { get; set; } public bool MyWorkOrders { get; set; } public string? SortBy { get; set; } public string? SortDir { get; set; } diff --git a/SeaHaven.Services/Helpers/WorkOrderAddonCutoff.cs b/SeaHaven.Services/Helpers/WorkOrderAddonCutoff.cs new file mode 100644 index 0000000..db81e62 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderAddonCutoff.cs @@ -0,0 +1,77 @@ +namespace SeaHaven.Services.Helpers +{ + /// + /// 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. + /// + public static class WorkOrderAddonCutoff + { + /// + /// Cutoff = Monday-of(anchor) − 2 days, end of day UTC (prior-week Saturday 23:59:59). + /// + 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)); + + /// + /// With schedule (ScheduledDate or TargetWeek): IsAddOn when createdAtUtc is after cutoff. + /// Without schedule: client (default false). + /// + public static bool ComputeIsAddOnAtCreation( + DateTime createdAtUtc, + DateTime? scheduledDate, + DateOnly? targetWeek, + bool? manualOverride) + { + return ComputeFromScheduleAnchor(createdAtUtc, scheduledDate, targetWeek, manualOverride); + } + + /// + /// SH-184 authoritative recalc after schedule set/change/clear. No manual override on patch. + /// Cleared schedule yields false. + /// + 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); + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs index 3067947..3589d36 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs @@ -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); diff --git a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs index b64d16c..c7ac473 100644 --- a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs +++ b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs @@ -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 { /// - /// 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. /// public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null) { @@ -21,7 +22,7 @@ namespace SeaHaven.Services.Helpers } /// - /// 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. /// public static bool IsPastDue( diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs index a4d332d..f1e79cf 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -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, diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index ee32e53..acba7d6 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -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.Changed("InternalWONumber", "", woNumber), diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 04bf63c..a2bda0c 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -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, diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 3e8c108..126d01e 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -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 { ApplyDateField(value, auditField, v => workOrder.DueDate = v, () => workOrder.DueDate) }, WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField), - WorkOrderBoardFieldNames.TargetWeek => new List { ApplyTargetWeek(workOrder, value, auditField) }, - WorkOrderBoardFieldNames.ScheduleWeekOnly => new List { 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 { await ApplyVendorIdAsync(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptDate => new List { 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 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(); 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 ApplyScheduleWeekOnly(WorkOrder workOrder, string? value, string auditField) + { + var change = ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly); + var changes = new List { change }; + if (change.HasChanged) + AppendIsAddOnRecalculation(workOrder, changes); + return changes; + } + + private static void AppendIsAddOnRecalculation(WorkOrder workOrder, List 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( diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs index 0bb6eac..9da5034 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -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, diff --git a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs index 8d0564d..8d7b1d5 100644 --- a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs +++ b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs @@ -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.") diff --git a/SeaHavenIndustries.Tests/WorkOrderAddonCutoffTests.cs b/SeaHavenIndustries.Tests/WorkOrderAddonCutoffTests.cs new file mode 100644 index 0000000..d860482 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderAddonCutoffTests.cs @@ -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); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs index 16f8e9e..d1014b6 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs @@ -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] diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index bd5f2bf..16cd08d 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -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(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.AddOn, + SiteCode = "BK5" + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); + + Assert.Contains(ex.Errors, e => e.PropertyName == "WorkOrderType"); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs index 933aa80..4cda7f8 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs @@ -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] diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs index 780c25c..9da4328 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -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.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 { 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 { "pending" }, + }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + null); + + Assert.Equal(1, result.TotalCount); + Assert.Equal(1, result.Items.Single().Id); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index 92c4d36..32a890c 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -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() { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index b775ee6..c76632a 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -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(() => + 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()); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs index 0d1f61c..2dd8c6b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs @@ -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] diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index d6bd47e..1d7440e 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -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, diff --git a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs index bff60ca..c299e21 100644 --- a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs @@ -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 diff --git a/docs/work-orders/phase-1/README.md b/docs/work-orders/phase-1/README.md index 660bfb3..cc37218 100644 --- a/docs/work-orders/phase-1/README.md +++ b/docs/work-orders/phase-1/README.md @@ -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).