diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs index 634443a..650c911 100644 --- a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -480,6 +480,76 @@ public class VendorDataServiceTests inactive.Should().BeEmpty(); } + [Fact] + public async Task GetActiveTradesAsync_ReturnsEmptyOnEmptyDatabase() + { + await using var context = NewContext(); + var service = new VendorDataService(context); + + var trades = await service.GetActiveTradesAsync(CancellationToken.None); + + trades.Should().BeEmpty(); + } + + [Fact] + public async Task GetActiveTradesAsync_FiltersInactiveAndOrdersBySortOrderThenName() + { + await using var context = NewContext(); + context.Trades.AddRange( + new Trade { Name = "Janitorial", NormalizedName = "janitorial", SortOrder = 7 }, + new Trade { Name = "Plumbing", NormalizedName = "plumbing", SortOrder = 1 }, + new Trade { Name = "Electrical", NormalizedName = "electrical", SortOrder = 2 }, + new Trade { Name = "Retired Trade", NormalizedName = "retired trade", SortOrder = 0, IsActive = false }); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var trades = await service.GetActiveTradesAsync(CancellationToken.None); + + trades.Select(t => t.Name).Should().Equal("Plumbing", "Electrical", "Janitorial"); + } + + [Fact] + public async Task GetPagedAsync_TradeFilterStillMatchesLegacyFreeTextValues() + { + await using var context = NewContext(); + context.Vendors.AddRange( + VendorWithJobs("Legacy Co", "Chicago", "IL", "Backflow Testing, Plumbing", 3), + VendorWithJobs("Canonical Co", "Chicago", "IL", "Plumbing", 5)); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + // SH-249: stored TradeSpecialties stay free text; filtering by a legacy + // value that is not in the canonical catalog must keep matching. + var (items, totalCount) = await service.GetPagedAsync( + 1, + 12, + isActive: true, + trades: new[] { "Backflow Testing" }); + + totalCount.Should().Be(1); + items.Single().CompanyName.Should().Be("Legacy Co"); + } + + [Fact] + public async Task GetDirectoryPagedAsync_TradeFilterStillMatchesLegacyFreeTextValues() + { + await using var context = NewContext(); + context.Vendors.AddRange( + VendorWithJobs("Legacy Co", "Chicago", "IL", "Backflow Testing, Plumbing", 3), + VendorWithJobs("Canonical Co", "Chicago", "IL", "Plumbing", 5)); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var (items, totalCount) = await service.GetDirectoryPagedAsync( + 1, + 12, + isActive: true, + trades: new[] { "Backflow Testing" }); + + totalCount.Should().Be(1); + items.Single().CompanyName.Should().Be("Legacy Co"); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index 2823fa6..1816257 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -2,8 +2,10 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentAssertions; using FluentValidation; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Moq; +using SeaHaven.DataServices.Implementation; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; using SeaHaven.Services.Configuration; @@ -573,21 +575,8 @@ public class VendorServiceTests GoogleMapsUrl = "https://owner.example/map" } }); - data.Setup(x => x.GetVendorsForFacetsAsync(true, It.IsAny())) - .ReturnsAsync(new List - { - new() - { - Id = 10, - CompanyName = "Owner Co", - ContactName = "Pat", - City = "TechCity", - State = "TS", - Email = "tech@example.com", - CompanyPhone = "(555) 000-9999", - TradeSpecialties = "Plumbing" - } - }); + data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) + .ReturnsAsync(new List()); var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None); @@ -608,7 +597,9 @@ public class VendorServiceTests location.State.Should().Be("OS"); location.Label.Should().Be("OwnerCity, OS"); - facets.Trades.Should().BeEquivalentTo(new[] { "Plumbing" }); + // SH-249: technician free text is no longer a trades source; the canonical + // vocabulary comes from the Trade reference table instead. + facets.Trades.Should().BeEmpty(); } [Fact] @@ -622,8 +613,8 @@ public class VendorServiceTests new() { Id = 2, Name = "alpha co" }, new() { Id = 3, Name = "Alpha Co" } }); - data.Setup(x => x.GetVendorsForFacetsAsync(null, It.IsAny())) - .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) + .ReturnsAsync(new List()); var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None); @@ -635,19 +626,68 @@ public class VendorServiceTests } [Fact] - public async Task GetFacetsAsync_TradesRemainDerivedFromTechniciansAndIgnoreCompanies() + public async Task GetFacetsAsync_TradesComeFromCanonicalReferenceDataNotTechnicianFreeText() { + using var cts = new CancellationTokenSource(); var data = new Mock(); data.Setup(x => x.GetCompaniesForFacetsAsync(true, It.IsAny())) .ReturnsAsync(new List { new() { Id = 1, Name = "No Trade Co" } }); - data.Setup(x => x.GetVendorsForFacetsAsync(true, It.IsAny())) - .ReturnsAsync(new List - { - new() { Id = 20, CompanyName = "Trade Co", TradeSpecialties = "HVAC, Roofing" } - }); + data.Setup(x => x.GetActiveTradesAsync(cts.Token)) + .ReturnsAsync(TradeCatalog.CanonicalTrades + .Select((name, index) => new Trade + { + Id = index + 1, + Name = name, + NormalizedName = TradeCatalog.Normalize(name), + SortOrder = index + 1 + }) + .ToList()); - var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None); + var facets = await NewService(data).GetFacetsAsync(true, cts.Token); - facets.Trades.Should().BeEquivalentTo(new[] { "HVAC", "Roofing" }, opts => opts.WithStrictOrdering()); + // SH-249: facets serve the canonical Trade vocabulary in catalog order, + // regardless of technician TradeSpecialties free text. + facets.Trades.Should().BeEquivalentTo( + TradeCatalog.CanonicalTrades, + opts => opts.WithStrictOrdering()); + + // G7 evidence: the caller's cancellation token reaches the data service. + data.Verify(x => x.GetActiveTradesAsync(cts.Token), Times.Once); + } + + [Fact] + public async Task GetFacetsAsync_ReturnsCanonicalTradesOnEmptyVendorDatabase() + { + await using var context = new ApplicationDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); + + // Empty vendor/company data; only the canonical Trade reference rows exist + // (in production these are seeded by the SH249_TradesVocabulary migration). + context.Trades.AddRange(TradeCatalog.CanonicalTrades.Select((name, index) => new Trade + { + Id = index + 1, + Name = name, + NormalizedName = TradeCatalog.Normalize(name), + SortOrder = index + 1 + })); + await context.SaveChangesAsync(); + + var service = new VendorService( + new VendorDataService(context), + Mock.Of(), + Mock.Of(), + Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), + new CreateVendorValidation(), + new UpdateVendorValidation(), + new WorkOrderVendorUpdateValidation()); + + var facets = await service.GetFacetsAsync(null, CancellationToken.None); + + facets.Companies.Should().BeEmpty(); + facets.Locations.Should().BeEmpty(); + facets.Trades.Should().BeEquivalentTo( + TradeCatalog.CanonicalTrades, + opts => opts.WithStrictOrdering()); } } diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index f804b72..fb8b588 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -186,6 +186,19 @@ namespace Data.SeaHavenIndustries .HasIndex(p => new { p.LocationId, p.VendorId }) .IsUnique() .HasFilter("[Trade] IS NULL"); + + // SH-249 bounded lengths: unique index keys must not be nvarchar(max). + builder.Entity() + .Property(t => t.Name) + .HasMaxLength(128); + + builder.Entity() + .Property(t => t.NormalizedName) + .HasMaxLength(128); + + builder.Entity() + .HasIndex(t => t.NormalizedName) + .IsUnique(); } public DbSet Categories { get; set; } public DbSet Locations { get; set; } @@ -207,6 +220,7 @@ namespace Data.SeaHavenIndustries public DbSet WorkOrderReconciliationJobs { get; set; } public DbSet WorkOrderExternalReceipts { get; set; } public DbSet DropdownOptions { get; set; } + public DbSet Trades { get; set; } public DbSet Vendors { get; set; } public DbSet VendorCompanies { get; set; } public DbSet VendorAuditLogs { get; set; } 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/20260818150533_SH249_TradesVocabulary.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260818150533_SH249_TradesVocabulary.Designer.cs new file mode 100644 index 0000000..9e6137f --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260818150533_SH249_TradesVocabulary.Designer.cs @@ -0,0 +1,3897 @@ +// +using System; +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260818150533_SH249_TradesVocabulary")] + partial class SH249_TradesVocabulary + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.HasSequence("WorkOrderInternalNumberSequence"); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrencyCode") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Importance") + .HasColumnType("int"); + + b.Property("IndustryId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerEmployeeId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceInstructions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("TerritoryId") + .HasColumnType("int"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("AddressType") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("nvarchar(max)"); + + b.Property("County") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ContactId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Color") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Contact") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeletedDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("Initials") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("Locations") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UniqueName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserImage") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalSourceEmailS3Key") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TemplateUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceKey", "IsActive"); + + b.ToTable("CompletionDocTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Territory") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("ContactDetails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CommercialStatusUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("EstimatedArrivalAt") + .HasColumnType("datetime2"); + + b.Property("EtaManualOverride") + .HasColumnType("bit"); + + b.Property("InvoiceNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchChecklistItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Signature") + .HasColumnType("nvarchar(max)"); + + b.Property("SignatureMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignoffType") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.ToTable("DispatchSignoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EscalatedAt") + .HasColumnType("datetime2"); + + b.Property("EvidenceDocumentId") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("InitialNotificationSentAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NotificationError") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RequestKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId") + .IsUnique() + .HasFilter("[Status] IN ('Pending', 'ChangesRequested')"); + + b.HasIndex("EvidenceDocumentId"); + + b.HasIndex("DispatchId", "RequestKey") + .IsUnique() + .HasFilter("[RequestKey] IS NOT NULL"); + + b.ToTable("DispatchUpliftRequests"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Events", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllDay") + .HasColumnType("bit"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EndTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("EventColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Location") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("RecordID") + .HasColumnType("int"); + + b.Property("RecordName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecordTargetType") + .HasColumnType("nvarchar(max)"); + + b.Property("ReminderMinutes") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalLocationId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Trade") + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("LocationId", "VendorId") + .IsUnique() + .HasFilter("[Trade] IS NULL"); + + b.HasIndex("LocationId", "VendorId", "Trade") + .IsUnique() + .HasFilter("[Trade] IS NOT NULL"); + + b.ToTable("SitePreferredVendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TaskListTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TaskListTemplateId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TaskListTemplateId"); + + b.ToTable("TaskListTemplateItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ContactId"); + + b.HasIndex("LocationId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Trade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Trades"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PreferredContact") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TradeSpecialties") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAccessTokens"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Actor") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("VendorCompanies"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("RejectionReason") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplacesDocumentId") + .HasColumnType("int"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime2"); + + b.Property("ScanStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScannedAt") + .HasColumnType("datetime2"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("Version") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("VendorCompletionDocuments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignDate") + .HasColumnType("date"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("AvettaTask") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("Building") + .HasColumnType("nvarchar(max)"); + + b.Property("CarriedOver") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Customer") + .HasColumnType("nvarchar(max)"); + + b.Property("DateReported") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocStatus") + .HasColumnType("int"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalAssignedTo") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalLastOccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalRecordType") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalWorkOrderId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExtraServices") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FlagColor") + .HasMaxLength(7) + .HasColumnType("nvarchar(7)"); + + b.Property("InternalWONumber") + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("IsAddOn") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LegacyStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("OperationalFlags") + .HasColumnType("int"); + + b.Property("OriginalDate") + .HasColumnType("date"); + + b.Property("OriginalWeek") + .HasColumnType("date"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("PocName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PocNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PocPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("PrimaryDispatchId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("Problem") + .HasColumnType("nvarchar(max)"); + + b.Property("RescheduleCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduleWeekOnly") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("ScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ServiceNotes") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Source") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceEmailS3Key") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("SubTrade") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("TargetWeek") + .HasColumnType("date"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.Property("istemplate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ExternalWorkOrderId") + .IsUnique() + .HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); + + b.HasIndex("InternalWONumber") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("SiteCode") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("WorkOrderType"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + + b.ToTable("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderAttachments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("ActorType") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderExternalReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VersionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Kind", "ExternalId") + .IsUnique() + .HasDatabaseName("IX_WorkOrderExternalReceipts_Source_Kind_ExternalId"); + + b.ToTable("WorkOrderExternalReceipts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("LockedAt") + .HasColumnType("datetime2"); + + b.Property("LockedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "FieldName") + .IsUnique(); + + b.ToTable("WorkOrderFieldLocks"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderReconciliationJob", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CommentsProcessed") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ErrorCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FenceToken") + .HasColumnType("uniqueidentifier"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("Reason") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequestedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("RunId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("WorkOrdersProcessed") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("State"); + + b.ToTable("WorkOrderReconciliationJobs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWebhookDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BodySha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeliveryId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("DeliveryId") + .IsUnique(); + + b.ToTable("WorkOrderWebhookDeliveries"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("SourceWeekStart") + .HasColumnType("date"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "SourceWeekStart") + .IsUnique(); + + b.ToTable("WorkOrderWeekRolledLedgers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Addresses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Comments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Comments") + .HasForeignKey("WorkerOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApplicationUser"); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("Dispatches") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Dispatches") + .HasForeignKey("WorkOrderId"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("ChecklistItems") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Signoffs") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany() + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.VendorCompletionDocument", "EvidenceDocument") + .WithMany() + .HasForeignKey("EvidenceDocumentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("EvidenceDocument"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("DispatchWorkOrders") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.HasOne("Data.SeaHavenIndustries.Department", "Department") + .WithMany("Employees") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.JobTitle", "JobTitle") + .WithMany("Employees") + .HasForeignKey("JobTitleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Region", "Region") + .WithMany("Employees") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Department"); + + b.Navigation("JobTitle"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithOne("Address") + .HasForeignKey("Data.SeaHavenIndustries.EmployeeAddress", "EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Emails") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Phones") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Employee"); + + b.Navigation("Location"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.HasOne("Data.SeaHavenIndustries.Assets", "Asset") + .WithMany("PMSchedules") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Asset"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contacts") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Quotes") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Contacts"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.HasOne("Data.SeaHavenIndustries.Quotes", "Quotes") + .WithMany("LineItems") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("SitePreferences") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") + .WithMany("Items") + .HasForeignKey("TaskListTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company") + .WithMany("Vendors") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("AuditLogs") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("CompletionDocuments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("WorkOrders") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("workOrders") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch") + .WithMany() + .HasForeignKey("PrimaryDispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("workOrderAttachments") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("WorkOrderCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderCategories") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Category"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany("WorkOrderContacts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderContacts") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("POC"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("FieldLocks") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Navigation("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Navigation("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Navigation("ChecklistItems"); + + b.Navigation("Comments"); + + b.Navigation("CompletionDocuments"); + + b.Navigation("DispatchWorkOrders"); + + b.Navigation("Signoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Navigation("Address"); + + b.Navigation("Emails"); + + b.Navigation("Phones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Navigation("Templates"); + + b.Navigation("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Navigation("LineItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Navigation("AuditLogs"); + + b.Navigation("Dispatches"); + + b.Navigation("SitePreferences"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Navigation("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Navigation("Comments"); + + b.Navigation("Dispatches"); + + b.Navigation("FieldLocks"); + + b.Navigation("Quotes"); + + b.Navigation("WorkOrderCategories"); + + b.Navigation("WorkOrderContacts"); + + b.Navigation("workOrderAttachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260818150533_SH249_TradesVocabulary.cs b/Data.SeaHavenIndustries/Migrations/20260818150533_SH249_TradesVocabulary.cs new file mode 100644 index 0000000..3d35bef --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260818150533_SH249_TradesVocabulary.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class SH249_TradesVocabulary : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Trades", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + NormalizedName = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + SortOrder = table.Column(type: "int", nullable: false), + IsActive = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Trades", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Trades_NormalizedName", + table: "Trades", + column: "NormalizedName", + unique: true); + + // SH-249 canonical trade vocabulary. PROVISIONAL: sourced from the + // in-repo prototype seed in Program.cs; Product must confirm before + // release. Keep literals in sync with TradeCatalog.CanonicalTrades + // (tests assert the applied table matches the catalog). Extend the + // vocabulary after this ships only through new migrations. + migrationBuilder.InsertData( + table: "Trades", + columns: new[] { "Id", "Name", "NormalizedName", "SortOrder", "IsActive" }, + values: new object[,] + { + { 1, "Plumbing", "plumbing", 1, true }, + { 2, "Electrical", "electrical", 2, true }, + { 3, "HVAC", "hvac", 3, true }, + { 4, "Fire/Life Safety", "fire/life safety", 4, true }, + { 5, "General", "general", 5, true }, + { 6, "Roofing", "roofing", 6, true }, + { 7, "Janitorial", "janitorial", 7, true }, + { 8, "Painting", "painting", 8, true }, + { 9, "Locksmith", "locksmith", 9, true }, + { 10, "Carpentry", "carpentry", 10, true } + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Trades"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 813853c..a87db56 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2003,6 +2003,38 @@ namespace Data.SeaHavenIndustries.Migrations b.ToTable("Templates"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.Trade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Trades"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => { b.Property("Id") @@ -2458,6 +2490,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/Trade.cs b/Data.SeaHavenIndustries/Models/Trade.cs new file mode 100644 index 0000000..21d4bec --- /dev/null +++ b/Data.SeaHavenIndustries/Models/Trade.cs @@ -0,0 +1,15 @@ +namespace Data.SeaHavenIndustries +{ + // SH-249: server-owned trade reference data backing the vendor facets + // vocabulary. Canonical options no longer derive from free-text + // Vendor.TradeSpecialties values; stored vendor strings are preserved + // unchanged and remain filterable. + public class Trade + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string NormalizedName { get; set; } = ""; + public int SortOrder { get; set; } + public bool IsActive { get; set; } = true; + } +} diff --git a/Data.SeaHavenIndustries/Models/TradeCatalog.cs b/Data.SeaHavenIndustries/Models/TradeCatalog.cs new file mode 100644 index 0000000..f35ba4f --- /dev/null +++ b/Data.SeaHavenIndustries/Models/TradeCatalog.cs @@ -0,0 +1,28 @@ +namespace Data.SeaHavenIndustries +{ + // SH-249 PROVISIONAL canonical trade vocabulary. Sourced from the + // in-repo prototype seed (Api.SeaHavenIndustries/Program.cs, disabled + // development seeding) because no authoritative Product list exists in + // the repo. Product must confirm the list before release; after the + // SH249_TradesVocabulary migration ships, extend the vocabulary only + // through new migrations (and keep this catalog in sync — tests assert + // the seeded table matches this catalog). + public static class TradeCatalog + { + public static readonly IReadOnlyList CanonicalTrades = new[] + { + "Plumbing", + "Electrical", + "HVAC", + "Fire/Life Safety", + "General", + "Roofing", + "Janitorial", + "Painting", + "Locksmith", + "Carpentry" + }; + + public static string Normalize(string name) => name.Trim().ToLowerInvariant(); + } +} 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 eee4e96..0213219 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, @@ -128,6 +129,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/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index 670ce1c..01a5be6 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -66,17 +66,13 @@ namespace SeaHaven.DataServices.Implementation .ToListAsync(cancellationToken); } - public async Task> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken) + public async Task> GetActiveTradesAsync(CancellationToken cancellationToken) { - var query = _context.Vendors + return await _context.Trades .AsNoTracking() - .Where(v => v.IsDeleted == null || v.IsDeleted == false); - - if (isActive.HasValue) - query = query.Where(v => v.IsActive == isActive.Value); - - return await query - .OrderByDescending(v => v.Id) + .Where(t => t.IsActive) + .OrderBy(t => t.SortOrder) + .ThenBy(t => t.Name) .ToListAsync(cancellationToken); } 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/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index c632c75..069519a 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -13,7 +13,11 @@ namespace SeaHaven.DataServices.Interfaces Task> GetByIdsOrderedAsync(IReadOnlyCollection ids, CancellationToken cancellationToken); Task> GetAllAsync(); Task> GetActiveVendorsAsync(CancellationToken cancellationToken); - Task> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken); + + // SH-249: canonical trade vocabulary (server-owned reference data) for + // vendor facets. Active trades ordered by SortOrder then Name; independent + // of technician Vendor.TradeSpecialties free text. + Task> GetActiveTradesAsync(CancellationToken cancellationToken); // SH-198 facet fix: company-owned facet profiles sourced from VendorCompanies, // with status semantics aligned to BuildDirectoryUnionQuery. Null returns all 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 8d8ecfb..6c452b9 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 8f78e36..6ed0366 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -35,6 +35,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/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index fe82c09..4944a58 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -623,10 +623,12 @@ namespace SeaHaven.Services.Implementation public async Task GetFacetsAsync(bool? isActive, CancellationToken cancellationToken) { // Companies and Locations are sourced from company-owned VendorCompany fields - // (status semantics aligned to the directory union); Trades remain derived from - // non-deleted technician Vendors. + // (status semantics aligned to the directory union). Trades come from the + // SH-249 server-owned canonical Trade reference data — a stable list that + // does not derive from technician TradeSpecialties free text, so it is + // populated even on an empty database and independent of the status filter. var companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken); - var vendors = await _vendorDataService.GetVendorsForFacetsAsync(isActive, cancellationToken); + var canonicalTrades = await _vendorDataService.GetActiveTradesAsync(cancellationToken); var companyFacets = companies .Where(c => !string.IsNullOrWhiteSpace(c.Name)) @@ -647,11 +649,7 @@ namespace SeaHaven.Services.Implementation GoogleMapsUrl = c.GoogleMapsUrl }); - var trades = vendors - .SelectMany(v => (v.TradeSpecialties ?? "") - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(value => value); + var trades = canonicalTrades.Select(t => t.Name).ToList(); var locations = companies .Where(c => !string.IsNullOrWhiteSpace(c.City) || !string.IsNullOrWhiteSpace(c.State)) 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 a03368b..c4de860 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 5fa8537..2e4f78b 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/SH249TradesVocabularyMigrationTests.cs b/SeaHavenIndustries.Tests/SH249TradesVocabularyMigrationTests.cs new file mode 100644 index 0000000..7d55632 --- /dev/null +++ b/SeaHavenIndustries.Tests/SH249TradesVocabularyMigrationTests.cs @@ -0,0 +1,80 @@ +using System.Reflection; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Migrations; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace SeaHavenIndustries.Tests; + +// SH-249: provider-backed migration evidence for the Trades reference table. +// Applies the real migration operations (Up, then Down) against SQLite and +// asserts the seeded canonical vocabulary matches TradeCatalog. +public class SH249TradesVocabularyMigrationTests +{ + [Fact] + public async Task Up_CreatesTradesTableSeededWithCanonicalCatalogOnEmptyDatabase() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new ApplicationDbContext(options); + await ApplyMigrationAsync(context, "Up"); + + var seeded = await context.Trades + .OrderBy(t => t.SortOrder) + .ToListAsync(); + + Assert.Equal( + TradeCatalog.CanonicalTrades, + seeded.Select(t => t.Name).ToArray()); + Assert.All(seeded, t => Assert.True(t.IsActive)); + Assert.Equal( + TradeCatalog.CanonicalTrades.Select(TradeCatalog.Normalize).ToArray(), + seeded.Select(t => t.NormalizedName).ToArray()); + } + + [Fact] + public async Task Down_DropsTradesTable() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new ApplicationDbContext(options); + await ApplyMigrationAsync(context, "Up"); + await ApplyMigrationAsync(context, "Down"); + + await using var command = connection.CreateCommand(); + command.CommandText = + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'Trades';"; + var tableCount = Convert.ToInt32(await command.ExecuteScalarAsync()); + + Assert.Equal(0, tableCount); + } + + // Mirrors Phase4SearchIndexesSqlServerTests: invoke the protected Up/Down + // body, translate through the configured provider's SQL generator, and + // execute the produced commands against the live database. + private static async Task ApplyMigrationAsync(ApplicationDbContext context, string direction) + { + var builder = new MigrationBuilder(context.Database.ProviderName!); + var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Migration.{direction} not found."); + operation.Invoke(new SH249_TradesVocabulary(), [builder]); + + var sqlGenerator = context.GetService(); + // InsertData operations need model metadata for column type mapping. + var commands = sqlGenerator.Generate(builder.Operations, context.Model); + foreach (var command in commands) + await context.Database.ExecuteSqlRawAsync(command.CommandText); + } +} 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 2040d88..dc536ce 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -396,6 +396,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 }); @@ -416,6 +417,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 b5dae37..d93d40b 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). diff --git a/infra/cdk/package-lock.json b/infra/cdk/package-lock.json index c596848..0ffb1ce 100644 --- a/infra/cdk/package-lock.json +++ b/infra/cdk/package-lock.json @@ -8,12 +8,12 @@ "name": "shoc-backend-cdk", "version": "0.1.0", "dependencies": { - "aws-cdk-lib": "2.262.1", + "aws-cdk-lib": "2.265.0", "constructs": "10.8.1" }, "devDependencies": { "@types/node": "26.2.0", - "aws-cdk": "2.1135.1", + "aws-cdk": "2.1137.0", "typescript": "7.0.2" }, "engines": { @@ -21,9 +21,9 @@ } }, "node_modules/@aws-cdk/asset-awscli-v1": { - "version": "2.2.282", - "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.282.tgz", - "integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g==", + "version": "2.2.292", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.292.tgz", + "integrity": "sha512-d4aMFsAFj19FtxVyw8IzlUKv5Zu4sIvgnEjI2IU6IWBJgVbJ4aFnadANqYa+6MwB1CQbGOg0jh8WE77M+Nb/9A==", "license": "Apache-2.0" }, "node_modules/@aws-cdk/asset-node-proxy-agent-v6": { @@ -419,9 +419,9 @@ } }, "node_modules/aws-cdk": { - "version": "2.1135.1", - "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1135.1.tgz", - "integrity": "sha512-g1jcMfWlyYtGamFJ/kPBOCuchl3NfwTF2UwOLTIDN0nJbGm84EAO+c8DlYnaemM8UmKFkdoq4BGdmiNL5nHWwA==", + "version": "2.1137.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1137.0.tgz", + "integrity": "sha512-SRy2df/5cqGvq8ncph1m93rWEh3RGbvP6uwElw+W0x135vZDzTpfbuq2L8oAHwK2yDt3RNS5sb7Phwb7xqQP2g==", "dev": true, "license": "Apache-2.0", "bin": { @@ -432,9 +432,9 @@ } }, "node_modules/aws-cdk-lib": { - "version": "2.262.1", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.262.1.tgz", - "integrity": "sha512-B6YP4r6ojUZCDhl+qBu/CrWzcipR8sIgshcqYvgw013sghPXmVkYdJ3yuI9+DKML3YLSjQrHy1nGJs+Nqq7JCg==", + "version": "2.265.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.265.0.tgz", + "integrity": "sha512-Ur96d/ZW+gqAW4XtqKQp//D9O/jQvyRoXhAqoHGybpV03qjckGIQcLcGkLV68+jv03TX/rOXHCsa2IEFcCXa4w==", "bundleDependencies": [ "@aws/cloudformation-validate", "@balena/dockerignore", @@ -451,11 +451,11 @@ ], "license": "Apache-2.0", "dependencies": { - "@aws-cdk/asset-awscli-v1": "2.2.282", + "@aws-cdk/asset-awscli-v1": "2.2.292", "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2", "@aws-cdk/cloud-assembly-api": "^2.2.6", "@aws-cdk/cloud-assembly-schema": "^54.11.0", - "@aws/cloudformation-validate": "1.5.1-beta", + "@aws/cloudformation-validate": "1.7.0-beta", "@balena/dockerignore": "^1.0.2", "case": "1.6.3", "fs-extra": "^11.3.6", @@ -490,7 +490,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/@aws/cloudformation-validate": { - "version": "1.5.1-beta", + "version": "1.7.0-beta", "inBundle": true, "license": "Apache-2.0", "engines": { @@ -511,14 +511,14 @@ } }, "node_modules/aws-cdk-lib/node_modules/brace-expansion": { - "version": "5.0.7", + "version": "5.0.9", "inBundle": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/aws-cdk-lib/node_modules/case": { diff --git a/infra/cdk/package.json b/infra/cdk/package.json index 0f6ff06..c79b316 100644 --- a/infra/cdk/package.json +++ b/infra/cdk/package.json @@ -13,12 +13,12 @@ "deploy": "npm run build && cdk deploy" }, "dependencies": { - "aws-cdk-lib": "2.262.1", + "aws-cdk-lib": "2.265.0", "constructs": "10.8.1" }, "devDependencies": { "@types/node": "26.2.0", - "aws-cdk": "2.1135.1", + "aws-cdk": "2.1137.0", "typescript": "7.0.2" } }