From 75d337df0f91fa640568c09cccdff81a7b9b2de1 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 13:22:12 -0300 Subject: [PATCH 1/3] fix(work-orders): omit inactive primary vendor from board rows (SH-183) Do not project VendorId/VendorName for Cancelled, Canceled, or Refused primary dispatches, and expose PrimaryDispatchStatus on the board DTO. --- .../WorkOrderBoardDispatchAssignment.cs | 28 +++++ .../Helpers/WorkOrderBoardProjection.cs | 10 +- .../Interfaces/WorkOrderBoardModels.cs | 1 + SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 4 + .../Implementation/WorkOrderBoardService.cs | 10 +- .../WorkOrderBoardDispatchAssignmentTests.cs | 35 ++++++ .../WorkOrderBoardInactiveVendorTests.cs | 102 ++++++++++++++++++ 7 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 SeaHaven.DataServices/Helpers/WorkOrderBoardDispatchAssignment.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderBoardDispatchAssignmentTests.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderBoardInactiveVendorTests.cs diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardDispatchAssignment.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardDispatchAssignment.cs new file mode 100644 index 0000000..b257b66 --- /dev/null +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardDispatchAssignment.cs @@ -0,0 +1,28 @@ +namespace SeaHaven.DataServices.Helpers +{ + /// + /// SH-183: a cancelled or refused primary dispatch is not a live vendor assignment. + /// Verified/Completed remain live — the company that did the work. + /// + public static class WorkOrderBoardDispatchAssignment + { + public static bool IsInactivePrimaryDispatch(string? status) + { + var normalized = (status ?? string.Empty).Trim().ToLowerInvariant(); + return normalized is "cancelled" or "canceled" or "refused"; + } + + public static (int? VendorId, string? VendorName) LiveVendor( + int? vendorId, + string? vendorName, + string? primaryDispatchStatus) + { + if (IsInactivePrimaryDispatch(primaryDispatchStatus)) + { + return (null, null); + } + + return (vendorId, vendorName); + } + } +} diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index 04feeca..480d534 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -50,6 +50,7 @@ namespace SeaHaven.DataServices.Helpers w.ScheduledEnd, w.TargetWeek, w.ScheduleWeekOnly, + PrimaryDispatchStatus = w.PrimaryDispatch != null ? w.PrimaryDispatch.Status : null, VendorId = w.PrimaryDispatch != null ? (int?)w.PrimaryDispatch.VendorId : null, VendorName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null ? w.PrimaryDispatch.Vendor.CompanyName @@ -124,6 +125,10 @@ namespace SeaHaven.DataServices.Helpers var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone) ? w.DispatchTechPhone : (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone); + var (vendorId, vendorName) = WorkOrderBoardDispatchAssignment.LiveVendor( + w.VendorId, + w.VendorName, + w.PrimaryDispatchStatus); return new WorkOrderBoardRawRow( w.Id, @@ -150,8 +155,8 @@ namespace SeaHaven.DataServices.Helpers w.ScheduledEnd, w.TargetWeek, w.ScheduleWeekOnly, - w.VendorId, - w.VendorName, + vendorId, + vendorName, w.TechName, techPhone, w.DispatchApptDate, @@ -170,6 +175,7 @@ namespace SeaHaven.DataServices.Helpers w.HasUplift, w.PrimaryUpliftStatus, w.PrimaryUpliftAmount, + w.PrimaryDispatchStatus, isUnscheduled); }).ToList(); } diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index f122889..6ae2928 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -58,6 +58,7 @@ namespace SeaHaven.DataServices.Interfaces bool HasUplift, string? PrimaryUpliftStatus, decimal? PrimaryUpliftAmount, + string? PrimaryDispatchStatus, bool IsUnscheduled); public record WorkOrderBoardQueryResult( diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index e59464c..865ef7a 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -58,6 +58,10 @@ namespace SeaHaven.Services.DTOs public string? DayGroup { get; set; } public int? VendorId { get; set; } public string? VendorName { get; set; } + /// + /// Primary dispatch status (SH-183). Inactive statuses are not a live company assignment. + /// + public string? PrimaryDispatchStatus { get; set; } public string? TechName { get; set; } public string? TechPhone { get; set; } public DateTime? ApptDate { get; set; } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 4aa70ad..c1b2f42 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; @@ -86,6 +87,10 @@ namespace SeaHaven.Services.Implementation // Time prefers WO ScheduledStart/End (dispatcher window), then dispatch datetime. var apptStart = row.ScheduledStart ?? row.DispatchApptDate; var apptEnd = row.ScheduledEnd; + var (vendorId, vendorName) = WorkOrderBoardDispatchAssignment.LiveVendor( + row.VendorId, + row.VendorName, + row.PrimaryDispatchStatus); return new WorkOrderBoardRowDto { @@ -113,8 +118,9 @@ namespace SeaHaven.Services.Implementation TargetWeek = row.TargetWeek, ScheduleWeekOnly = row.ScheduleWeekOnly, DayGroup = WorkOrderDerivedFields.GetDayGroup(row.ScheduledDate), - VendorId = row.VendorId, - VendorName = row.VendorName, + VendorId = vendorId, + VendorName = vendorName, + PrimaryDispatchStatus = row.PrimaryDispatchStatus, TechName = row.TechName, TechPhone = row.TechPhone, ApptDate = row.DispatchApptDate ?? row.ScheduledDate, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardDispatchAssignmentTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardDispatchAssignmentTests.cs new file mode 100644 index 0000000..a49e275 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardDispatchAssignmentTests.cs @@ -0,0 +1,35 @@ +using SeaHaven.DataServices.Helpers; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardDispatchAssignmentTests +{ + [Theory] + [InlineData("Cancelled")] + [InlineData("Canceled")] + [InlineData("Refused")] + [InlineData(" cancelled ")] + [InlineData("CANCELED")] + [InlineData("refused")] + public void LiveVendor_InactiveStatus_ClearsAssignment(string status) + { + var (vendorId, vendorName) = WorkOrderBoardDispatchAssignment.LiveVendor(45, "Acme HVAC", status); + Assert.Null(vendorId); + Assert.Null(vendorName); + Assert.True(WorkOrderBoardDispatchAssignment.IsInactivePrimaryDispatch(status)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Sent")] + [InlineData("Verified")] + [InlineData("Completed")] + public void LiveVendor_LiveOrMissingStatus_KeepsVendorIdWhenNameEmpty(string? status) + { + var (vendorId, vendorName) = WorkOrderBoardDispatchAssignment.LiveVendor(45, null, status); + Assert.Equal(45, vendorId); + Assert.Null(vendorName); + Assert.False(WorkOrderBoardDispatchAssignment.IsInactivePrimaryDispatch(status)); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardInactiveVendorTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardInactiveVendorTests.cs new file mode 100644 index 0000000..d2adc8e --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardInactiveVendorTests.cs @@ -0,0 +1,102 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardInactiveVendorTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static async Task SeedPrimaryDispatchAsync( + ApplicationDbContext context, + string? dispatchStatus, + string? companyName) + { + context.Vendors.Add(new Vendor { Id = 1, CompanyName = companyName }); + context.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + WorkOrderId = 1, + Status = dispatchStatus, + ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0) + }); + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + SiteCode = "BK5" + }); + await context.SaveChangesAsync(); + } + + [Theory] + [InlineData("Cancelled")] + [InlineData("Canceled")] + [InlineData("Refused")] + public async Task GetBoardRows_InactivePrimaryDispatch_OmitsVendorAssignment(string status) + { + await using var context = CreateContext(); + await SeedPrimaryDispatchAsync(context, status, "Acme HVAC"); + var weekStart = new DateOnly(2026, 6, 22); + + var result = await new WorkOrderBoardDataService(context).GetBoardRowsAsync(new WorkOrderBoardQuery( + weekStart, + weekStart.AddDays(4), + null, + false, + null, + false, + null, + null)); + + var raw = Assert.Single(result.ScheduledRows); + Assert.Null(raw.VendorId); + Assert.Null(raw.VendorName); + Assert.Equal(status, raw.PrimaryDispatchStatus); + + var dto = WorkOrderBoardService.MapRawRow(raw, DateTime.UtcNow); + Assert.Null(dto.VendorId); + Assert.Null(dto.VendorName); + Assert.Equal(status, dto.PrimaryDispatchStatus); + } + + [Fact] + public async Task GetBoardRows_LiveDispatchWithEmptyCompanyName_KeepsVendorId() + { + await using var context = CreateContext(); + await SeedPrimaryDispatchAsync(context, "Sent", ""); + var weekStart = new DateOnly(2026, 6, 22); + + var result = await new WorkOrderBoardDataService(context).GetBoardRowsAsync(new WorkOrderBoardQuery( + weekStart, + weekStart.AddDays(4), + null, + false, + null, + false, + null, + null)); + + var raw = Assert.Single(result.ScheduledRows); + Assert.Equal(1, raw.VendorId); + Assert.Equal("Sent", raw.PrimaryDispatchStatus); + + var dto = WorkOrderBoardService.MapRawRow(raw, DateTime.UtcNow); + Assert.Equal(1, dto.VendorId); + Assert.Equal("Sent", dto.PrimaryDispatchStatus); + } +} From 743841d93e990b5ecb8df758909902a60ef1ae1a Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 13:41:42 -0300 Subject: [PATCH 2/3] fix(work-orders): map primary dispatch status on detail (SH-183) --- .../Implementation/WorkOrderDetailService.cs | 1 + .../WorkOrderPhase6Tests.cs | 67 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs index 8a94e26..9fcf0ab 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -118,6 +118,7 @@ namespace SeaHaven.Services.Implementation DayGroup = row.DayGroup, VendorId = row.VendorId, VendorName = row.VendorName, + PrimaryDispatchStatus = row.PrimaryDispatchStatus, TechName = row.TechName, TechPhone = row.TechPhone, ApptDate = row.ApptDate, diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 34a3cbb..af241d4 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -216,6 +216,73 @@ public class WorkOrderDetailServiceTests Assert.Equal(1500m, detail.Info.UpliftSummary.Amount); } + [Fact] + public async Task GetDetail_InactivePrimaryDispatch_OmitsVendorAndMapsStatus() + { + var (context, service) = CreateSut(); + + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + context.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + WorkOrderId = 1, + Status = "Refused", + ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0) + }); + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = DateTime.UtcNow.AddDays(1), + PrimaryDispatchId = 10, + SiteCode = "BK5", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); + + Assert.NotNull(detail); + Assert.Null(detail!.Info.VendorId); + Assert.Null(detail.Info.VendorName); + Assert.Equal("Refused", detail.Info.PrimaryDispatchStatus); + } + + [Fact] + public async Task GetDetail_LiveDispatchWithEmptyCompanyName_KeepsVendorIdAndMapsStatus() + { + var (context, service) = CreateSut(); + + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "" }); + context.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + WorkOrderId = 1, + Status = "Sent", + ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0) + }); + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = DateTime.UtcNow.AddDays(1), + PrimaryDispatchId = 10, + SiteCode = "BK5", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); + + Assert.NotNull(detail); + Assert.Equal(1, detail!.Info.VendorId); + Assert.Equal("Sent", detail.Info.PrimaryDispatchStatus); + } + [Fact] public async Task GetDetail_ReturnsAdditionalContactsFromBoardRow() { From aa2c571d1eec27a5cb25ecc165e46832e6ccfe07 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 13:43:42 -0300 Subject: [PATCH 3/3] docs(work-orders): drop ticket key from primary dispatch status comment --- SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index 865ef7a..f7ecc1d 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -59,7 +59,7 @@ namespace SeaHaven.Services.DTOs public int? VendorId { get; set; } public string? VendorName { get; set; } /// - /// Primary dispatch status (SH-183). Inactive statuses are not a live company assignment. + /// Primary dispatch status. Inactive statuses are not a live company assignment. /// public string? PrimaryDispatchStatus { get; set; } public string? TechName { get; set; }