From 15315edf083f6e815ebda79d359b7ae376b5f450 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 24 Aug 2026 15:14:31 -0300 Subject: [PATCH 1/3] feat(work-orders): persist avetaRequired on board create, patch, and search Expose avetaRequired and originalDate on list rows so the frontend can round-trip the Aveta checkbox and Reschedule hover. --- .../20260824150000_SH117_AvetaRequired.cs | 33 ++++++++ .../ApplicationDbContextModelSnapshot.cs | 3 + Data.SeaHavenIndustries/Models/WorkerOrder.cs | 4 + .../Helpers/WorkOrderBoardProjection.cs | 6 ++ .../WorkOrderBoardSearchFacetFilters.cs | 10 +++ .../WorkOrderAdvancedSearchDataService.cs | 1 + .../WorkOrderAdvancedSearchModels.cs | 1 + .../Interfaces/WorkOrderBoardModels.cs | 3 + SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 3 + .../DTOs/WorkOrderBoardRequestDTOs.cs | 4 + SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs | 2 - .../Helpers/WorkOrderBoardFieldNames.cs | 5 +- .../WorkOrderAdvancedSearchService.cs | 1 + .../WorkOrderBoardCreateService.cs | 3 + .../Implementation/WorkOrderBoardService.cs | 3 + .../WorkOrderBoardUpdateService.cs | 10 +++ .../Implementation/WorkOrderDetailService.cs | 5 +- .../WorkOrderBoardCreateServiceTests.cs | 39 ++++++++++ .../WorkOrderBoardSearchTests.cs | 47 ++++++++++++ .../WorkOrderBoardServiceTests.cs | 34 +++++++++ .../WorkOrderBoardUpdateServiceTests.cs | 76 +++++++++++++++++++ .../WorkOrderMigrationDiscoveryTests.cs | 13 ++++ 22 files changed, 301 insertions(+), 5 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260824150000_SH117_AvetaRequired.cs diff --git a/Data.SeaHavenIndustries/Migrations/20260824150000_SH117_AvetaRequired.cs b/Data.SeaHavenIndustries/Migrations/20260824150000_SH117_AvetaRequired.cs new file mode 100644 index 0000000..eb71969 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260824150000_SH117_AvetaRequired.cs @@ -0,0 +1,33 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260824150000_SH117_AvetaRequired")] + public partial class SH117_AvetaRequired : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AvetaRequired", + table: "workOrders", + type: "bit", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AvetaRequired", + table: "workOrders"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 0efc712..a3051ed 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2410,6 +2410,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Attachments") .HasColumnType("nvarchar(max)"); + b.Property("AvetaRequired") + .HasColumnType("bit"); + b.Property("AvettaTask") .HasColumnType("nvarchar(max)"); diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 733b01e..67549ad 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -62,6 +62,10 @@ namespace Data.SeaHavenIndustries public string? ExtraServices { get; set; } [MaxLength(4000)] public string? ServiceNotes { get; set; } + /// + /// Per-WO Aveta compliance flag (SH-117). Distinct from legacy AvettaTask string. + /// + public bool AvetaRequired { get; set; } /// Tech/crew phone when no primary dispatch; otherwise prefer Dispatch.TechPhone. [MaxLength(50)] public string? TechPhone { get; set; } diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index 480d534..0ccb890 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -19,6 +19,9 @@ namespace SeaHaven.DataServices.Helpers w.RescheduleCount, w.CarriedOver, w.IsAddOn, + w.AvetaRequired, + w.OriginalDate, + w.OriginalWeek, w.WorkOrderType, w.SiteCode, LocationName = w.Locations != null ? w.Locations.Name : null, @@ -176,6 +179,9 @@ namespace SeaHaven.DataServices.Helpers w.PrimaryUpliftStatus, w.PrimaryUpliftAmount, w.PrimaryDispatchStatus, + w.AvetaRequired, + w.OriginalDate, + w.OriginalWeek, isUnscheduled); }).ToList(); } diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs index 08b2c2d..d3bdfea 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs @@ -74,6 +74,16 @@ namespace SeaHaven.DataServices.Helpers return query.Where(w => w.IsAddOn); } + public static IQueryable ApplyAvetaOnlyFilter( + IQueryable query, + bool avetaOnly) + { + if (!avetaOnly) + return query; + + return query.Where(w => w.AvetaRequired); + } + public static IQueryable ApplyFlagColorFilter( IQueryable query, IReadOnlyList? flagColors) diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs index 1d4892d..2eebc30 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs @@ -32,6 +32,7 @@ namespace SeaHaven.DataServices.Implementation baseQuery = WorkOrderBoardSearchFacetFilters.ApplyRescheduledFilter(baseQuery, query.Rescheduled); baseQuery = WorkOrderBoardSearchFacetFilters.ApplyCarriedOverFilter(baseQuery, query.CarriedOver); baseQuery = WorkOrderBoardSearchFacetFilters.ApplyAddOnFilter(baseQuery, query.AddOn); + baseQuery = WorkOrderBoardSearchFacetFilters.ApplyAvetaOnlyFilter(baseQuery, query.AvetaOnly); baseQuery = WorkOrderBoardSearchFacetFilters.ApplyFlagColorFilter(baseQuery, query.FlagColors); baseQuery = WorkOrderBoardSearchFacetFilters.ApplyInternalOnlyFilter(baseQuery, query.InternalOnly); baseQuery = WorkOrderBoardSearchFacetFilters.ApplyUpliftFilter( diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs index b965e86..44889dc 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs @@ -19,6 +19,7 @@ namespace SeaHaven.DataServices.Interfaces bool Rescheduled, bool CarriedOver, bool AddOn, + bool AvetaOnly, IReadOnlyList? FlagColors, bool InternalOnly, bool HasUplift, diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index 6ae2928..d4cb09c 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -59,6 +59,9 @@ namespace SeaHaven.DataServices.Interfaces string? PrimaryUpliftStatus, decimal? PrimaryUpliftAmount, string? PrimaryDispatchStatus, + bool AvetaRequired, + DateOnly? OriginalDate, + DateOnly? OriginalWeek, bool IsUnscheduled); public record WorkOrderBoardQueryResult( diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index f7ecc1d..395e858 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -36,6 +36,9 @@ namespace SeaHaven.Services.DTOs public int RescheduleCount { get; set; } public int CarriedOver { get; set; } public bool IsAddOn { get; set; } + public bool AvetaRequired { get; set; } + public DateOnly? OriginalDate { get; set; } + public DateOnly? OriginalWeek { 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 9a5de5d..d9718ff 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -32,6 +32,8 @@ namespace SeaHaven.Services.DTOs public string? PrimaryService { get; set; } public List? ExtraServices { get; set; } public string? ServiceNotes { get; set; } + /// Per-WO Aveta compliance. Omit or false = not required. + public bool? AvetaRequired { get; set; } public int? LocationId { get; set; } public DateTime? DueDate { get; set; } public string? AssignTo { get; set; } @@ -73,6 +75,8 @@ namespace SeaHaven.Services.DTOs public bool Rescheduled { get; set; } public bool CarriedOver { get; set; } public bool AddOn { get; set; } + /// When true, only rows with AvetaRequired. + public bool AvetaOnly { get; set; } public List? FlagColors { get; set; } public bool InternalOnly { get; set; } public bool HasUplift { get; set; } diff --git a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs index 8f2c692..9832b01 100644 --- a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs @@ -17,8 +17,6 @@ namespace SeaHaven.Services.DTOs public string? Trade { get; set; } public string? Problem { get; set; } public string? SubTrade { get; set; } - public DateOnly? OriginalWeek { get; set; } - public DateOnly? OriginalDate { get; set; } public string? VendorNotes { get; set; } } diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs index 45ae58f..4056197 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs @@ -21,6 +21,7 @@ namespace SeaHaven.Services.Helpers public const string ExtraServices = "extraServices"; public const string AdditionalContacts = "additionalContacts"; public const string FlagColor = "flagColor"; + public const string AvetaRequired = "avetaRequired"; public const string RescheduleCount = "RescheduleCount"; private static readonly HashSet DispatchFields = new(StringComparer.OrdinalIgnoreCase) @@ -50,7 +51,8 @@ namespace SeaHaven.Services.Helpers ServiceNotes, ExtraServices, AdditionalContacts, - FlagColor + FlagColor, + AvetaRequired }; public static bool IsEditable(string field) => Canonicalize(field) != null; @@ -93,6 +95,7 @@ namespace SeaHaven.Services.Helpers ExtraServices => "ExtraServices", AdditionalContacts => "AdditionalContacts", FlagColor => "FlagColor", + AvetaRequired => "AvetaRequired", _ => canonical }; } diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs index f1e79cf..6185f99 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -55,6 +55,7 @@ namespace SeaHaven.Services.Implementation query.Rescheduled, query.CarriedOver, query.AddOn, + query.AvetaOnly, query.FlagColors, query.InternalOnly, query.HasUplift, diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index a5df677..f644f6e 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -85,6 +85,7 @@ namespace SeaHaven.Services.Implementation Trade = primaryService, ExtraServices = extraServicesJson, ServiceNotes = serviceNotes, + AvetaRequired = request.AvetaRequired == true, PocName = pocName, PocPhone = pocPhone, PocNotes = pocNotes, @@ -130,6 +131,8 @@ namespace SeaHaven.Services.Implementation changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ExtraServices", "", workOrder.ExtraServices)); if (workOrder.ServiceNotes != null) changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ServiceNotes", "", workOrder.ServiceNotes)); + if (workOrder.AvetaRequired) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("AvetaRequired", "", bool.TrueString)); if (workOrder.PocName != null) changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocName", "", workOrder.PocName)); if (workOrder.PocPhone != null) diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index c1b2f42..c4405b3 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -99,6 +99,9 @@ namespace SeaHaven.Services.Implementation RescheduleCount = row.RescheduleCount, CarriedOver = row.CarriedOver, IsAddOn = row.IsAddOn, + AvetaRequired = row.AvetaRequired, + OriginalDate = row.OriginalDate, + OriginalWeek = row.OriginalWeek, 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 5a11b21..6c27693 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -193,6 +193,7 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardFieldNames.ExtraServices => new List { ApplyExtraServices(workOrder, value, auditField) }, WorkOrderBoardFieldNames.AdditionalContacts => new List { ApplyAdditionalContacts(workOrder, value, auditField) }, WorkOrderBoardFieldNames.FlagColor => new List { ApplyFlagColor(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.AvetaRequired => new List { ApplyAvetaRequired(workOrder, value, auditField) }, _ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.") }; } @@ -679,6 +680,15 @@ namespace SeaHaven.Services.Implementation return FieldChange.ForField(auditField, old, parsed.ToString()); } + private static FieldChange ApplyAvetaRequired(WorkOrder workOrder, string? value, string auditField) + { + return ApplyBoolField( + value, + auditField, + v => workOrder.AvetaRequired = v == true, + () => workOrder.AvetaRequired); + } + private static FieldChange ApplyFlagColor(WorkOrder workOrder, string? value, string auditField) { if (!WorkOrderFlagColors.TryNormalize(value, out var canonical)) diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs index 9fcf0ab..b30f052 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -96,6 +96,7 @@ namespace SeaHaven.Services.Implementation RescheduleCount = row.RescheduleCount, CarriedOver = row.CarriedOver, IsAddOn = row.IsAddOn, + AvetaRequired = row.AvetaRequired, WorkOrderType = row.WorkOrderType, IsPastDue = row.IsPastDue, SiteCode = row.SiteCode, @@ -137,8 +138,8 @@ namespace SeaHaven.Services.Implementation Trade = extended?.Trade, Problem = extended?.Problem, SubTrade = extended?.SubTrade, - OriginalWeek = extended?.OriginalWeek, - OriginalDate = extended?.OriginalDate, + OriginalWeek = row.OriginalWeek ?? extended?.OriginalWeek, + OriginalDate = row.OriginalDate ?? extended?.OriginalDate, CompletedDate = extended?.CompletedDate, VendorNotes = extended?.VendorNotes }; diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index d3df3a9..53f4f84 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -432,6 +432,45 @@ public class WorkOrderBoardCreateServiceTests Assert.True(wo.IsAddOn); } + [Fact] + public async Task Create_AvetaRequiredTrue_PersistsAndReturns() + { + var (context, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + AvetaRequired = true + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + Assert.True(result.AvetaRequired); + var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id); + Assert.True(wo.AvetaRequired); + Assert.Contains( + await context.WorkOrderAuditLogs.ToListAsync(), + l => l.FieldName == "AvetaRequired" && l.Action == "FieldChanged"); + } + + [Fact] + public async Task Create_AvetaRequiredOmitted_DefaultsFalse() + { + var (context, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5" + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + Assert.False(result.AvetaRequired); + var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id); + Assert.False(wo.AvetaRequired); + Assert.DoesNotContain( + await context.WorkOrderAuditLogs.ToListAsync(), + l => l.FieldName == "AvetaRequired"); + } + [Fact] public async Task Create_WorkOrderTypeAddOn_ThrowsValidation() { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs index 9da4328..7f98f9a 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -829,4 +829,51 @@ public class WorkOrderAdvancedSearchServiceTests Assert.Equal(1, result.TotalCount); Assert.Equal(1, result.Items.Single().Id); } + + [Fact] + public async Task SearchAsync_AvetaOnly_ReturnsFlaggedRows() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + AvetaRequired = true, + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000001", + }, + new WorkOrder + { + Id = 2, + AvetaRequired = false, + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000002", + }); + + 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), + AvetaOnly = true, + }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + null); + + Assert.Equal(1, result.TotalCount); + Assert.Equal(1, result.Items.Single().Id); + Assert.True(result.Items.Single().AvetaRequired); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index dc536ce..a13bc22 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -448,6 +448,40 @@ public class WorkOrderBoardServiceTests Assert.Equal(WorkOrderType.PM, response.Scheduled[0].WorkOrderType); } + [Fact] + public async Task GetBoardAsync_MapsAvetaRequiredAndOriginalDate() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + WorkOrderType = WorkOrderType.PM, + AvetaRequired = true, + OriginalDate = new DateOnly(2026, 5, 8), + OriginalWeek = new DateOnly(2026, 5, 4), + 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].AvetaRequired); + Assert.Equal(new DateOnly(2026, 5, 8), response.Scheduled[0].OriginalDate); + Assert.Equal(new DateOnly(2026, 5, 4), response.Scheduled[0].OriginalWeek); + } + [Fact] public async Task GetBoardAsync_InvalidWeekWindow_Throws() { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index ebf9996..9bdf934 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -1388,4 +1388,80 @@ public class WorkOrderBoardUpdateServiceTests Assert.True(persisted.IsAddOn); Assert.Empty(await context.WorkOrderAuditLogs.Where(a => a.FieldName == "IsAddOn").ToListAsync()); } + + [Fact] + public async Task PatchField_AvetaRequiredTrue_PersistsAndAudits() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + 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.AvetaRequired, + Value = "true", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.True(result.AvetaRequired); + Assert.True((await context.workOrders.FindAsync(1))!.AvetaRequired); + Assert.Contains( + await context.WorkOrderAuditLogs.ToListAsync(), + a => a.FieldName == "AvetaRequired" && a.Action == AuditActionType.FieldChanged.ToString()); + } + + [Fact] + public async Task PatchField_AvetaRequiredFalse_ClearsFlag() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + AvetaRequired = 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.AvetaRequired, + Value = "false", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.False(result.AvetaRequired); + Assert.False((await context.workOrders.FindAsync(1))!.AvetaRequired); + } + + [Fact] + public async Task PatchField_AvetaRequiredInvalid_ThrowsInvalidValue() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.AvetaRequired, + Value = "yes", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("InvalidValue", ex.Code); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderMigrationDiscoveryTests.cs b/SeaHavenIndustries.Tests/WorkOrderMigrationDiscoveryTests.cs index 5484926..8e34d94 100644 --- a/SeaHavenIndustries.Tests/WorkOrderMigrationDiscoveryTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderMigrationDiscoveryTests.cs @@ -19,4 +19,17 @@ public class WorkOrderMigrationDiscoveryTests Assert.Contains("20260713120000_Phase7_ExternalWorkOrderIdUnique", migrations); } + + [Fact] + public void SH117_AvetaRequired_is_discoverable_by_ef_runtime() + { + var options = new DbContextOptionsBuilder() + .UseSqlite("DataSource=:memory:") + .Options; + + using var context = new ApplicationDbContext(options); + var migrations = context.Database.GetMigrations().ToList(); + + Assert.Contains("20260824150000_SH117_AvetaRequired", migrations); + } } From 3a7a1b1f094a65f32b4c4e1bb890406226dc16a5 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 25 Aug 2026 12:03:19 -0300 Subject: [PATCH 2/3] docs(work-orders): drop ticket key from AvetaRequired xml comment --- Data.SeaHavenIndustries/Models/WorkerOrder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 67549ad..5e87a2e 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -63,7 +63,7 @@ namespace Data.SeaHavenIndustries [MaxLength(4000)] public string? ServiceNotes { get; set; } /// - /// Per-WO Aveta compliance flag (SH-117). Distinct from legacy AvettaTask string. + /// Per-WO Aveta compliance flag. Distinct from legacy AvettaTask string. /// public bool AvetaRequired { get; set; } /// Tech/crew phone when no primary dispatch; otherwise prefer Dispatch.TechPhone. From 7c7c6bc5251b5d5a22cd16ed15fd4a0bdf816ebf Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 25 Aug 2026 15:10:45 -0300 Subject: [PATCH 3/3] feat(work-orders): persist Aveta Extra Docs media category Round-trip category 5 on media POST/PATCH/GET and project hasAvetaDocument so pending vs attached survives reopen. --- .../Enums/WorkOrderEnums.cs | 3 +- .../Helpers/WorkOrderBoardProjection.cs | 5 + .../Interfaces/WorkOrderBoardModels.cs | 1 + SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 1 + .../Helpers/WorkOrderBoardMutationRules.cs | 8 +- .../Implementation/WorkOrderBoardService.cs | 1 + .../Implementation/WorkOrderDetailService.cs | 1 + .../WorkOrderBoardMutationRulesTests.cs | 4 + .../WorkOrderBoardServiceTests.cs | 71 ++++++++++++++ .../WorkOrderCompletedSelectiveLockTests.cs | 44 +++++++++ .../WorkOrderPhase6Tests.cs | 95 +++++++++++++++++++ 11 files changed, 232 insertions(+), 2 deletions(-) diff --git a/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs b/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs index b8db7fc..f50824f 100644 --- a/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs +++ b/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs @@ -165,7 +165,8 @@ namespace Data.SeaHavenIndustries.Enums Before = 1, After = 2, Extra = 3, - Completion = 4 + Completion = 4, + Aveta = 5 } } \ No newline at end of file diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index 0ccb890..015c2ad 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; @@ -20,6 +21,9 @@ namespace SeaHaven.DataServices.Helpers w.CarriedOver, w.IsAddOn, w.AvetaRequired, + HasAvetaDocument = w.workOrderAttachments!.Any(a => + (a.IsDeleted == null || a.IsDeleted == false) + && a.Category == WorkOrderMediaCategory.Aveta), w.OriginalDate, w.OriginalWeek, w.WorkOrderType, @@ -180,6 +184,7 @@ namespace SeaHaven.DataServices.Helpers w.PrimaryUpliftAmount, w.PrimaryDispatchStatus, w.AvetaRequired, + w.HasAvetaDocument, w.OriginalDate, w.OriginalWeek, isUnscheduled); diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index d4cb09c..da84f94 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -60,6 +60,7 @@ namespace SeaHaven.DataServices.Interfaces decimal? PrimaryUpliftAmount, string? PrimaryDispatchStatus, bool AvetaRequired, + bool HasAvetaDocument, DateOnly? OriginalDate, DateOnly? OriginalWeek, bool IsUnscheduled); diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index 395e858..5e960d3 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -37,6 +37,7 @@ namespace SeaHaven.Services.DTOs public int CarriedOver { get; set; } public bool IsAddOn { get; set; } public bool AvetaRequired { get; set; } + public bool HasAvetaDocument { get; set; } public DateOnly? OriginalDate { get; set; } public DateOnly? OriginalWeek { get; set; } public WorkOrderType? WorkOrderType { get; set; } diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs index 7a52474..dde4bfb 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs @@ -33,7 +33,13 @@ namespace SeaHaven.Services.Helpers return false; if (!IsCoreLocked(status)) return true; - return (category ?? WorkOrderMediaCategory.Extra) == WorkOrderMediaCategory.Extra; + return IsExtraDocsFamily(category); + } + + public static bool IsExtraDocsFamily(WorkOrderMediaCategory? category) + { + var resolved = category ?? WorkOrderMediaCategory.Extra; + return resolved == WorkOrderMediaCategory.Extra || resolved == WorkOrderMediaCategory.Aveta; } public static bool ShouldBlockStatusChangeWhenPastDue(string field, bool isPastDue) diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index c4405b3..335e71c 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -100,6 +100,7 @@ namespace SeaHaven.Services.Implementation CarriedOver = row.CarriedOver, IsAddOn = row.IsAddOn, AvetaRequired = row.AvetaRequired, + HasAvetaDocument = row.HasAvetaDocument, OriginalDate = row.OriginalDate, OriginalWeek = row.OriginalWeek, WorkOrderType = row.WorkOrderType, diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs index b30f052..7f4d132 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -97,6 +97,7 @@ namespace SeaHaven.Services.Implementation CarriedOver = row.CarriedOver, IsAddOn = row.IsAddOn, AvetaRequired = row.AvetaRequired, + HasAvetaDocument = row.HasAvetaDocument, WorkOrderType = row.WorkOrderType, IsPastDue = row.IsPastDue, SiteCode = row.SiteCode, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs index 195c8d1..6494004 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs @@ -87,8 +87,12 @@ public class WorkOrderBoardMutationRulesTests LifecycleStatus.Completed, WorkOrderMediaCategory.Before)); Assert.False(WorkOrderBoardMutationRules.CanMutateExtraMedia( LifecycleStatus.Completed, WorkOrderMediaCategory.After)); + Assert.True(WorkOrderBoardMutationRules.CanMutateExtraMedia( + LifecycleStatus.Completed, WorkOrderMediaCategory.Aveta)); Assert.False(WorkOrderBoardMutationRules.CanMutateExtraMedia( LifecycleStatus.Canceled, WorkOrderMediaCategory.Extra)); + Assert.False(WorkOrderBoardMutationRules.CanMutateExtraMedia( + LifecycleStatus.Canceled, WorkOrderMediaCategory.Aveta)); } [Fact] diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index a13bc22..a73d582 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -480,6 +480,77 @@ public class WorkOrderBoardServiceTests Assert.True(response.Scheduled[0].AvetaRequired); Assert.Equal(new DateOnly(2026, 5, 8), response.Scheduled[0].OriginalDate); Assert.Equal(new DateOnly(2026, 5, 4), response.Scheduled[0].OriginalWeek); + Assert.False(response.Scheduled[0].HasAvetaDocument); + } + + [Fact] + public async Task GetBoardAsync_HasAvetaDocument_WhenAvetaAttachmentExists() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + AvetaRequired = true, + ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc), + LifecycleStatus = LifecycleStatus.Scheduled + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/aveta.pdf", + Category = WorkOrderMediaCategory.Aveta + }); + 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].AvetaRequired); + Assert.True(response.Scheduled[0].HasAvetaDocument); + } + + [Fact] + public async Task GetBoardAsync_HasAvetaDocumentFalse_WhenOnlyExtraAttachment() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + AvetaRequired = true, + ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc), + LifecycleStatus = LifecycleStatus.Scheduled + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/extra.pdf", + Category = WorkOrderMediaCategory.Extra + }); + 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].AvetaRequired); + Assert.False(response.Scheduled[0].HasAvetaDocument); } [Fact] diff --git a/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs index 9ebd221..733c589 100644 --- a/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs @@ -242,6 +242,24 @@ public class WorkOrderCompletedSelectiveLockTests Assert.Equal(WorkOrderMediaCategory.Extra, media.Category); } + [Fact] + public async Task AddMedia_Completed_AllowsAveta() + { + var (context, service) = CreateMediaSut(); + context.workOrders.Add(CompletedWorkOrder()); + await context.SaveChangesAsync(); + + var media = await service.AddMediaAsync( + 1, + WorkOrderMediaCategory.Aveta, + "https://example.com/aveta.pdf", + WorkOrderAccountTestHelpers.OrgWideAdmin(), + "actor-1"); + + Assert.True(media.Id > 0); + Assert.Equal(WorkOrderMediaCategory.Aveta, media.Category); + } + [Fact] public async Task AddMedia_Completed_BlocksBefore() { @@ -286,6 +304,32 @@ public class WorkOrderCompletedSelectiveLockTests Assert.Equal(WorkOrderMediaCategory.Extra, media.Category); } + [Fact] + public async Task UpdateMediaCategory_Completed_AllowsAveta() + { + var (context, service) = CreateMediaSut(); + var wo = CompletedWorkOrder(); + context.workOrders.Add(wo); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + Id = 10, + WorkorderId = 1, + Attachments = "https://example.com/extra.pdf", + Category = WorkOrderMediaCategory.Extra + }); + await context.SaveChangesAsync(); + + var media = await service.UpdateMediaCategoryAsync( + 1, + 10, + WorkOrderMediaCategory.Aveta, + ToVersion(wo), + WorkOrderAccountTestHelpers.OrgWideAdmin(), + "actor-1"); + + Assert.Equal(WorkOrderMediaCategory.Aveta, media.Category); + } + [Fact] public async Task UpdateMediaCategory_Completed_BlocksMoveToBefore() { diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 1586a83..7e6a254 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -866,6 +866,101 @@ public class WorkOrderMediaServiceTests Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.Extra); } + [Fact] + public async Task GetMedia_IncludesAvetaCategory() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder { Id = 1 }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/aveta.pdf", + Category = WorkOrderMediaCategory.Aveta + }); + await context.SaveChangesAsync(); + + var media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1"); + + Assert.NotNull(media); + Assert.Contains(media!, m => m.Category == WorkOrderMediaCategory.Aveta); + } + + [Fact] + public async Task AddMedia_Aveta_PersistsCategory() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + var media = await service.AddMediaAsync( + 1, + WorkOrderMediaCategory.Aveta, + "https://example.com/aveta.pdf", + AuthenticatedUser(), + "actor-1"); + + Assert.True(media.Id > 0); + Assert.Equal(WorkOrderMediaCategory.Aveta, media.Category); + Assert.Equal(WorkOrderMediaCategory.Aveta, context.workOrderAttachments.Single().Category); + } + + [Fact] + public async Task AddMedia_Extra_DoesNotPersistAsAveta() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + var media = await service.AddMediaAsync( + 1, + WorkOrderMediaCategory.Extra, + "https://example.com/extra.pdf", + AuthenticatedUser(), + "actor-1"); + + Assert.Equal(WorkOrderMediaCategory.Extra, media.Category); + Assert.Equal(WorkOrderMediaCategory.Extra, context.workOrderAttachments.Single().Category); + Assert.NotEqual(WorkOrderMediaCategory.Aveta, context.workOrderAttachments.Single().Category); + } + + [Fact] + public async Task UpdateMediaCategory_ExtraToAveta() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + Id = 10, + WorkorderId = 1, + Attachments = "https://example.com/extra.pdf", + Category = WorkOrderMediaCategory.Extra + }); + await context.SaveChangesAsync(); + + var media = await service.UpdateMediaCategoryAsync( + 1, 10, WorkOrderMediaCategory.Aveta, ToVersion(wo), AuthenticatedUser(), "actor-1"); + + Assert.Equal(WorkOrderMediaCategory.Aveta, media.Category); + Assert.Equal(WorkOrderMediaCategory.Aveta, context.workOrderAttachments.Single().Category); + Assert.True(context.workOrderAttachments.Single().IsDeleted != true); + } + [Fact] public async Task GetMedia_TechnicianOnAssignedWorkOrder_Succeeds() {