diff --git a/.env.example b/.env.example index cb07e57..db65d8b 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ -# Sea Haven Industries — shoc-backend required configuration +# Sea Haven Industries ÔÇö shoc-backend required configuration # # This file documents the secrets that used to be hardcoded in appsettings*.json # and source files. Copy the values into one of the supported configuration sources; # do NOT commit real values. # # .NET resolves configuration in this order (later wins): -# 1. appsettings.json / appsettings.{Environment}.json (committed — placeholders only) +# 1. appsettings.json / appsettings.{Environment}.json (committed ÔÇö placeholders only) # 2. User Secrets (local dev): dotnet user-secrets set "Key:Sub" "value" # 3. Environment variables (use "__" as the section separator) # @@ -32,3 +32,16 @@ GoogleMaps__ApiKey=AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX # AWS_ACCESS_KEY_ID= # AWS_SECRET_ACCESS_KEY= # AWS_REGION=us-east-2 + +# --- Work Order ingest (Lambda cutover) --- +# Leave Enabled=false until ApiKey is a real secret (min 32 chars). Do not use appsettings placeholders. +WorkOrderIngest__Enabled=false +WorkOrderIngest__ApiKey= + +# --- Dynamo sync bridge (keep false after Lambda cutover; avoid dual-run with ingest) --- +Sync__Enabled=true + +# --- Legacy endpoint deprecation headers --- +LegacyEndpoints__DeprecationEnabled=false +LegacyEndpoints__SunsetDate=2026-12-31 + diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs index 71b592b..7d9aa08 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs @@ -25,7 +25,7 @@ public class AuthenticationServiceTests var (manager, s, h) = IdentityTestHelpers.CreateUserManager(); store = s; hasher = h; - return new AuthenticationService(manager, Options.Create(JwtOptions), userData.Object, forget.Object, email.Object); + return new AuthenticationService(manager, Microsoft.Extensions.Options.Options.Create(JwtOptions), userData.Object, forget.Object, email.Object); } private static JwtOptions JwtOptions => new() diff --git a/Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs b/Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs index 97de3a7..bf994ec 100644 --- a/Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs +++ b/Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs @@ -42,7 +42,7 @@ public class DispatchBatchingBehaviorTests Mock.Of(), emailPort.Object, tokenPort.Object, - Options.Create(new FrontendOptions())); + Microsoft.Extensions.Options.Options.Create(new FrontendOptions())); } private static Mock WorkOrderDataReturning(params WorkOrder[] workOrders) => diff --git a/Api.SeaHavenIndustries.Tests/IdentityTestHelpers.cs b/Api.SeaHavenIndustries.Tests/IdentityTestHelpers.cs index 469bedc..d1d7906 100644 --- a/Api.SeaHavenIndustries.Tests/IdentityTestHelpers.cs +++ b/Api.SeaHavenIndustries.Tests/IdentityTestHelpers.cs @@ -17,7 +17,7 @@ internal static class IdentityTestHelpers var manager = new UserManager( store.Object, - Options.Create(new IdentityOptions()), + Microsoft.Extensions.Options.Options.Create(new IdentityOptions()), hasher.Object, new List>(), new List>(), diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs new file mode 100644 index 0000000..ebbd3d6 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs @@ -0,0 +1,110 @@ +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class LocationControllerSitesTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static LocationController NewController(ApplicationDbContext ctx) + { + var dataService = new LocationDataService(ctx); + var service = new LocationService( + dataService, + Mock.Of(), + Mock.Of()); + + var controller = new LocationController(service, Mock.Of>()) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + + return controller; + } + + private static async Task SeedSitesAsync(ApplicationDbContext ctx) + { + ctx.Locations.AddRange( + new Locations { Id = 1, Name = "STL8", City = "St. Louis", State = "MO" }, + new Locations { Id = 2, Name = "BK5", City = "Dallas", State = "TX" }, + new Locations { Id = 3, Name = "IND9", City = "Indianapolis", State = "IN" }, + new Locations { Id = 4, Name = "DFW8", City = "Dallas", State = "TX" }); + await ctx.SaveChangesAsync(); + } + + [Fact] + public async Task GetSites_ReturnsIdCodeCityState_OrderedByCode() + { + await using var ctx = NewContext(); + await SeedSitesAsync(ctx); + var controller = NewController(ctx); + + var result = await controller.GetSites(); + + var ok = result.Should().BeOfType().Subject; + var data = ok.Value.Should().BeAssignableTo>().Subject; + + data.Should().HaveCount(4); + data.Select(s => s.Code).Should().ContainInOrder("BK5", "DFW8", "IND9", "STL8"); + data[0].Should().BeEquivalentTo(new SiteOptionDTO + { + Id = 2, + Code = "BK5", + City = "Dallas", + State = "TX" + }); + } + + [Fact] + public async Task GetSites_FiltersByCode() + { + await using var ctx = NewContext(); + await SeedSitesAsync(ctx); + var controller = NewController(ctx); + + var result = await controller.GetSites("bk5"); + + var ok = result.Should().BeOfType().Subject; + var data = ok.Value.Should().BeAssignableTo>().Subject; + + data.Should().ContainSingle(); + data[0].Code.Should().Be("BK5"); + } + + [Fact] + public async Task GetSites_FiltersByCity() + { + await using var ctx = NewContext(); + await SeedSitesAsync(ctx); + var controller = NewController(ctx); + + var result = await controller.GetSites("Dallas"); + + var ok = result.Should().BeOfType().Subject; + var data = ok.Value.Should().BeAssignableTo>().Subject; + + data.Should().HaveCount(2); + data.Select(s => s.Code).Should().BeEquivalentTo("BK5", "DFW8"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs b/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs index 1c84083..343d5f7 100644 --- a/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs +++ b/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs @@ -210,7 +210,7 @@ public class WorkOrderDispatchServiceBehaviorTests Mock.Of(), Mock.Of(), Mock.Of(), - Options.Create(new FrontendOptions())); + Microsoft.Extensions.Options.Options.Create(new FrontendOptions())); } } diff --git a/Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs new file mode 100644 index 0000000..a6c6e68 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs @@ -0,0 +1,319 @@ +using System.Collections; +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class VendorOperationsControllerTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static VendorOperationsController NewController(ApplicationDbContext context) + { + var dataService = new VendorOperationsDataService(context, new DispatchDataService(context)); + var service = new VendorOperationsService(dataService, TimeProvider.System); + var controller = new VendorOperationsController(service); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") }, "test")) + } + }; + return controller; + } + + private static object Prop(object source, string name) => + source.GetType().GetProperty(name)!.GetValue(source)!; + + [Fact] + public async Task Notifications_ReturnsUnassignedWorkOrderInsideFortyEightHours() + { + using var context = NewContext(); + context.workOrders.Add(new WorkOrder + { + InternalWONumber = "WO-48", + WorkerOrderTitle = "Needs vendor", + ScheduledDate = DateTime.UtcNow.AddHours(47) + }); + await context.SaveChangesAsync(); + + var result = await NewController(context).GetNotifications(CancellationToken.None); + + var value = ((OkObjectResult)result).Value!; + var items = ((IEnumerable)Prop(value, "Items")).Cast().ToList(); + items.Should().ContainSingle(); + Prop(items[0], "Type").Should().Be("NoVendor"); + Prop(items[0], "Title").Should().Be("No vendor assigned — 48h to service"); + } + + [Fact] + public async Task Notifications_RemovesReminderAsSoonAsVendorIsAssigned() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder + { + InternalWONumber = "WO-ASSIGNED", + ScheduledDate = DateTime.UtcNow.AddHours(24) + }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + context.Dispatches.Add(new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + Status = "Sent", + ScheduledDate = workOrder.ScheduledDate + }); + await context.SaveChangesAsync(); + + var result = await NewController(context).GetNotifications(CancellationToken.None); + + var items = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Items")).Cast(); + items.Should().BeEmpty(); + } + + [Fact] + public async Task Availability_SurfacesOverlapAndRanksPreferredVendorFirst() + { + using var context = NewContext(); + var site = new Locations { Name = "St Louis" }; + var preferred = new Vendor + { + CompanyName = "Preferred", + ContactName = "Alex", + IsActive = true, + TradeSpecialties = "HVAC", + AvailabilityStatus = "Available", + AvailabilityUpdatedAt = DateTime.UtcNow + }; + var busy = new Vendor { CompanyName = "Busy", ContactName = "Sam", IsActive = true }; + var workOrder = new WorkOrder + { + InternalWONumber = "WO-BUSY", + ScheduledStart = DateTime.UtcNow.AddHours(1), + ScheduledEnd = DateTime.UtcNow.AddHours(3) + }; + context.AddRange(site, preferred, busy, workOrder); + await context.SaveChangesAsync(); + context.SitePreferredVendors.Add(new SitePreferredVendor + { + LocationId = site.Id, + VendorId = preferred.Id, + Trade = "HVAC", + SortOrder = 0 + }); + context.Dispatches.Add(new Dispatch + { + VendorId = busy.Id, + WorkOrderId = workOrder.Id, + Status = "Sent", + ScheduledDate = workOrder.ScheduledStart + }); + await context.SaveChangesAsync(); + + var result = await NewController(context).GetAvailability( + DateTime.UtcNow.AddHours(2), + DateTime.UtcNow.AddHours(4), + site.Id, + "HVAC", + CancellationToken.None); + + var rows = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Data")).Cast().ToList(); + Prop(rows[0], "CompanyName").Should().Be("Preferred"); + Prop(rows[0], "IsPreferred").Should().Be(true); + var busyRow = rows.Single(row => (string?)Prop(row, "CompanyName") == "Busy"); + Prop(busyRow, "AvailabilityStatus").Should().Be("Unavailable"); + } + + [Fact] + public async Task ReplaceSitePreferences_RejectsInactiveVendor() + { + using var context = NewContext(); + var site = new Locations { Name = "St Louis" }; + var inactive = new Vendor { CompanyName = "Inactive", IsActive = false }; + context.AddRange(site, inactive); + await context.SaveChangesAsync(); + + var result = await NewController(context).ReplaceSitePreferences(site.Id, + new List + { + new() { VendorId = inactive.Id } + }, + CancellationToken.None); + + result.Should().BeOfType(); + context.SitePreferredVendors.Should().BeEmpty(); + } + + [Fact] + public async Task InsightsExports_ReturnStableCsvAndPdfFormats() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "=HYPERLINK(\"https://invalid.test\")", IsActive = false }; + var workOrder = new WorkOrder { DueDate = DateTime.UtcNow.AddHours(1), Trade = "Plumbing" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + context.Dispatches.Add(new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + Status = "Completed", + DispatchedAt = DateTime.UtcNow.AddHours(-4), + ScheduledDate = DateTime.UtcNow.AddHours(-2), + AcknowledgedAt = DateTime.UtcNow.AddHours(-3), + CompletedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var controller = NewController(context); + + var csv = (FileContentResult)await controller.ExportInsightsCsv(null, null, null, null, null, CancellationToken.None); + var pdf = (FileContentResult)await controller.ExportInsightsPdf(null, null, null, null, null, CancellationToken.None); + + csv.ContentType.Should().Be("text/csv"); + var csvText = System.Text.Encoding.UTF8.GetString(csv.FileContents); + csvText.Should().Contain("AverageCycleHours"); + csvText.Should().Contain("\"'=HYPERLINK(\"\"https://invalid.test\"\")\""); + pdf.ContentType.Should().Be("application/pdf"); + System.Text.Encoding.ASCII.GetString(pdf.FileContents, 0, 8).Should().StartWith("%PDF-1.4"); + } + + [Fact] + public async Task AssignVendor_RejectsInactiveVendor() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Inactive", IsActive = false }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + + var result = await NewController(context).AssignVendor( + workOrder.Id, + new VendorAssignmentRequest { VendorId = vendor.Id }, + CancellationToken.None); + + result.Should().BeOfType(); + context.Dispatches.Should().BeEmpty(); + } + + [Fact] + public async Task AssignVendor_PersistsEtaAuditAndDisclosesConflicts() + { + using var context = NewContext(); + var start = DateTime.UtcNow.AddHours(2); + var vendor = new Vendor + { + CompanyName = "Gateway", + IsActive = true, + AvailabilityStatus = "Available", + AvailabilityUpdatedAt = DateTime.UtcNow + }; + var existingWorkOrder = new WorkOrder + { + InternalWONumber = "WO-EXISTING", + ScheduledStart = start, + ScheduledEnd = start.AddHours(2) + }; + var assignedWorkOrder = new WorkOrder + { + InternalWONumber = "WO-NEW", + ScheduledStart = start.AddMinutes(30), + ScheduledEnd = start.AddHours(1) + }; + context.AddRange(vendor, existingWorkOrder, assignedWorkOrder); + await context.SaveChangesAsync(); + context.Dispatches.Add(new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = existingWorkOrder.Id, + Status = "Sent", + ScheduledDate = existingWorkOrder.ScheduledStart + }); + await context.SaveChangesAsync(); + var eta = start.AddMinutes(45); + + var result = await NewController(context).AssignVendor( + assignedWorkOrder.Id, + new VendorAssignmentRequest + { + VendorId = vendor.Id, + ScheduledStart = assignedWorkOrder.ScheduledStart, + ScheduledEnd = assignedWorkOrder.ScheduledEnd, + EstimatedArrivalAt = eta, + EtaManualOverride = true + }, + CancellationToken.None); + + var value = ((OkObjectResult)result).Value!; + Prop(value, "AvailabilityStatus").Should().Be("Unavailable"); + ((IEnumerable)Prop(value, "Conflicts")).Cast().Should().ContainSingle(); + var dispatch = await context.Dispatches.SingleAsync(item => + item.WorkOrderId == assignedWorkOrder.Id); + dispatch.EstimatedArrivalAt.Should().Be(eta); + dispatch.EtaManualOverride.Should().BeTrue(); + dispatch.DispatchNumber.Should().StartWith("DISP-"); + context.WorkOrderAuditLogs.Should().ContainSingle(log => + log.WorkOrderId == assignedWorkOrder.Id && log.Action == "vendor_assigned"); + } + + [Fact] + public async Task AssignVendor_ReusesDispatchLinkedThroughMultiWorkOrderJoin() + { + using var context = NewContext(); + var originalVendor = new Vendor { CompanyName = "Original", IsActive = true }; + var replacementVendor = new Vendor { CompanyName = "Replacement", IsActive = true }; + var workOrder = new WorkOrder + { + InternalWONumber = "WO-LINKED", + ScheduledStart = DateTime.UtcNow.AddHours(2), + ScheduledEnd = DateTime.UtcNow.AddHours(4) + }; + context.AddRange(originalVendor, replacementVendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = new Dispatch + { + VendorId = originalVendor.Id, + Status = "Sent", + DispatchNumber = "DISP-LINKED" + }; + context.Dispatches.Add(dispatch); + await context.SaveChangesAsync(); + context.DispatchWorkOrders.Add(new DispatchWorkOrder + { + DispatchId = dispatch.Id, + WorkOrderId = workOrder.Id + }); + await context.SaveChangesAsync(); + + var result = await NewController(context).AssignVendor( + workOrder.Id, + new VendorAssignmentRequest + { + VendorId = replacementVendor.Id, + ScheduledStart = workOrder.ScheduledStart, + ScheduledEnd = workOrder.ScheduledEnd + }, + CancellationToken.None); + + result.Should().BeOfType(); + context.Dispatches.Should().ContainSingle(); + (await context.Dispatches.SingleAsync()).VendorId.Should().Be(replacementVendor.Id); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs new file mode 100644 index 0000000..fab68d5 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -0,0 +1,179 @@ +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.Helper; +using Api.SeaHavenIndustries.Infrastructure; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class VendorPortalDocumentTests : IDisposable +{ + private const string Token = "vendor-document-test-token"; + private readonly string _contentRoot = Path.Combine( + Path.GetTempPath(), + $"seahaven-vendor-documents-{Guid.NewGuid():N}"); + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private VendorPortalController NewController(ApplicationDbContext context) + { + var vendorData = new VendorDataService(context); + var tokenService = new VendorPortalTokenService( + vendorData, + Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions())); + var documentData = new VendorDocumentDataService(context); + var storageEnvironment = new Mock(); + storageEnvironment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot); + var storage = new VendorDocumentStorageAdapter(storageEnvironment.Object); + + var service = new VendorPortalService( + tokenService, + new DispatchDataService(context), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + documentData, + storage, + Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), + Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()), + Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions())); + + var controller = new VendorPortalController(service, Mock.Of>()); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + controller.Request.Headers["X-Vendor-Token"] = Token; + return controller; + } + + private static async Task<(Vendor Vendor, Dispatch Dispatch)> SeedDispatch( + ApplicationDbContext context) + { + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + Status = "Completed" + }; + context.Dispatches.Add(dispatch); + context.VendorAccessTokens.Add(new VendorAccessToken + { + VendorId = vendor.Id, + Token = Token, + IssuedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddDays(1) + }); + await context.SaveChangesAsync(); + return (vendor, dispatch); + } + + private static FormFile FormFile(byte[] bytes, string fileName, string contentType) + { + return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + + [Fact] + public async Task UploadCompletionDocument_RejectsMismatchedFileSignature() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile("not a pdf"u8.ToArray(), "completion.pdf", "application/pdf")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + + [Fact] + public async Task UploadCompletionDocument_QuarantinesValidFileUntilScanPasses() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + var controller = NewController(context); + var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); + + var upload = await controller.UploadCompletionDocument( + dispatch.Id, + FormFile(pdf, "completion.pdf", "application/pdf")); + + upload.Should().BeOfType(); + var document = await context.VendorCompletionDocuments.SingleAsync(); + document.ScanStatus.Should().Be("Pending"); + document.ReviewStatus.Should().Be("Processing"); + File.Exists(VendorDocumentStorage.ResolvePath(_contentRoot, document)).Should().BeTrue(); + + var download = await controller.DownloadCompletionDocument(dispatch.Id, document.Id); + + var locked = download.Should().BeOfType().Subject; + locked.StatusCode.Should().Be(StatusCodes.Status423Locked); + } + + [Fact] + public async Task UploadCompletionDocument_AcceptsMixedCasePdfContentType() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(pdf, "completion.pdf", "Application/PDF")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().HaveCount(1); + } + + [Fact] + public async Task UploadCompletionDocument_AcceptsMixedCaseImageContentType() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + var png = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00 }; + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(png, "photo.png", "Image/PNG")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().HaveCount(1); + } + + public void Dispose() + { + if (Directory.Exists(_contentRoot)) + { + Directory.Delete(_contentRoot, recursive: true); + } + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index 3f4f6bc..cc55139 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -21,7 +21,7 @@ public class VendorServiceTests data.Object, Mock.Of(), Mock.Of(), - Options.Create(new FrontendOptions()), + Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), new CreateVendorValidation(), new UpdateVendorValidation(), new WorkOrderVendorUpdateValidation()); diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs new file mode 100644 index 0000000..10ac872 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs @@ -0,0 +1,63 @@ +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class WorkOrderControllerSearchTests +{ + private static WorkOrderBoardController NewController(IWorkOrderAdvancedSearchService advancedSearch) + { + var controller = new WorkOrderBoardController( + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + advancedSearch); + + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new System.Security.Claims.ClaimsPrincipal( + new System.Security.Claims.ClaimsIdentity( + new[] + { + new System.Security.Claims.Claim( + System.Security.Claims.ClaimTypes.NameIdentifier, + "test-user") + }, + "Test")) + } + }; + + return controller; + } + + [Fact] + public async Task SearchBoard_WhenCustomPresetMissingDates_ReturnsBadRequest() + { + var advancedSearch = new WorkOrderAdvancedSearchService( + Mock.Of()); + var controller = NewController(advancedSearch); + + var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom + }); + + var badRequest = result.Should().BeOfType().Subject; + var body = badRequest.Value.Should().BeOfType().Subject; + body.Status.Should().Be("Error"); + body.Message.Should().Contain("dateFrom"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs new file mode 100644 index 0000000..7fd4260 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs @@ -0,0 +1,206 @@ +using Api.SeaHavenIndustries.Controllers; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ActionConstraints; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using Xunit.Abstractions; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// Framework-backed route contract tests that prove the public endpoint set exposed by the +/// WorkOrder URL space (api/WorkOrder, api/workorders) is preserved exactly after splitting the +/// original godfile WorkOrderController into cohesive controllers, and that no verb+route +/// combination is registered more than once by different actions. +/// +/// Routes are read from the ASP.NET Core action descriptor provider so the EXACT templates the +/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder, +/// GetWorkorderById) and the two shared controller-level base routes. +/// +public class WorkOrderRouteContractTests +{ + private readonly ITestOutputHelper _output; + + private static readonly HashSet WorkOrderFamilyControllerTypes = new() + { + typeof(WorkOrderController), + typeof(WorkOrderBoardController), + typeof(WorkOrderDetailController), + typeof(WorkOrderCompletionController), + typeof(WorkOrderMediaController), + typeof(WorkOrderDispatchController), + }; + + /// + /// Baseline public endpoint set (verb + action-relative route) that the original single + /// WorkOrderController exposed. Every action is reachable under both api/WorkOrder and + /// api/workorders; that base-route duplication is collapsed here, so this is the distinct + /// action-relative contract. 45 routes come from 43 actions (Editworkorder and + /// GetWorkorderById each bind two routes). + /// + private static readonly HashSet ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal) + { + "DELETE DeleteWorkorder", + "DELETE completion-templates/{id:int}", + "DELETE {id:int}/media/{mediaId:int}", + "GET GetComments", + "GET GetCommentsByWorkorderId", + "GET GetDispatch/{id}", + "GET GetDispatches/{workOrderId}", + "GET GetFilteredWorkorder", + "GET GetFilteredWorkorder2", + "GET GetWorkOrderList", + "GET GetWorkorderById", + "GET Getworkorders", + "GET GetworkordersDD", + "GET board", + "GET board/search", + "GET completion-templates", + "GET completion-templates/{id:int}", + "GET lookups/dispatchers", + "GET {id:int}", + "GET {id:int}/audit", + "GET {id:int}/comments", + "GET {id:int}/detail", + "GET {id:int}/media", + "PATCH {id:int}/board", + "POST AddChecklistItem", + "POST AddComment", + "POST AddCommentJson", + "POST AddDispatchComment", + "POST AddDispatchSignoff", + "POST AddWorkorder", + "POST ChangeAssignment", + "POST ChangeStatus", + "POST DispatchToVendor", + "POST EditWorkorder", + "POST UpdateChecklistItem", + "POST UpdateDispatch", + "POST VerifyDispatch", + "POST board", + "POST completion-templates", + "POST {id:int}/cancel", + "POST {id:int}/comments", + "POST {id:int}/completion-doc", + "POST {id:int}/media", + "PUT completion-templates/{id:int}", + "PUT {id:int}", + }; + + public WorkOrderRouteContractTests(ITestOutputHelper output) + { + _output = output; + } + + private static IReadOnlyList BuildActionDescriptors() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMvcCore() + .AddApplicationPart(typeof(WorkOrderController).Assembly); + + using var provider = services.BuildServiceProvider(); + var actionProvider = provider.GetRequiredService(); + return actionProvider.ActionDescriptors.Items; + } + + private static IReadOnlyList<(string Verb, string FullTemplate, string Controller, string Action)> FullRoutesFor( + Func predicate) + { + var tuples = new List<(string Verb, string FullTemplate, string Controller, string Action)>(); + foreach (var action in BuildActionDescriptors().Where(predicate)) + { + var cad = action as ControllerActionDescriptor; + var template = cad?.AttributeRouteInfo?.Template?.Trim('/'); + if (string.IsNullOrEmpty(template)) + continue; + + var methods = action.ActionConstraints + .OfType() + .SelectMany(c => c.HttpMethods) + .Distinct(StringComparer.OrdinalIgnoreCase); + + foreach (var method in methods) + tuples.Add((method.ToUpperInvariant(), template, cad!.ControllerName, cad.ActionName)); + } + + return tuples; + } + + private static string Normalize(string fullTemplate) + { + foreach (var prefix in new[] { "api/WorkOrder/", "api/workorders/" }) + { + if (fullTemplate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return fullTemplate[prefix.Length..]; + } + foreach (var exact in new[] { "api/WorkOrder", "api/workorders" }) + { + if (fullTemplate.Equals(exact, StringComparison.OrdinalIgnoreCase)) + return string.Empty; + } + return fullTemplate; + } + + [Fact] + public void WorkOrder_Family_Preserves_Expected_Public_Endpoint_Set() + { + var actual = FullRoutesFor(a => a is ControllerActionDescriptor cad + && WorkOrderFamilyControllerTypes.Contains(cad.ControllerTypeInfo)) + .Select(t => $"{t.Verb} {Normalize(t.FullTemplate)}") + .ToHashSet(StringComparer.Ordinal); + + Dump(actual); + + var missing = ExpectedWorkOrderEndpoints.Except(actual).OrderBy(x => x).ToList(); + var unexpected = actual.Except(ExpectedWorkOrderEndpoints).OrderBy(x => x).ToList(); + + missing.Should().BeEmpty("every pre-split WorkOrder endpoint must still be reachable"); + unexpected.Should().BeEmpty("the split must not introduce new public WorkOrder endpoints"); + actual.Should().HaveCount(ExpectedWorkOrderEndpoints.Count, + "the distinct action-relative endpoint count must match the baseline"); + } + + [Fact] + public void WorkOrder_Family_Has_No_Duplicate_Verb_And_Route_From_Different_Actions() + { + // Duplicate detection is done at the FULL route-template level. Each action legitimately + // resolves under both base routes (api/WorkOrder and api/workorders); those are distinct + // full templates. A true collision is the SAME full template produced by two actions, + // which would cause an AmbiguousMatchException at request time. + var collisions = FullRoutesFor(a => a is ControllerActionDescriptor cad + && WorkOrderFamilyControllerTypes.Contains(cad.ControllerTypeInfo)) + .GroupBy(t => (t.Verb, t.FullTemplate)) + .Where(g => g.Select(x => (x.Controller, x.Action)).Distinct().Count() > 1) + .Select(g => $"{g.Key.Verb} {g.Key.FullTemplate} -> {string.Join(", ", g.Select(x => $"{x.Controller}.{x.Action}"))}") + .ToList(); + + collisions.Should().BeEmpty( + "no two actions in the WorkOrder family may register the same verb+full-route"); + } + + [Fact] + public void WorkOrder_Url_Space_Has_No_Duplicate_Verb_And_Route_From_Different_Actions() + { + var collisions = FullRoutesFor(_ => true) + .Where(t => t.FullTemplate.StartsWith("api/WorkOrder", StringComparison.OrdinalIgnoreCase) + || t.FullTemplate.StartsWith("api/workorders", StringComparison.OrdinalIgnoreCase)) + .GroupBy(t => (t.Verb, t.FullTemplate)) + .Where(g => g.Select(x => (x.Controller, x.Action)).Distinct().Count() > 1) + .Select(g => $"{g.Key.Verb} {g.Key.FullTemplate} -> {string.Join(", ", g.Select(x => $"{x.Controller}.{x.Action}"))}") + .ToList(); + + collisions.Should().BeEmpty( + "no two controllers sharing api/WorkOrder or api/workorders may register the same verb+full-route"); + } + + private void Dump(IEnumerable endpoints) + { + _output.WriteLine("WorkOrder-family public endpoints (verb + action-relative route):"); + foreach (var endpoint in endpoints.OrderBy(x => x, StringComparer.Ordinal)) + _output.WriteLine(" " + endpoint); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/LocationController.cs b/Api.SeaHavenIndustries/Controllers/LocationController.cs index 47962c0..b44f58e 100644 --- a/Api.SeaHavenIndustries/Controllers/LocationController.cs +++ b/Api.SeaHavenIndustries/Controllers/LocationController.cs @@ -25,6 +25,13 @@ namespace Api.SeaHavenIndustries.Controllers _logger = logger; } + [HttpGet("sites")] + public async Task GetSites(string? search = null) + { + var data = await _locationService.GetSiteOptionsAsync(search); + return Ok(data); + } + [HttpGet("GetLocationList")] public async Task GetLocationList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) { diff --git a/Api.SeaHavenIndustries/Controllers/SyncController.cs b/Api.SeaHavenIndustries/Controllers/SyncController.cs index 1be2b1e..d392bff 100644 --- a/Api.SeaHavenIndustries/Controllers/SyncController.cs +++ b/Api.SeaHavenIndustries/Controllers/SyncController.cs @@ -1,24 +1,42 @@ +using Api.SeaHavenIndustries.Options; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { - [Authorize] + [Authorize(Roles = "Admin")] [ApiController] [Route("api/[controller]")] public class SyncController : Controller { private readonly ISyncService _syncService; + private readonly SyncOptions _syncOptions; - public SyncController(ISyncService syncService) + public SyncController(ISyncService syncService, IOptions syncOptions) { _syncService = syncService; + _syncOptions = syncOptions.Value; + } + + private IActionResult? SyncDisabledResult() + { + if (_syncOptions.Enabled) + return null; + + return StatusCode(StatusCodes.Status503ServiceUnavailable, new + { + message = "DynamoDB sync bridge is disabled. Use POST /api/workorders/ingest." + }); } [HttpPost("WorkOrders")] public async Task SyncWorkOrders(CancellationToken cancellationToken) { + if (SyncDisabledResult() is { } disabled) + return disabled; + var result = await _syncService.SyncWorkOrdersAsync(cancellationToken); return Ok(result); } @@ -26,6 +44,9 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("Comments")] public async Task SyncComments(CancellationToken cancellationToken) { + if (SyncDisabledResult() is { } disabled) + return disabled; + var result = await _syncService.SyncCommentsAsync(cancellationToken); return Ok(result); } @@ -33,6 +54,9 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("BackfillInternalWONumbers")] public async Task BackfillInternalWONumbers(CancellationToken cancellationToken) { + if (SyncDisabledResult() is { } disabled) + return disabled; + var result = await _syncService.BackfillInternalWONumbersAsync(cancellationToken); return Ok(result); } @@ -40,6 +64,9 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("BackfillDispatchNumbers")] public async Task BackfillDispatchNumbers(CancellationToken cancellationToken) { + if (SyncDisabledResult() is { } disabled) + return disabled; + var result = await _syncService.BackfillDispatchNumbersAsync(cancellationToken); return Ok(result); } @@ -47,6 +74,9 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("VendorReplies")] public async Task SyncVendorReplies(CancellationToken cancellationToken) { + if (SyncDisabledResult() is { } disabled) + return disabled; + var result = await _syncService.SyncVendorRepliesAsync(cancellationToken); return Ok(result); } @@ -54,6 +84,9 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("BackfillCommentTypes")] public async Task BackfillCommentTypes(CancellationToken cancellationToken) { + if (SyncDisabledResult() is { } disabled) + return disabled; + var result = await _syncService.BackfillCommentTypesAsync(cancellationToken); return Ok(result); } @@ -61,6 +94,9 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("All")] public async Task SyncAll(CancellationToken cancellationToken) { + if (SyncDisabledResult() is { } disabled) + return disabled; + var result = await _syncService.SyncAllAsync(cancellationToken); return Ok(result); } diff --git a/Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs b/Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs new file mode 100644 index 0000000..9b46315 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs @@ -0,0 +1,210 @@ +using System.Text; +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/vendor-operations")] + public class VendorOperationsController : ControllerBase + { + private readonly IVendorOperationsService _service; + + public VendorOperationsController(IVendorOperationsService service) + { + _service = service; + } + + private string Actor() => User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.Identity?.Name + ?? "unknown"; + + private IActionResult Map(VendorOperationOutcome outcome) => outcome.Kind switch + { + VendorOperationOutcomeKind.Ok => Ok(outcome.Value), + VendorOperationOutcomeKind.NotFound => outcome.Message is null + ? NotFound() + : NotFound(new Response { Status = "Error", Message = outcome.Message }), + VendorOperationOutcomeKind.BadRequest => BadRequest(new Response { Status = "Error", Message = outcome.Message }), + VendorOperationOutcomeKind.Conflict => Conflict(new Response { Status = "Conflict", Message = outcome.Message }), + _ => StatusCode(StatusCodes.Status500InternalServerError) + }; + + [HttpGet("notifications")] + public async Task GetNotifications(CancellationToken cancellationToken) + { + var result = await _service.GetNotificationsAsync(cancellationToken); + return Ok(new { result.GeneratedAt, result.Items }); + } + + [HttpGet("availability")] + public async Task GetAvailability( + [FromQuery] DateTime? start, + [FromQuery] DateTime? end, + [FromQuery] int? locationId, + [FromQuery] string? trade, + CancellationToken cancellationToken) + { + var outcome = await _service.GetAvailabilityAsync(start, end, locationId, trade, cancellationToken); + return Map(outcome); + } + + [HttpPut("vendors/{vendorId:int}/availability")] + public async Task UpdateAvailability(int vendorId, [FromBody] VendorAvailabilityRequest body, CancellationToken cancellationToken) + { + var outcome = await _service.UpdateAvailabilityAsync(vendorId, body?.Status, Actor(), cancellationToken); + return Map(outcome); + } + + [HttpGet("sites/{locationId:int}/preferred-vendors")] + public async Task GetSitePreferences(int locationId, CancellationToken cancellationToken) + { + var preferences = await _service.GetSitePreferencesAsync(locationId, cancellationToken); + return Ok(preferences); + } + + [HttpPut("sites/{locationId:int}/preferred-vendors")] + public async Task ReplaceSitePreferences(int locationId, [FromBody] List body, CancellationToken cancellationToken) + { + var outcome = await _service.ReplaceSitePreferencesAsync(locationId, body ?? new List(), Actor(), cancellationToken); + return Map(outcome); + } + + [HttpPut("documents/{documentId:int}/review")] + public async Task ReviewDocument(int documentId, [FromBody] DocumentReviewRequest body, CancellationToken cancellationToken) + { + var outcome = await _service.ReviewDocumentAsync(documentId, body ?? new DocumentReviewRequest(), Actor(), cancellationToken); + return Map(outcome); + } + + [HttpPut("dispatches/{dispatchId:int}/commercial-status")] + public async Task UpdateCommercialStatus(int dispatchId, [FromBody] CommercialStatusRequest body, CancellationToken cancellationToken) + { + var outcome = await _service.UpdateCommercialStatusAsync(dispatchId, body ?? new CommercialStatusRequest(), Actor(), cancellationToken); + return Map(outcome); + } + + [HttpPut("work-orders/{workOrderId:int}/assignment")] + public async Task AssignVendor(int workOrderId, [FromBody] VendorAssignmentRequest body, CancellationToken cancellationToken) + { + var outcome = await _service.AssignVendorAsync(workOrderId, body ?? new VendorAssignmentRequest(), Actor(), cancellationToken); + return Map(outcome); + } + + [HttpGet("insights")] + public async Task GetInsights( + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] int? locationId, + [FromQuery] string? trade, + [FromQuery] int? vendorId, + CancellationToken cancellationToken) + { + var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken); + return Ok(new + { + GeneratedAt = _service.UtcNow, + Filters = new { from, to, locationId, trade, vendorId }, + MetricDefinitions = MetricDefinitions(), + Data = rows + }); + } + + [HttpGet("insights.csv")] + public async Task ExportInsightsCsv( + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] int? locationId, + [FromQuery] string? trade, + [FromQuery] int? vendorId, + CancellationToken cancellationToken) + { + var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken); + var csv = new StringBuilder("VendorId,Vendor,CompletedJobs,CancellationRefusalRate,OnTimeArrivalRate,OnTimeCompletionRate,AverageCycleHours\n"); + foreach (var row in rows) + { + csv.AppendLine(string.Join(",", + row.VendorId, + Csv(row.Vendor), + row.CompletedJobs, + row.CancellationRefusalRate.ToString("0.####"), + row.OnTimeArrivalRate.ToString("0.####"), + row.OnTimeCompletionRate.ToString("0.####"), + row.AverageCycleHours?.ToString("0.##") ?? "")); + } + return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"vendor-insights-{_service.UtcNow:yyyyMMddHHmmss}.csv"); + } + + [HttpGet("insights.pdf")] + public async Task ExportInsightsPdf( + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] int? locationId, + [FromQuery] string? trade, + [FromQuery] int? vendorId, + CancellationToken cancellationToken) + { + var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken); + var lines = new List + { + "Vendor Performance Insights", + $"Generated: {_service.UtcNow:u}", + $"Filters: from={from:u}; to={to:u}; site={locationId}; trade={trade}; vendor={vendorId}", + "Definitions: completed jobs; cancellation/refusal rate; on-time acknowledgement; on-time completion; average dispatch-to-completion hours." + }; + lines.AddRange(rows.Take(35).Select(row => + $"{row.Vendor}: completed {row.CompletedJobs}; cancel/refusal {row.CancellationRefusalRate:P1}; arrival {row.OnTimeArrivalRate:P1}; completion {row.OnTimeCompletionRate:P1}; cycle {row.AverageCycleHours:0.0}h")); + return File(SimplePdf(lines), "application/pdf", $"vendor-insights-{_service.UtcNow:yyyyMMddHHmmss}.pdf"); + } + + private static string Csv(string value) + { + var spreadsheetSafe = value.Length > 0 && "=+-@\t\r".Contains(value[0]) + ? $"'{value}" + : value; + return $"\"{spreadsheetSafe.Replace("\"", "\"\"")}\""; + } + + private static object MetricDefinitions() => new + { + CompletedJobs = "Dispatches with Completed or Verified status.", + CancellationRefusalRate = "Cancelled, Canceled, or Refused dispatches divided by all dispatches.", + OnTimeArrivalRate = "Acknowledgements at or before scheduled time divided by dispatches with both timestamps.", + OnTimeCompletionRate = "Completions at or before the work-order due date divided by completed dispatches with a due date.", + AverageCycleHours = "Average elapsed hours from dispatch to completion." + }; + + private static byte[] SimplePdf(IEnumerable lines) + { + var escaped = lines.Select(line => line.Replace("\\", "\\\\").Replace("(", "\\(").Replace(")", "\\)")); + var content = "BT /F1 10 Tf 40 760 Td 13 TL " + + string.Join(" T* ", escaped.Select(line => $"({line}) Tj")) + + " ET"; + var objects = new[] + { + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>", + $"<< /Length {Encoding.ASCII.GetByteCount(content)} >>\nstream\n{content}\nendstream", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>" + }; + var builder = new StringBuilder("%PDF-1.4\n"); + var offsets = new List { 0 }; + for (var index = 0; index < objects.Length; index++) + { + offsets.Add(Encoding.ASCII.GetByteCount(builder.ToString())); + builder.Append($"{index + 1} 0 obj\n{objects[index]}\nendobj\n"); + } + var xrefOffset = Encoding.ASCII.GetByteCount(builder.ToString()); + builder.Append($"xref\n0 {objects.Length + 1}\n0000000000 65535 f \n"); + foreach (var offset in offsets.Skip(1)) builder.Append($"{offset:D10} 00000 n \n"); + builder.Append($"trailer << /Size {objects.Length + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF"); + return Encoding.ASCII.GetBytes(builder.ToString()); + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index 2619ba8..447cf30 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -1,5 +1,6 @@ using Api.SeaHavenIndustries.Helper; using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using SeaHaven.Services.DTOs; @@ -226,6 +227,43 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpPost("dispatches/{id:int}/completion-documents")] + [RequestSizeLimit(10_000_000)] + public async Task UploadCompletionDocument(int id, [FromForm] IFormFile file, CancellationToken cancellationToken = default) + { + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + + try + { + var result = await _portalService.UploadCompletionDocumentAsync(session, id, file, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The completion document could not be uploaded") }); + } + } + + [HttpGet("dispatches/{id:int}/completion-documents/{documentId:int}")] + public async Task DownloadCompletionDocument(int id, int documentId, CancellationToken cancellationToken = default) + { + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + + var result = await _portalService.DownloadCompletionDocumentAsync(session, id, documentId, cancellationToken); + return result.Outcome switch + { + VendorDocumentDownloadOutcome.Ok => File(result.Content!, result.ContentType!, result.FileName!), + VendorDocumentDownloadOutcome.Locked => StatusCode(StatusCodes.Status423Locked, new Response { Status = "Locked", Message = "The completion document is not available for download yet." }), + _ => NotFound(new Response { Status = "Error", Message = "Completion document not found" }) + }; + } + private async Task ResolveSessionAsync(CancellationToken cancellationToken) { if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null; diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs new file mode 100644 index 0000000..204b742 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs @@ -0,0 +1,216 @@ +using Api.SeaHavenIndustries.DTOs; +using Api.SeaHavenIndustries.Helper; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentValidation; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/WorkOrder")] + [Route("api/workorders")] + public class WorkOrderBoardController : Controller + { + private readonly IWorkOrderBoardService _workOrderBoardService; + private readonly IWorkOrderBoardUpdateService _boardUpdateService; + private readonly IWorkOrderBoardCreateService _boardCreateService; + private readonly IWorkOrderBoardCancelService _boardCancelService; + private readonly IWorkOrderAdvancedSearchService _advancedSearchService; + + public WorkOrderBoardController( + IWorkOrderBoardService workOrderBoardService, + IWorkOrderBoardUpdateService boardUpdateService, + IWorkOrderBoardCreateService boardCreateService, + IWorkOrderBoardCancelService boardCancelService, + IWorkOrderAdvancedSearchService advancedSearchService) + { + _workOrderBoardService = workOrderBoardService; + _boardUpdateService = boardUpdateService; + _boardCreateService = boardCreateService; + _boardCancelService = boardCancelService; + _advancedSearchService = advancedSearchService; + } + + /// + /// Weekly board payload. + /// When is true, the authenticated user filter wins and + /// is ignored (not AND-combined). + /// Weekend WOs in a Mon–Sun window may have dayGroup = null; FE should handle that. + /// + [HttpGet("board")] + public async Task GetBoard( + [FromQuery, BindRequired] DateOnly weekStart, + [FromQuery] DateOnly? weekEnd = null, + [FromQuery] List? dispatchers = null, + [FromQuery] bool myWorkOrders = false, + [FromQuery] List? types = null, + [FromQuery] string? search = null) + { + var resolvedWeekEnd = weekEnd ?? weekStart.AddDays(4); + var weekValidationError = WorkOrderOperationalWeek.ValidateWeekWindow(weekStart, resolvedWeekEnd); + if (weekValidationError != null) + return BadRequest(weekValidationError); + + try + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var query = new WorkOrderBoardQueryDto + { + WeekStart = weekStart, + WeekEnd = weekEnd, + Dispatchers = dispatchers, + MyWorkOrders = myWorkOrders, + Types = types, + Search = search + }; + + var result = await _workOrderBoardService.GetBoardAsync(query, userId); + return Ok(result); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("board/search")] + public async Task SearchBoard([FromQuery] WorkOrderAdvancedSearchQueryDto query) + { + try + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _advancedSearchService.SearchAsync(query, userId); + return Ok(result); + } + catch (ArgumentException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } + } + + /// + /// Dispatcher dropdown options. Currently returns all non-deleted users + /// (role-filtered assignee list deferred until roles settle). + /// + [HttpGet("lookups/dispatchers")] + public async Task GetDispatcherLookups() + { + var result = await _workOrderBoardService.GetDispatcherLookupsAsync(); + return Ok(result); + } + + [HttpPatch("{id:int}/board")] + public async Task PatchBoardField(int id, [FromBody] WorkOrderBoardPatchRequestDto request) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var row = await _boardUpdateService.PatchFieldAsync(id, request, actorId); + return Ok(row); + } + catch (WorkOrderBoardConcurrencyException ex) + { + return Conflict(new WorkOrderBoardConflictDto + { + CurrentState = ex.CurrentState + }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + catch (ArgumentException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } + } + + [HttpPost("board")] + public async Task CreateBoardWorkOrder([FromBody] WorkOrderBoardCreateRequestDto request) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var row = await _boardCreateService.CreateAsync(request, actorId); + return Ok(row); + } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "DuplicateWoNumber") + { + return Conflict(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + } + + [HttpPost("{id:int}/cancel")] + public async Task CancelBoardWorkOrder(int id) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var row = await _boardCancelService.CancelAsync(id, actorId); + return Ok(row); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs new file mode 100644 index 0000000..e253cbd --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs @@ -0,0 +1,139 @@ +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/WorkOrder")] + [Route("api/workorders")] + public class WorkOrderCompletionController : Controller + { + private readonly IWorkOrderCompletionService _workOrderCompletionService; + private readonly IFileStoragePort _fileStorage; + + public WorkOrderCompletionController( + IWorkOrderCompletionService workOrderCompletionService, + IFileStoragePort fileStorage) + { + _workOrderCompletionService = workOrderCompletionService; + _fileStorage = fileStorage; + } + + [HttpGet("completion-templates")] + public async Task GetCompletionTemplates( + [FromQuery] string? serviceKey = null, + [FromQuery] WorkOrderType? workOrderType = null) + { + var templates = await _workOrderCompletionService.GetTemplatesAsync(serviceKey, workOrderType); + return Ok(templates); + } + + [HttpGet("completion-templates/{id:int}")] + public async Task GetCompletionTemplate(int id) + { + var template = await _workOrderCompletionService.GetTemplateByIdAsync(id); + if (template == null) + return NotFound(new Response { Status = "Error", Message = "Template not found." }); + return Ok(template); + } + + [Authorize(Roles = "Admin")] + [HttpPost("completion-templates")] + public async Task CreateCompletionTemplate([FromBody] CompletionDocTemplateCreateDto request) + { + try + { + var created = await _workOrderCompletionService.CreateTemplateAsync(request); + return Ok(created); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [Authorize(Roles = "Admin")] + [HttpPut("completion-templates/{id:int}")] + public async Task UpdateCompletionTemplate(int id, [FromBody] CompletionDocTemplateCreateDto request) + { + try + { + var updated = await _workOrderCompletionService.UpdateTemplateAsync(id, request); + return Ok(updated); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [Authorize(Roles = "Admin")] + [HttpDelete("completion-templates/{id:int}")] + public async Task DeleteCompletionTemplate(int id) + { + try + { + await _workOrderCompletionService.DeleteTemplateAsync(id); + return NoContent(); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpPost("{id:int}/completion-doc")] + [RequestSizeLimit(30_000_000)] + public async Task UploadCompletionDoc( + int id, + [FromForm] IFormFile file, + [FromForm] string? signOffName = null, + [FromForm] string? signOffSignature = null, + [FromForm] string? workOrderVersion = null) + { + if (file == null || file.Length == 0) + return BadRequest(new Response { Status = "Error", Message = "file is required." }); + + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var fileUrl = await _fileStorage.SaveFileAsync(file); + var result = await _workOrderCompletionService.UploadCompletionDocAsync( + id, + new WorkOrderCompletionDocUploadDto + { + SignOffName = signOffName, + SignOffSignature = signOffSignature, + WorkOrderVersion = workOrderVersion + }, + fileUrl, + actorId); + return Ok(result); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code is "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code is "ConcurrencyConflict") + { + return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 0a0c0a4..9d99e2e 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -5,9 +5,9 @@ using FluentValidation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; -using SeaHaven.DataServices.Models; using System.Security.Claims; namespace Api.SeaHavenIndustries.Controllers @@ -19,16 +19,13 @@ namespace Api.SeaHavenIndustries.Controllers public class WorkOrderController : Controller { private readonly IWorkOrderService _workOrderService; - private readonly IWorkOrderDispatchService _dispatchService; private readonly ILogger _logger; public WorkOrderController( IWorkOrderService workOrderService, - IWorkOrderDispatchService dispatchService, ILogger logger) { _workOrderService = workOrderService; - _dispatchService = dispatchService; _logger = logger; } @@ -134,6 +131,7 @@ namespace Api.SeaHavenIndustries.Controllers return Ok(viewModel); } + [Authorize(Roles = "Admin")] [HttpDelete] [Route("DeleteWorkorder")] public async Task Deleteworkorder([FromBody] EditWorkorder_DTO model) @@ -344,188 +342,5 @@ namespace Api.SeaHavenIndustries.Controllers var data = await _workOrderService.GetWorkordersAsync(); return Ok(data); } - - [HttpPost] - [Route("DispatchToVendor")] - public async Task DispatchToVendor([FromBody] Dispatch_DTO model, CancellationToken cancellationToken) - { - try - { - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - - var input = new DispatchToVendorInput - { - WorkOrderIds = model.WorkOrderIds, - VendorIds = model.VendorIds, - NTEAmount = model.NTEAmount, - Description = model.Description, - ScheduledDate = model.ScheduledDate, - TaskListTemplateId = model.TaskListTemplateId, - CustomChecklistItems = model.CustomChecklistItems, - UserId = userId - }; - - var result = await _dispatchService.DispatchToVendorAsync(input, cancellationToken); - return Ok(result); - } - catch (Exception ex) - { - return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); - } - } - - [HttpGet("GetDispatches/{workOrderId}")] - public async Task GetDispatches(int workOrderId) - { - var data = await _dispatchService.GetDispatchesAsync(workOrderId); - return Ok(data); - } - - [HttpGet("GetDispatch/{id}")] - public async Task GetDispatchById(int id) - { - var d = await _dispatchService.GetDispatchDetailAsync(id); - if (d == null) - return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - return Ok(d); - } - - [HttpPost("UpdateDispatch")] - public async Task UpdateDispatch([FromBody] UpdateDispatch_DTO model) - { - try - { - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var input = new UpdateDispatchInput - { - Id = model.Id, - Status = model.Status, - NTEAmount = model.NTEAmount, - ScheduledDate = model.ScheduledDate, - CompletedDate = model.CompletedDate, - Description = model.Description, - UserId = userId - }; - - var success = await _dispatchService.UpdateDispatchAsync(input); - if (!success) - return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); - - return Ok(new { message = "Dispatch updated" }); - } - catch (Exception ex) - { - return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); - } - } - - [HttpPost("AddDispatchComment")] - public async Task AddDispatchComment([FromBody] DispatchComment_DTO model) - { - try - { - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var input = new AddDispatchCommentInput - { - DispatchId = model.DispatchId, - Text = model.Text, - SendEmail = model.SendEmail, - UserId = userId - }; - - var result = await _dispatchService.AddDispatchCommentAsync(input); - if (result == null) - return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); - - return Ok(result); - } - catch (Exception ex) - { - return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); - } - } - - [HttpPost("VerifyDispatch")] - public async Task VerifyDispatch(int dispatchId) - { - try - { - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var result = await _dispatchService.VerifyDispatchAsync(dispatchId, userId!); - if (result == null) - return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); - - return Ok(result); - } - catch (DispatchVerificationException ex) - { - return BadRequest(new { status = "Error", message = "Cannot verify", missing = ex.Missing }); - } - catch (Exception ex) - { - return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); - } - } - - [HttpPost("AddDispatchSignoff")] - public async Task AddDispatchSignoff([FromBody] DispatchSignoff_DTO model) - { - try - { - var input = new AddDispatchSignoffInput - { - DispatchId = model.DispatchId, - SignoffType = model.SignoffType, - Name = model.Name, - Signature = model.Signature, - SignatureMethod = model.SignatureMethod - }; - - var result = await _dispatchService.AddDispatchSignoffAsync(input); - if (result == null) - return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); - - return Ok(result); - } - catch (InvalidOperationException ex) - { - return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The dispatch signoff could not be added") }); - } - catch (Exception ex) - { - return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); - } - } - - [HttpPost("UpdateChecklistItem")] - public async Task UpdateChecklistItem([FromBody] ChecklistItemUpdate_DTO model) - { - var input = new ChecklistItemUpdateInput - { - Id = model.Id, - IsCompleted = model.IsCompleted, - CompletedBy = model.CompletedBy - }; - - var result = await _dispatchService.UpdateChecklistItemAsync(input); - if (result == null) - return NotFound(new Response { Status = "Error", Message = "Checklist item not found" }); - - return Ok(result); - } - - [HttpPost("AddChecklistItem")] - public async Task AddChecklistItem([FromBody] AddChecklistItem_DTO model) - { - var input = new AddChecklistItemInput - { - DispatchId = model.DispatchId, - WorkOrderId = model.WorkOrderId, - ItemText = model.ItemText - }; - - var result = await _dispatchService.AddChecklistItemAsync(input); - return Ok(result); - } } } diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs new file mode 100644 index 0000000..7e3b657 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs @@ -0,0 +1,75 @@ +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/WorkOrder")] + [Route("api/workorders")] + public class WorkOrderDetailController : Controller + { + private readonly IWorkOrderDetailService _workOrderDetailService; + private readonly IWorkOrderCommentService _workOrderCommentService; + + public WorkOrderDetailController( + IWorkOrderDetailService workOrderDetailService, + IWorkOrderCommentService workOrderCommentService) + { + _workOrderDetailService = workOrderDetailService; + _workOrderCommentService = workOrderCommentService; + } + + [HttpGet("{id:int}/detail")] + public async Task GetDetail(int id) + { + var detail = await _workOrderDetailService.GetDetailAsync(id); + if (detail == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(detail); + } + + [HttpGet("{id:int}/audit")] + public async Task GetAudit(int id, [FromQuery] int limit = 50) + { + var audit = await _workOrderDetailService.GetAuditAsync(id, limit); + if (audit == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(audit); + } + + [HttpGet("{id:int}/comments")] + public async Task GetBoardComments(int id) + { + var comments = await _workOrderCommentService.GetCommentsAsync(id); + if (comments == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(comments); + } + + [HttpPost("{id:int}/comments")] + public async Task AddBoardComment(int id, [FromBody] WorkOrderCommentCreateDto request) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var comment = await _workOrderCommentService.AddCommentAsync(id, request, actorId); + return Ok(comment); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderDispatchController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderDispatchController.cs new file mode 100644 index 0000000..53186b8 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderDispatchController.cs @@ -0,0 +1,214 @@ +using Api.SeaHavenIndustries.DTOs; +using Api.SeaHavenIndustries.Helper; +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/WorkOrder")] + [Route("api/workorders")] + public class WorkOrderDispatchController : Controller + { + private readonly IWorkOrderDispatchService _dispatchService; + private readonly ILogger _logger; + + public WorkOrderDispatchController( + IWorkOrderDispatchService dispatchService, + ILogger logger) + { + _dispatchService = dispatchService; + _logger = logger; + } + + [HttpPost] + [Route("DispatchToVendor")] + public async Task DispatchToVendor([FromBody] Dispatch_DTO model, CancellationToken cancellationToken) + { + try + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + + var input = new DispatchToVendorInput + { + WorkOrderIds = model.WorkOrderIds, + VendorIds = model.VendorIds, + NTEAmount = model.NTEAmount, + Description = model.Description, + ScheduledDate = model.ScheduledDate, + TaskListTemplateId = model.TaskListTemplateId, + CustomChecklistItems = model.CustomChecklistItems, + UserId = userId + }; + + var result = await _dispatchService.DispatchToVendorAsync(input, cancellationToken); + return Ok(result); + } + catch (Exception ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); + } + } + + [HttpGet("GetDispatches/{workOrderId}")] + public async Task GetDispatches(int workOrderId) + { + var data = await _dispatchService.GetDispatchesAsync(workOrderId); + return Ok(data); + } + + [HttpGet("GetDispatch/{id}")] + public async Task GetDispatchById(int id) + { + var d = await _dispatchService.GetDispatchDetailAsync(id); + if (d == null) + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + return Ok(d); + } + + [HttpPost("UpdateDispatch")] + public async Task UpdateDispatch([FromBody] UpdateDispatch_DTO model) + { + try + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var input = new UpdateDispatchInput + { + Id = model.Id, + Status = model.Status, + NTEAmount = model.NTEAmount, + ScheduledDate = model.ScheduledDate, + CompletedDate = model.CompletedDate, + Description = model.Description, + UserId = userId + }; + + var success = await _dispatchService.UpdateDispatchAsync(input); + if (!success) + return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); + + return Ok(new { message = "Dispatch updated" }); + } + catch (Exception ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); + } + } + + [HttpPost("AddDispatchComment")] + public async Task AddDispatchComment([FromBody] DispatchComment_DTO model) + { + try + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var input = new AddDispatchCommentInput + { + DispatchId = model.DispatchId, + Text = model.Text, + SendEmail = model.SendEmail, + UserId = userId + }; + + var result = await _dispatchService.AddDispatchCommentAsync(input); + if (result == null) + return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); + + return Ok(result); + } + catch (Exception ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); + } + } + + [HttpPost("VerifyDispatch")] + public async Task VerifyDispatch(int dispatchId) + { + try + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _dispatchService.VerifyDispatchAsync(dispatchId, userId!); + if (result == null) + return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); + + return Ok(result); + } + catch (DispatchVerificationException ex) + { + return BadRequest(new { status = "Error", message = "Cannot verify", missing = ex.Missing }); + } + catch (Exception ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); + } + } + + [HttpPost("AddDispatchSignoff")] + public async Task AddDispatchSignoff([FromBody] DispatchSignoff_DTO model) + { + try + { + var input = new AddDispatchSignoffInput + { + DispatchId = model.DispatchId, + SignoffType = model.SignoffType, + Name = model.Name, + Signature = model.Signature, + SignatureMethod = model.SignatureMethod + }; + + var result = await _dispatchService.AddDispatchSignoffAsync(input); + if (result == null) + return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); + + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The dispatch signoff could not be added") }); + } + catch (Exception ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); + } + } + + [HttpPost("UpdateChecklistItem")] + public async Task UpdateChecklistItem([FromBody] ChecklistItemUpdate_DTO model) + { + var input = new ChecklistItemUpdateInput + { + Id = model.Id, + IsCompleted = model.IsCompleted, + CompletedBy = model.CompletedBy + }; + + var result = await _dispatchService.UpdateChecklistItemAsync(input); + if (result == null) + return NotFound(new Response { Status = "Error", Message = "Checklist item not found" }); + + return Ok(result); + } + + [HttpPost("AddChecklistItem")] + public async Task AddChecklistItem([FromBody] AddChecklistItem_DTO model) + { + var input = new AddChecklistItemInput + { + DispatchId = model.DispatchId, + WorkOrderId = model.WorkOrderId, + ItemText = model.ItemText + }; + + var result = await _dispatchService.AddChecklistItemAsync(input); + return Ok(result); + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderIngestController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderIngestController.cs new file mode 100644 index 0000000..47b91a1 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderIngestController.cs @@ -0,0 +1,50 @@ +using Api.SeaHavenIndustries.Filters; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + [ApiController] + [Route("api/workorders")] + public class WorkOrderIngestController : ControllerBase + { + private const int MaxBatchSize = 100; + private readonly IWorkOrderIngestService _ingestService; + + public WorkOrderIngestController(IWorkOrderIngestService ingestService) + { + _ingestService = ingestService; + } + + /// + /// Idempotent upsert by ExternalWorkOrderId (Lambda cutover target). + /// Auth: X-Ingest-Key header. + /// + [HttpPost("ingest")] + [IngestApiKey] + [ProducesResponseType(typeof(WorkOrderIngestBatchResultDto), StatusCodes.Status200OK)] + public async Task> Ingest( + [FromBody] WorkOrderIngestBatchRequestDto? batch, + CancellationToken cancellationToken) + { + var items = NormalizeItems(batch); + if (items.Count == 0) + return BadRequest("At least one item with externalWorkOrderId is required."); + + if (items.Count > MaxBatchSize) + return BadRequest($"Max {MaxBatchSize} items per request."); + + var result = await _ingestService.UpsertBatchAsync(items, cancellationToken); + return Ok(result); + } + + private static List NormalizeItems(WorkOrderIngestBatchRequestDto? batch) + { + if (batch?.Items is { Count: > 0 }) + return batch.Items; + + return new List(); + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs new file mode 100644 index 0000000..4277475 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs @@ -0,0 +1,58 @@ +using Api.SeaHavenIndustries.Options; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + /// Ops-only endpoints for manual domain job reprocessing. + [Authorize(Roles = "Admin")] + [ApiController] + [Route("api/workorders/jobs")] + public class WorkOrderJobsController : ControllerBase + { + private readonly IWorkOrderWeekRolledService _weekRolledService; + private readonly IPastDueCacheService _pastDueCacheService; + + public WorkOrderJobsController( + IWorkOrderWeekRolledService weekRolledService, + IPastDueCacheService pastDueCacheService) + { + _weekRolledService = weekRolledService; + _pastDueCacheService = pastDueCacheService; + } + + /// + /// Reprocess WeekRolled for a specific operational week (Monday start). + /// Idempotent via WorkOrderWeekRolledLedger. + /// + [HttpPost("week-rolled")] + [ProducesResponseType(typeof(WeekRolledJobResult), StatusCodes.Status200OK)] + public async Task> RunWeekRolled( + [FromQuery] DateOnly? sourceWeekStart, + CancellationToken cancellationToken) + { + var weekStart = sourceWeekStart + ?? WorkOrderOperationalWeek.GetPreviousOperationalWeekStart(DateTime.UtcNow); + + if (weekStart.DayOfWeek != DayOfWeek.Monday) + return BadRequest("sourceWeekStart must be a Monday (operational week start)."); + + var result = await _weekRolledService.ProcessWeekRolledAsync(weekStart, cancellationToken); + return Ok(result); + } + + /// + /// Refresh optional OperationalFlags.PastDue cache (board isPastDue remains on-read). + /// + [HttpPost("past-due-cache")] + [ProducesResponseType(typeof(PastDueCacheJobResult), StatusCodes.Status200OK)] + public async Task> RunPastDueCache(CancellationToken cancellationToken) + { + var result = await _pastDueCacheService.RefreshAsync(cancellationToken); + return Ok(result); + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs new file mode 100644 index 0000000..2664d65 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -0,0 +1,85 @@ +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/WorkOrder")] + [Route("api/workorders")] + public class WorkOrderMediaController : Controller + { + private readonly IWorkOrderMediaService _workOrderMediaService; + private readonly IFileStoragePort _fileStorage; + + public WorkOrderMediaController( + IWorkOrderMediaService workOrderMediaService, + IFileStoragePort fileStorage) + { + _workOrderMediaService = workOrderMediaService; + _fileStorage = fileStorage; + } + + [HttpGet("{id:int}/media")] + public async Task GetMedia(int id) + { + var media = await _workOrderMediaService.GetMediaAsync(id); + if (media == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(media); + } + + [HttpPost("{id:int}/media")] + [RequestSizeLimit(30_000_000)] + public async Task AddMedia( + int id, + [FromForm] WorkOrderMediaCategory category, + [FromForm] IFormFile file) + { + if (file == null || file.Length == 0) + return BadRequest(new Response { Status = "Error", Message = "file is required." }); + + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var fileUrl = await _fileStorage.SaveFileAsync(file); + var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId); + return Ok(media); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpDelete("{id:int}/media/{mediaId:int}")] + public async Task DeleteMedia(int id, int mediaId) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + await _workOrderMediaService.DeleteMediaAsync(id, mediaId, actorId); + return NoContent(); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs new file mode 100644 index 0000000..dfbff62 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + /// Ops health and coexistence metrics (Fase 7). + [Authorize(Roles = "Admin")] + [ApiController] + [Route("api/workorders/ops")] + public class WorkOrderOpsController : ControllerBase + { + private readonly IWorkOrderOpsHealthService _healthService; + + public WorkOrderOpsController(IWorkOrderOpsHealthService healthService) + { + _healthService = healthService; + } + + [HttpGet("health")] + [ProducesResponseType(typeof(WorkOrderOpsHealthDto), StatusCodes.Status200OK)] + public async Task> GetHealth(CancellationToken cancellationToken) + { + var health = await _healthService.GetHealthAsync(cancellationToken); + return Ok(health); + } + } +} diff --git a/Api.SeaHavenIndustries/DTOs/Dispatch_DTO.cs b/Api.SeaHavenIndustries/DTOs/Dispatch_DTO.cs index 4601436..49f17f0 100644 --- a/Api.SeaHavenIndustries/DTOs/Dispatch_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Dispatch_DTO.cs @@ -6,6 +6,8 @@ namespace Api.SeaHavenIndustries.DTOs public List VendorIds { get; set; } = new(); public decimal? NTEAmount { get; set; } public DateTime? ScheduledDate { get; set; } + public DateTime? EstimatedArrivalAt { get; set; } + public bool EtaManualOverride { get; set; } public string? Description { get; set; } public int? TaskListTemplateId { get; set; } public List? CustomChecklistItems { get; set; } diff --git a/Api.SeaHavenIndustries/DTOs/UpdateDispatch_DTO.cs b/Api.SeaHavenIndustries/DTOs/UpdateDispatch_DTO.cs index 31770e1..40052f1 100644 --- a/Api.SeaHavenIndustries/DTOs/UpdateDispatch_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/UpdateDispatch_DTO.cs @@ -6,6 +6,8 @@ namespace Api.SeaHavenIndustries.DTOs public string? Status { get; set; } public decimal? NTEAmount { get; set; } public DateTime? ScheduledDate { get; set; } + public DateTime? EstimatedArrivalAt { get; set; } + public bool EtaManualOverride { get; set; } public DateTime? CompletedDate { get; set; } public string? Description { get; set; } } diff --git a/Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs b/Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs new file mode 100644 index 0000000..0bfeabb --- /dev/null +++ b/Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Api.SeaHavenIndustries.Filters +{ + public class ArgumentExceptionFilter : IExceptionFilter + { + public void OnException(ExceptionContext context) + { + if (context.Exception is not ArgumentException ex) return; + + context.Result = new BadRequestObjectResult(new + { + status = "Error", + message = ex.Message, + code = 400 + }); + context.ExceptionHandled = true; + } + } +} diff --git a/Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs b/Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs new file mode 100644 index 0000000..b98bfcc --- /dev/null +++ b/Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs @@ -0,0 +1,67 @@ +using System.Security.Cryptography; +using System.Text; +using Api.SeaHavenIndustries.Options; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; + +namespace Api.SeaHavenIndustries.Filters +{ + /// Validates X-Ingest-Key for POST /api/workorders/ingest. + public class IngestApiKeyFilter : IAsyncActionFilter + { + private readonly WorkOrderIngestOptions _options; + + public IngestApiKeyFilter(IOptions options) + { + _options = options.Value; + } + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + if (!_options.Enabled) + { + context.Result = new ObjectResult(new { message = "Work order ingest is disabled." }) + { + StatusCode = StatusCodes.Status503ServiceUnavailable + }; + return; + } + + if (string.IsNullOrWhiteSpace(_options.ApiKey) + || _options.ApiKey.Contains("${", StringComparison.Ordinal)) + { + context.Result = new ObjectResult(new { message = "Ingest API key is not configured." }) + { + StatusCode = StatusCodes.Status503ServiceUnavailable + }; + return; + } + + if (!context.HttpContext.Request.Headers.TryGetValue("X-Ingest-Key", out var provided) + || !FixedTimeEquals(provided.ToString(), _options.ApiKey)) + { + context.Result = new UnauthorizedObjectResult(new { message = "Invalid or missing X-Ingest-Key." }); + return; + } + + await next(); + } + + private static bool FixedTimeEquals(string provided, string expected) + { + var providedBytes = Encoding.UTF8.GetBytes(provided); + var expectedBytes = Encoding.UTF8.GetBytes(expected); + return providedBytes.Length == expectedBytes.Length + && CryptographicOperations.FixedTimeEquals(providedBytes, expectedBytes); + } + } + + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public sealed class IngestApiKeyAttribute : ServiceFilterAttribute + { + public IngestApiKeyAttribute() : base(typeof(IngestApiKeyFilter)) + { + } + } +} diff --git a/Api.SeaHavenIndustries/Helper/ClamAvDocumentScanner.cs b/Api.SeaHavenIndustries/Helper/ClamAvDocumentScanner.cs new file mode 100644 index 0000000..7280c9f --- /dev/null +++ b/Api.SeaHavenIndustries/Helper/ClamAvDocumentScanner.cs @@ -0,0 +1,70 @@ +using System.Buffers.Binary; +using System.Net.Sockets; +using System.Text; + +namespace Api.SeaHavenIndustries.Helper +{ + public enum DocumentScanResult + { + Passed, + Infected, + Unavailable + } + + public interface IDocumentScanner + { + Task ScanAsync(string path, CancellationToken cancellationToken); + } + + public class ClamAvDocumentScanner : IDocumentScanner + { + private readonly IConfiguration _configuration; + + public ClamAvDocumentScanner(IConfiguration configuration) + { + _configuration = configuration; + } + + public async Task ScanAsync(string path, CancellationToken cancellationToken) + { + var host = _configuration["ClamAV:Host"]; + var port = _configuration.GetValue("ClamAV:Port") ?? 3310; + if (string.IsNullOrWhiteSpace(host) || !File.Exists(path)) + return DocumentScanResult.Unavailable; + + try + { + using var client = new TcpClient(); + await client.ConnectAsync(host, port, cancellationToken); + await using var network = client.GetStream(); + await network.WriteAsync(Encoding.ASCII.GetBytes("zINSTREAM\0"), cancellationToken); + await using var file = File.OpenRead(path); + var buffer = new byte[64 * 1024]; + while (true) + { + var count = await file.ReadAsync(buffer, cancellationToken); + if (count == 0) break; + var length = new byte[4]; + BinaryPrimitives.WriteInt32BigEndian(length, count); + await network.WriteAsync(length, cancellationToken); + await network.WriteAsync(buffer.AsMemory(0, count), cancellationToken); + } + await network.WriteAsync(new byte[4], cancellationToken); + await network.FlushAsync(cancellationToken); + + var responseBuffer = new byte[1024]; + var responseLength = await network.ReadAsync(responseBuffer, cancellationToken); + var response = Encoding.ASCII.GetString(responseBuffer, 0, responseLength); + if (response.Contains("FOUND", StringComparison.OrdinalIgnoreCase)) + return DocumentScanResult.Infected; + return response.Contains("OK", StringComparison.OrdinalIgnoreCase) + ? DocumentScanResult.Passed + : DocumentScanResult.Unavailable; + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + return DocumentScanResult.Unavailable; + } + } + } +} diff --git a/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs b/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs new file mode 100644 index 0000000..c77e5b6 --- /dev/null +++ b/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs @@ -0,0 +1,78 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; + +namespace Api.SeaHavenIndustries.Helper +{ + public class VendorDocumentScanWorker : BackgroundService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly IWebHostEnvironment _environment; + private readonly ILogger _logger; + + public VendorDocumentScanWorker( + IServiceScopeFactory scopeFactory, + IWebHostEnvironment environment, + ILogger logger) + { + _scopeFactory = scopeFactory; + _environment = environment; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + await ScanPendingAsync(stoppingToken); + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); + } + } + + private async Task ScanPendingAsync(CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var scanner = scope.ServiceProvider.GetRequiredService(); + var pending = await db.VendorCompletionDocuments + .Where(document => document.ScanStatus == "Pending") + .OrderBy(document => document.CreatedDate) + .Take(20) + .ToListAsync(cancellationToken); + + foreach (var document in pending) + { + var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document); + var result = await scanner.ScanAsync(path, cancellationToken); + if (result == DocumentScanResult.Unavailable) + { + _logger.LogWarning("Vendor document scan service unavailable; document {DocumentId} remains quarantined", document.Id); + continue; + } + + document.ScanStatus = result == DocumentScanResult.Passed ? "Passed" : "Rejected"; + document.ReviewStatus = result == DocumentScanResult.Passed ? "Processing" : "Rejected"; + document.RejectionReason = result == DocumentScanResult.Infected + ? "The upload failed malware scanning." + : document.RejectionReason; + document.ScannedAt = DateTime.UtcNow; + document.LastModificationTime = DateTime.UtcNow; + await db.SaveChangesAsync(cancellationToken); + + if (result == DocumentScanResult.Infected && File.Exists(path)) + File.Delete(path); + } + } + } + + public static class VendorDocumentStorage + { + public static string Root(string contentRoot) => + Path.Combine(contentRoot, "App_Data", "vendor-documents"); + + public static string ResolvePath(string contentRoot, int vendorId, int dispatchId, string storedFileName) => + Path.Combine(Root(contentRoot), vendorId.ToString(), dispatchId.ToString(), storedFileName); + + public static string ResolvePath(string contentRoot, VendorCompletionDocument document) => + ResolvePath(contentRoot, document.VendorId, document.DispatchId, document.StoredFileName); + } +} diff --git a/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs b/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs new file mode 100644 index 0000000..6daf1f7 --- /dev/null +++ b/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.Options; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Api.SeaHavenIndustries.Options; + +namespace Api.SeaHavenIndustries.HostedServices +{ + public class PastDueCacheHostedService : BackgroundService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly PastDueCacheJobOptions _options; + private readonly WorkOrderJobRunState _runState; + private DateOnly? _lastRunDate; + + public PastDueCacheHostedService( + IServiceScopeFactory scopeFactory, + IOptions options, + WorkOrderJobRunState runState, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + _options = options.Value.PastDueCache; + _runState = runState; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + if (_options.Enabled && ShouldRunNow()) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + if (_lastRunDate != today) + { + if (await RunJobAsync(stoppingToken)) + _lastRunDate = today; + } + } + + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + } + } + + private async Task RunJobAsync(CancellationToken stoppingToken) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + await service.RefreshAsync(stoppingToken); + + _runState.LastPastDueCacheRunUtc = DateTime.UtcNow; + _runState.LastPastDueCacheError = null; + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _runState.LastPastDueCacheError = ex.Message; + _logger.LogError(ex, "PastDue cache hosted job failed."); + return false; + } + } + + private bool ShouldRunNow() + { + var now = DateTime.UtcNow; + + if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt)) + runAt = new TimeOnly(0, 10); + + return TimeOnly.FromDateTime(now) >= runAt; + } + } +} diff --git a/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs b/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs new file mode 100644 index 0000000..dc5a309 --- /dev/null +++ b/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.Options; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Api.SeaHavenIndustries.Options; + +namespace Api.SeaHavenIndustries.HostedServices +{ + public class WorkOrderWeekRolledHostedService : BackgroundService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly WeekRolledJobOptions _options; + private readonly WorkOrderJobRunState _runState; + private DateOnly? _lastRunDate; + + public WorkOrderWeekRolledHostedService( + IServiceScopeFactory scopeFactory, + IOptions options, + WorkOrderJobRunState runState, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + _options = options.Value.WeekRolled; + _runState = runState; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + if (_options.Enabled && ShouldRunNow()) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + if (_lastRunDate != today) + { + if (await RunJobAsync(stoppingToken)) + _lastRunDate = today; + } + } + + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + } + } + + private async Task RunJobAsync(CancellationToken stoppingToken) + { + try + { + var sourceWeekStart = WorkOrderOperationalWeek.GetPreviousOperationalWeekStart(DateTime.UtcNow); + + using var scope = _scopeFactory.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + await service.ProcessWeekRolledAsync(sourceWeekStart, stoppingToken); + + _runState.LastWeekRolledRunUtc = DateTime.UtcNow; + _runState.LastWeekRolledError = null; + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _runState.LastWeekRolledError = ex.Message; + _logger.LogError(ex, "WeekRolled hosted job failed."); + return false; + } + } + + private bool ShouldRunNow() + { + var now = DateTime.UtcNow; + if (!Enum.TryParse(_options.DayOfWeek, ignoreCase: true, out var targetDay)) + targetDay = DayOfWeek.Monday; + + if (now.DayOfWeek != targetDay) + return false; + + if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt)) + runAt = new TimeOnly(0, 5); + + return TimeOnly.FromDateTime(now) >= runAt; + } + } +} diff --git a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs index 02c6278..308dd86 100644 --- a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs +++ b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs @@ -72,4 +72,37 @@ namespace Api.SeaHavenIndustries.Infrastructure return token.Token; } } + + public class VendorDocumentStorageAdapter : IVendorDocumentStoragePort + { + private readonly IWebHostEnvironment _environment; + + public VendorDocumentStorageAdapter(IWebHostEnvironment environment) + { + _environment = environment; + } + + public async Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken) + { + var path = Helper.VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, vendorId, dispatchId, storedFileName); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await using var file = File.Create(path); + await content.CopyToAsync(file, cancellationToken); + } + + public Stream OpenRead(int vendorId, int dispatchId, string storedFileName) + { + var path = Helper.VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, vendorId, dispatchId, storedFileName); + return File.OpenRead(path); + } + + public void Delete(int vendorId, int dispatchId, string storedFileName) + { + var path = Helper.VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, vendorId, dispatchId, storedFileName); + if (File.Exists(path)) + { + File.Delete(path); + } + } + } } diff --git a/Api.SeaHavenIndustries/Middleware/LegacyDeprecationMiddleware.cs b/Api.SeaHavenIndustries/Middleware/LegacyDeprecationMiddleware.cs new file mode 100644 index 0000000..0e2ccb6 --- /dev/null +++ b/Api.SeaHavenIndustries/Middleware/LegacyDeprecationMiddleware.cs @@ -0,0 +1,57 @@ +using Api.SeaHavenIndustries.Options; +using Microsoft.Extensions.Options; + +namespace Api.SeaHavenIndustries.Middleware +{ + /// Adds Sunset/Deprecation headers to legacy WorkOrder REST routes. + public class LegacyDeprecationMiddleware + { + private static readonly HashSet LegacyPathSegments = new(StringComparer.OrdinalIgnoreCase) + { + "GetWorkOrderList", + "AddWorkorder", + "EditWorkorder", + "ChangeStatus", + "ChangeAssignment", + "DeleteWorkorder", + "GetWorkorderById" + }; + + private readonly RequestDelegate _next; + private readonly LegacyEndpointOptions _options; + + public LegacyDeprecationMiddleware(RequestDelegate next, IOptions options) + { + _next = next; + _options = options.Value; + } + + public async Task InvokeAsync(HttpContext context) + { + if (_options.DeprecationEnabled) + { + var path = context.Request.Path.Value ?? ""; + var isLegacyWo = (path.Contains("/WorkOrder", StringComparison.OrdinalIgnoreCase) + || path.Contains("/workorders", StringComparison.OrdinalIgnoreCase)) + && LegacyPathSegments.Any(seg => path.Contains(seg, StringComparison.OrdinalIgnoreCase)); + + if (isLegacyWo) + { + context.Response.OnStarting(() => + { + context.Response.Headers["Deprecation"] = "true"; + if (!string.IsNullOrWhiteSpace(_options.SunsetDate) + && DateTimeOffset.TryParse(_options.SunsetDate, out var sunset)) + { + context.Response.Headers["Sunset"] = sunset.UtcDateTime.ToString("r"); + } + context.Response.Headers["Link"] = "; rel=\"successor-version\""; + return Task.CompletedTask; + }); + } + } + + await _next(context); + } + } +} diff --git a/Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs b/Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs new file mode 100644 index 0000000..ffd01ef --- /dev/null +++ b/Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs @@ -0,0 +1,12 @@ +namespace Api.SeaHavenIndustries.Options +{ + public class LegacyEndpointOptions + { + public const string SectionName = "LegacyEndpoints"; + + public bool DeprecationEnabled { get; set; } + + /// ISO date returned in Sunset header (e.g. 2026-12-31). + public string? SunsetDate { get; set; } + } +} diff --git a/Api.SeaHavenIndustries/Options/SyncOptions.cs b/Api.SeaHavenIndustries/Options/SyncOptions.cs new file mode 100644 index 0000000..230e16c --- /dev/null +++ b/Api.SeaHavenIndustries/Options/SyncOptions.cs @@ -0,0 +1,10 @@ +namespace Api.SeaHavenIndustries.Options +{ + public class SyncOptions + { + public const string SectionName = "Sync"; + + /// When false, SyncController endpoints return 503 (post-Lambda cutover). Default false to avoid dual-run with ingest. + public bool Enabled { get; set; } + } +} diff --git a/Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs b/Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs new file mode 100644 index 0000000..96bed87 --- /dev/null +++ b/Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs @@ -0,0 +1,13 @@ +namespace Api.SeaHavenIndustries.Options +{ + public class WorkOrderIngestOptions + { + public const string SectionName = "WorkOrderIngest"; + + /// When false, ingest endpoints return 503. Default false until a real ApiKey is provisioned. + public bool Enabled { get; set; } + + /// Shared secret for X-Ingest-Key header (Lambda / service accounts). + public string? ApiKey { get; set; } + } +} diff --git a/Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs b/Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs new file mode 100644 index 0000000..791638c --- /dev/null +++ b/Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs @@ -0,0 +1,23 @@ +namespace Api.SeaHavenIndustries.Options +{ + public class WorkOrderJobsOptions + { + public const string SectionName = "WorkOrderJobs"; + + public WeekRolledJobOptions WeekRolled { get; set; } = new(); + public PastDueCacheJobOptions PastDueCache { get; set; } = new(); + } + + public class WeekRolledJobOptions + { + public bool Enabled { get; set; } = true; + public string RunAtUtc { get; set; } = "00:05"; + public string DayOfWeek { get; set; } = "Monday"; + } + + public class PastDueCacheJobOptions + { + public bool Enabled { get; set; } + public string RunAtUtc { get; set; } = "00:10"; + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index c711931..d043c46 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -1,4 +1,7 @@ using Api.SeaHavenIndustries.Helper; +using Api.SeaHavenIndustries.HostedServices; +using Api.SeaHavenIndustries.Middleware; +using Api.SeaHavenIndustries.Options; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; @@ -9,6 +12,7 @@ using Microsoft.OpenApi.Models; using System.Text; using SeaHaven.DataServices.DependencyInjection; using SeaHaven.Services.DependencyInjection; +using SeaHaven.Services.Implementation; var builder = WebApplication.CreateBuilder(args); ConfigurationManager configuration = builder.Configuration; @@ -25,6 +29,7 @@ builder.Services.AddIdentity(options => builder.Services.AddControllers(options => { options.Filters.Add(); + options.Filters.Add(); }).AddJsonOptions(options => { options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value @@ -40,6 +45,9 @@ option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().All builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); builder.Services.AddSingleton(sp => { var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance(); @@ -57,6 +65,39 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.Configure( + builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName)); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(WorkOrderIngestOptions.SectionName)) + .Validate( + o => !o.Enabled + || (!string.IsNullOrWhiteSpace(o.ApiKey) + && !o.ApiKey.Contains("${", StringComparison.Ordinal) + && o.ApiKey.Length >= 32), + "WorkOrderIngest:ApiKey must be a real secret (env/user-secrets, length >= 32) when ingest is enabled.") + .ValidateOnStart(); +builder.Services.Configure( + builder.Configuration.GetSection(SyncOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(LegacyEndpointOptions.SectionName)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService().State); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddOptions() + .Configure, + Microsoft.Extensions.Options.IOptions, + Microsoft.Extensions.Options.IOptions>( + (health, sync, ingest, legacy) => + { + health.SyncEnabled = sync.Value.Enabled; + health.IngestEnabled = ingest.Value.Enabled; + health.LegacyDeprecationEnabled = legacy.Value.DeprecationEnabled; + health.LegacySunsetDate = legacy.Value.SunsetDate; + }); builder.Services.AddAuthentication(options => { @@ -125,6 +166,7 @@ if (!app.Environment.IsDevelopment()) app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseCors(); +app.UseMiddleware(); app.UseAuthentication(); app.UseAuthorization(); diff --git a/Api.SeaHavenIndustries/appsettings.Production.json b/Api.SeaHavenIndustries/appsettings.Production.json index 651d68c..b4aae5c 100644 --- a/Api.SeaHavenIndustries/appsettings.Production.json +++ b/Api.SeaHavenIndustries/appsettings.Production.json @@ -14,6 +14,12 @@ // Provide the real value via environment variable SendGrid__ApiKey or the instance secret store. "ApiKey": "${SENDGRID_API_KEY}" }, + // Production must supply a reachable ClamAV Host (environment variable ClamAV__Host). + // While Host is empty the scanner is unavailable and uploads stay quarantined (423 Locked). + "ClamAV": { + "Host": "", + "Port": 3310 + }, "AllowedHosts": "*", "JWT": { "ValidAudience": "http://console.seahavenind.com", diff --git a/Api.SeaHavenIndustries/appsettings.json b/Api.SeaHavenIndustries/appsettings.json index 2d5dc80..4156b9d 100644 --- a/Api.SeaHavenIndustries/appsettings.json +++ b/Api.SeaHavenIndustries/appsettings.json @@ -1,4 +1,4 @@ -{ +{ "ConnectionStrings": { //"DefaultConnection": "Server=DESKTOP-64LC14O\SQLEXPRESS;Database=SeahavenIndustries;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true", @@ -17,11 +17,34 @@ // Provide the real value via environment variable SendGrid__ApiKey or user-secrets. "ApiKey": "${SENDGRID_API_KEY}" }, + // Malware scanner for vendor completion document uploads. While Host is empty the + // scanner is considered unavailable and uploaded files stay quarantined (download + // returns 423 Locked until a scan passes). Production must supply a real Host. + "ClamAV": { + "Host": "", + "Port": 3310 + }, "AllowedHosts": "*", "JWT": { "ValidAudience": "http://localhost:4200", "ValidIssuer": "http://localhost:7195", // Provide the real value via environment variable JWT__Secret or user-secrets. "Secret": "${JWT_SECRET}" - } -} \ No newline at end of file + }, + "WorkOrderJobs": { + "WeekRolled": { "Enabled": true, "RunAtUtc": "00:05", "DayOfWeek": "Monday" }, + "PastDueCache": { "Enabled": false, "RunAtUtc": "00:10" } + }, + "WorkOrderIngest": { + "Enabled": false, + "ApiKey": "${WORKORDER_INGEST_API_KEY}" + }, + "Sync": { + "Enabled": true + }, + "LegacyEndpoints": { + "DeprecationEnabled": false, + "SunsetDate": "2026-12-31" + }, + "FrontendBaseUrl": "https://shoc.seahaven.com" +} diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index c7c85c6..48c34fe 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -38,10 +38,51 @@ namespace Data.SeaHavenIndustries .HasIndex(l => new { l.WorkOrderId, l.FieldName }) .IsUnique(); + builder.Entity() + .HasIndex(l => new { l.WorkOrderId, l.SourceWeekStart }) + .IsUnique(); + + builder.Entity() + .HasIndex(t => new { t.ServiceKey, t.IsActive }); + + builder.Entity() .Property(w => w.RowVersion) .IsRowVersion(); + // Bounded lengths required for SQL Server index keys (nvarchar(max) is not indexable). + // SiteCode: board create contract MaximumLength(32). InternalWONumber: normalized to 11 digits. + builder.Entity() + .Property(w => w.SiteCode) + .HasMaxLength(32); + + builder.Entity() + .Property(w => w.InternalWONumber) + .HasMaxLength(11); + + + builder.Entity() + .HasIndex(w => w.SiteCode) + .HasFilter("[istemplate] = 0"); + + builder.Entity() + .HasIndex(w => w.InternalWONumber) + .HasFilter("[istemplate] = 0"); + + builder.Entity() + .HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate }) + .HasFilter("[istemplate] = 0"); + + builder.Entity() + .Property(w => w.ExternalWorkOrderId) + .HasMaxLength(450); + + builder.Entity() + .HasIndex(w => w.ExternalWorkOrderId) + .IsUnique() + .HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); + + builder.Entity() .Property(d => d.RowVersion) .IsRowVersion(); @@ -61,6 +102,16 @@ namespace Data.SeaHavenIndustries .WithMany(c => c.Vendors) .HasForeignKey(v => v.CompanyId) .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasIndex(p => new { p.LocationId, p.VendorId, p.Trade }) + .IsUnique() + .HasFilter("[Trade] IS NOT NULL"); + + builder.Entity() + .HasIndex(p => new { p.LocationId, p.VendorId }) + .IsUnique() + .HasFilter("[Trade] IS NULL"); } public DbSet Categories { get; set; } public DbSet Locations { get; set; } @@ -74,13 +125,17 @@ namespace Data.SeaHavenIndustries public DbSet WorkOrderContacts { get; set; } public DbSet workOrderCategories { get; set; } public DbSet workOrderAttachments { get; set; } + public DbSet CompletionDocTemplates { get; set; } public DbSet WorkOrderAuditLogs { get; set; } public DbSet WorkOrderFieldLocks { get; set; } + public DbSet WorkOrderWeekRolledLedgers { get; set; } public DbSet DropdownOptions { get; set; } public DbSet Vendors { get; set; } public DbSet VendorCompanies { get; set; } public DbSet VendorAuditLogs { get; set; } public DbSet VendorAccessTokens { get; set; } + public DbSet VendorCompletionDocuments { get; set; } + public DbSet SitePreferredVendors { get; set; } public DbSet Dispatches { get; set; } public DbSet DispatchWorkOrders { get; set; } public DbSet DispatchChecklistItems { get; set; } @@ -106,6 +161,51 @@ namespace Data.SeaHavenIndustries public DbSet Departments { get; set; } public DbSet JobTitles { get; set; } public DbSet Regions { get; set; } + + public override int SaveChanges() + { + BumpRowVersionsForInMemoryProvider(); + return base.SaveChanges(); + } + + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + BumpRowVersionsForInMemoryProvider(); + return base.SaveChangesAsync(cancellationToken); + } + + private void BumpRowVersionsForInMemoryProvider() + { + if (!Database.ProviderName?.Contains("InMemory", StringComparison.OrdinalIgnoreCase) ?? true) + return; + + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Modified) + entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion); + } + + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Modified) + entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion); + } + } + + private static byte[] IncrementRowVersion(byte[]? current) + { + var bytes = current != null && current.Length > 0 + ? (byte[])current.Clone() + : new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 }; + + for (var i = 0; i < bytes.Length; i++) + { + if (++bytes[i] != 0) + break; + } + + return bytes; + } } public class ApplicationUser : IdentityUser diff --git a/Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs b/Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs new file mode 100644 index 0000000..d4a93bc --- /dev/null +++ b/Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs @@ -0,0 +1,14 @@ +namespace Data.SeaHavenIndustries.Enums +{ + /// + /// Shared terminal lifecycle statuses for EF-translatable filters and board rules. + /// + public static class LifecycleStatusSets + { + public static readonly LifecycleStatus?[] Terminal = + { + LifecycleStatus.Completed, + LifecycleStatus.Canceled + }; + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs new file mode 100644 index 0000000..e860475 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs @@ -0,0 +1,3008 @@ +// +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("20260624200000_Phase4_SearchIndexes")] + partial class Phase4_SearchIndexes + { + /// + 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.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("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("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("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.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("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("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("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + 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("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + 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", "Status") + .IsUnique() + .HasFilter("[Status] = 'Pending'"); + + 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.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("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.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.Vendor", 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("CompanyName") + .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("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Phone") + .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.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.WorkOrder", 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("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("ExternalWorkOrderId") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + 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("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("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + 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("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + 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("AssignTo"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("InternalWONumber") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("SiteCode") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("WorkOrderType"); + + 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("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.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("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.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.Navigation("Dispatch"); + }); + + 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.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.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + 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("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("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("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("Dispatches"); + }); + + 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/20260624200000_Phase4_SearchIndexes.cs b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs new file mode 100644 index 0000000..c92d732 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs @@ -0,0 +1,84 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class Phase4_SearchIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "SiteCode", + table: "workOrders", + type: "nvarchar(32)", + maxLength: 32, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + + migrationBuilder.AlterColumn( + name: "InternalWONumber", + table: "workOrders", + type: "nvarchar(11)", + maxLength: 11, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + + migrationBuilder.CreateIndex( + name: "IX_workOrders_SiteCode", + table: "workOrders", + column: "SiteCode", + filter: "[istemplate] = 0"); + + migrationBuilder.CreateIndex( + name: "IX_workOrders_InternalWONumber", + table: "workOrders", + column: "InternalWONumber", + filter: "[istemplate] = 0"); + + migrationBuilder.CreateIndex( + name: "IX_workOrders_LifecycleStatus_ScheduledDate", + table: "workOrders", + columns: new[] { "LifecycleStatus", "ScheduledDate" }, + filter: "[istemplate] = 0"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_workOrders_SiteCode", + table: "workOrders"); + + migrationBuilder.DropIndex( + name: "IX_workOrders_InternalWONumber", + table: "workOrders"); + + migrationBuilder.DropIndex( + name: "IX_workOrders_LifecycleStatus_ScheduledDate", + table: "workOrders"); + + migrationBuilder.AlterColumn( + name: "SiteCode", + table: "workOrders", + type: "nvarchar(max)", + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(32)", + oldMaxLength: 32); + + migrationBuilder.AlterColumn( + name: "InternalWONumber", + table: "workOrders", + type: "nvarchar(max)", + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(11)", + oldMaxLength: 11); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs b/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs new file mode 100644 index 0000000..f52940d --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs @@ -0,0 +1,88 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260625120000_Phase6_CompletionSlideOver")] + public partial class Phase6_CompletionSlideOver : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" + IF COL_LENGTH('workOrderAttachments', 'Category') IS NULL + BEGIN + ALTER TABLE workOrderAttachments ADD Category int NULL; + END + "); + + migrationBuilder.Sql(@" + IF OBJECT_ID(N'dbo.CompletionDocTemplates', N'U') IS NULL + BEGIN + CREATE TABLE CompletionDocTemplates ( + Id int NOT NULL IDENTITY(1,1), + Name nvarchar(max) NOT NULL, + ServiceKey nvarchar(450) NOT NULL, + WorkOrderType int NULL, + TemplateUrl nvarchar(max) NOT NULL, + IsActive bit NOT NULL, + IsDeleted bit NULL, + createdby nvarchar(max) NULL, + DeleterUserId nvarchar(max) NULL, + DeletionTime datetime2 NULL, + CreatedDate datetime2 NULL, + LastModificationTime datetime2 NULL, + LastModifierUserId int NULL, + CONSTRAINT PK_CompletionDocTemplates PRIMARY KEY (Id) + ); + END + "); + + migrationBuilder.Sql(@" + IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = N'IX_CompletionDocTemplates_ServiceKey_IsActive' + AND object_id = OBJECT_ID(N'dbo.CompletionDocTemplates')) + BEGIN + CREATE INDEX IX_CompletionDocTemplates_ServiceKey_IsActive + ON CompletionDocTemplates (ServiceKey, IsActive); + END + "); + + migrationBuilder.Sql(@" + UPDATE workOrders + SET DocStatus = 1 + WHERE DocStatus IS NULL + AND SignOffAttachment IS NOT NULL + AND LTRIM(RTRIM(SignOffAttachment)) <> ''; + "); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" + UPDATE workOrders + SET DocStatus = NULL + WHERE DocStatus = 1 + AND SignOffAttachment IS NOT NULL + AND LTRIM(RTRIM(SignOffAttachment)) <> ''; + "); + + migrationBuilder.Sql(@" + IF OBJECT_ID(N'dbo.CompletionDocTemplates', N'U') IS NOT NULL + DROP TABLE CompletionDocTemplates; + "); + + migrationBuilder.Sql(@" + IF COL_LENGTH('workOrderAttachments', 'Category') IS NOT NULL + ALTER TABLE workOrderAttachments DROP COLUMN Category; + "); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs new file mode 100644 index 0000000..ba49c97 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs @@ -0,0 +1,2996 @@ +// +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("20260701120000_Phase1_BoardIndexes")] + partial class Phase1_BoardIndexes + { + 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.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("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("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("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.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("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("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("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + 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("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + 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", "Status") + .IsUnique() + .HasFilter("[Status] = 'Pending'"); + + 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.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("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.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.Vendor", 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("CompanyName") + .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("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Phone") + .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.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.WorkOrder", 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("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("ExternalWorkOrderId") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasColumnType("nvarchar(max)"); + + 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("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("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .HasColumnType("nvarchar(max)"); + + 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("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + 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("AssignTo"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("WorkOrderType"); + + 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("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.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("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.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.Navigation("Dispatch"); + }); + + 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.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.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + 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("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("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("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("Dispatches"); + }); + + 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/20260701120000_Phase1_BoardIndexes.cs b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs new file mode 100644 index 0000000..744a9fa --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class Phase1_BoardIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = 'IX_workOrders_ScheduledDate' + AND object_id = OBJECT_ID('workOrders')) + BEGIN + CREATE INDEX IX_workOrders_ScheduledDate ON workOrders (ScheduledDate); + END + + IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = 'IX_workOrders_AssignTo_ScheduledDate' + AND object_id = OBJECT_ID('workOrders')) + BEGIN + CREATE INDEX IX_workOrders_AssignTo_ScheduledDate ON workOrders (AssignTo, ScheduledDate); + END + + IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = 'IX_workOrders_WorkOrderType' + AND object_id = OBJECT_ID('workOrders')) + BEGIN + CREATE INDEX IX_workOrders_WorkOrderType ON workOrders (WorkOrderType); + END + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + IF EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = 'IX_workOrders_WorkOrderType' + AND object_id = OBJECT_ID('workOrders')) + DROP INDEX IX_workOrders_WorkOrderType ON workOrders; + + IF EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = 'IX_workOrders_AssignTo_ScheduledDate' + AND object_id = OBJECT_ID('workOrders')) + DROP INDEX IX_workOrders_AssignTo_ScheduledDate ON workOrders; + + IF EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = 'IX_workOrders_ScheduledDate' + AND object_id = OBJECT_ID('workOrders')) + DROP INDEX IX_workOrders_ScheduledDate ON workOrders; + """); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.Designer.cs new file mode 100644 index 0000000..7387556 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.Designer.cs @@ -0,0 +1,3048 @@ +// +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("20260709170202_Phase5_DomainEvents")] + partial class Phase5_DomainEvents + { + /// + 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.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", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .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("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", (string)null); + }); + + 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", (string)null); + }); + + 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("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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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("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("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("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + 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", "Status") + .IsUnique() + .HasFilter("[Status] = 'Pending'"); + + b.ToTable("DispatchUpliftRequests", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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("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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyName") + .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("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Phone") + .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.ToTable("Vendors", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", 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("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("ExternalWorkOrderId") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + 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("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("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("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + 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("AssignTo"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("InternalWONumber") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("SiteCode") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("WorkOrderType"); + + b.ToTable("workOrders", (string)null); + }); + + 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("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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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.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.Navigation("Dispatch"); + }); + + 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.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.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + 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("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.WorkOrderWeekRolledLedger", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + 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("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("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("Dispatches"); + }); + + 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/20260709170202_Phase5_DomainEvents.cs b/Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.cs new file mode 100644 index 0000000..6a0307f --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class Phase5_DomainEvents : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "WorkOrderWeekRolledLedgers", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + WorkOrderId = table.Column(type: "int", nullable: false), + SourceWeekStart = table.Column(type: "date", nullable: false), + ProcessedAt = table.Column(type: "datetime2", nullable: false), + CorrelationId = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WorkOrderWeekRolledLedgers", x => x.Id); + table.ForeignKey( + name: "FK_WorkOrderWeekRolledLedgers_workOrders_WorkOrderId", + column: x => x.WorkOrderId, + principalTable: "workOrders", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_WorkOrderWeekRolledLedgers_WorkOrderId_SourceWeekStart", + table: "WorkOrderWeekRolledLedgers", + columns: new[] { "WorkOrderId", "SourceWeekStart" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "WorkOrderWeekRolledLedgers"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260713120000_Phase7_ExternalWorkOrderIdUnique.cs b/Data.SeaHavenIndustries/Migrations/20260713120000_Phase7_ExternalWorkOrderIdUnique.cs new file mode 100644 index 0000000..291a952 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260713120000_Phase7_ExternalWorkOrderIdUnique.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class Phase7_ExternalWorkOrderIdUnique : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "ExternalWorkOrderId", + table: "workOrders", + type: "nvarchar(450)", + maxLength: 450, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "IX_workOrders_ExternalWorkOrderId", + table: "workOrders", + column: "ExternalWorkOrderId", + unique: true, + filter: "[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_workOrders_ExternalWorkOrderId", + table: "workOrders"); + + migrationBuilder.AlterColumn( + name: "ExternalWorkOrderId", + table: "workOrders", + type: "nvarchar(max)", + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(450)", + oldMaxLength: 450, + oldNullable: true); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260715160000_WoServiceNotesStructuredFields.cs b/Data.SeaHavenIndustries/Migrations/20260715160000_WoServiceNotesStructuredFields.cs new file mode 100644 index 0000000..e49532f --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260715160000_WoServiceNotesStructuredFields.cs @@ -0,0 +1,121 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260715160000_WoServiceNotesStructuredFields")] + public partial class WoServiceNotesStructuredFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PocName", + table: "workOrders", + type: "nvarchar(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "PocPhone", + table: "workOrders", + type: "nvarchar(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "PocNotes", + table: "workOrders", + type: "nvarchar(2000)", + maxLength: 2000, + nullable: true); + + migrationBuilder.AddColumn( + name: "ExtraServices", + table: "workOrders", + type: "nvarchar(2000)", + maxLength: 2000, + nullable: true); + + migrationBuilder.AddColumn( + name: "ServiceNotes", + table: "workOrders", + type: "nvarchar(4000)", + maxLength: 4000, + nullable: true); + + migrationBuilder.AddColumn( + name: "TechPhone", + table: "workOrders", + type: "nvarchar(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "VendorNotes", + table: "workOrders", + type: "nvarchar(2000)", + maxLength: 2000, + nullable: true); + + migrationBuilder.AddColumn( + name: "TechPhone", + table: "Dispatches", + type: "nvarchar(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "VendorNotes", + table: "Dispatches", + type: "nvarchar(2000)", + maxLength: 2000, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PocName", + table: "workOrders"); + + migrationBuilder.DropColumn( + name: "PocPhone", + table: "workOrders"); + + migrationBuilder.DropColumn( + name: "PocNotes", + table: "workOrders"); + + migrationBuilder.DropColumn( + name: "ExtraServices", + table: "workOrders"); + + migrationBuilder.DropColumn( + name: "ServiceNotes", + table: "workOrders"); + + migrationBuilder.DropColumn( + name: "TechPhone", + table: "workOrders"); + + migrationBuilder.DropColumn( + name: "VendorNotes", + table: "workOrders"); + + migrationBuilder.DropColumn( + name: "TechPhone", + table: "Dispatches"); + + migrationBuilder.DropColumn( + name: "VendorNotes", + table: "Dispatches"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260715180000_WoFlagColor.cs b/Data.SeaHavenIndustries/Migrations/20260715180000_WoFlagColor.cs new file mode 100644 index 0000000..e48600e --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260715180000_WoFlagColor.cs @@ -0,0 +1,33 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260715180000_WoFlagColor")] + public partial class WoFlagColor : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FlagColor", + table: "workOrders", + type: "nvarchar(7)", + maxLength: 7, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FlagColor", + table: "workOrders"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.Designer.cs new file mode 100644 index 0000000..9dc7d67 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.Designer.cs @@ -0,0 +1,3441 @@ +// +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("20260723220614_AddVendorRoadmapOperations")] + partial class AddVendorRoadmapOperations + { + /// + 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.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("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("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("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.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("VendorId") + .HasColumnType("int"); + + 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("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + 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", "Status") + .IsUnique() + .HasFilter("[Status] = 'Pending'"); + + 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("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.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("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("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("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("ExternalWorkOrderId") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasColumnType("nvarchar(max)"); + + 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("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("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") + .HasColumnType("nvarchar(max)"); + + 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("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + 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("AssignTo"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + 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("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.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("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.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.Navigation("Dispatch"); + }); + + 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.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("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("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/20260723220614_AddVendorRoadmapOperations.cs b/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.cs new file mode 100644 index 0000000..4125639 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.cs @@ -0,0 +1,230 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class AddVendorRoadmapOperations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AvailabilityStatus", + table: "Vendors", + type: "nvarchar(max)", + nullable: false, + defaultValue: "Unknown"); + + migrationBuilder.AddColumn( + name: "AvailabilityUpdatedAt", + table: "Vendors", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "CommercialStatusUpdatedAt", + table: "Dispatches", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "EstimatedArrivalAt", + table: "Dispatches", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "EtaManualOverride", + table: "Dispatches", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "InvoiceNumber", + table: "Dispatches", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "InvoiceStatus", + table: "Dispatches", + type: "nvarchar(max)", + nullable: false, + defaultValue: "Not Submitted"); + + migrationBuilder.AddColumn( + name: "PaymentStatus", + table: "Dispatches", + type: "nvarchar(max)", + nullable: false, + defaultValue: "Unavailable"); + + migrationBuilder.CreateTable( + name: "SitePreferredVendors", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + LocationId = table.Column(type: "int", nullable: false), + VendorId = table.Column(type: "int", nullable: false), + Trade = table.Column(type: "nvarchar(450)", nullable: true), + SortOrder = table.Column(type: "int", nullable: false), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + IsDeleted = table.Column(type: "bit", nullable: true), + createdby = table.Column(type: "nvarchar(max)", nullable: true), + DeleterUserId = table.Column(type: "nvarchar(max)", nullable: true), + DeletionTime = table.Column(type: "datetime2", nullable: true), + CreatedDate = table.Column(type: "datetime2", nullable: true), + LastModificationTime = table.Column(type: "datetime2", nullable: true), + LastModifierUserId = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SitePreferredVendors", x => x.Id); + table.ForeignKey( + name: "FK_SitePreferredVendors_Locations_LocationId", + column: x => x.LocationId, + principalTable: "Locations", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_SitePreferredVendors_Vendors_VendorId", + column: x => x.VendorId, + principalTable: "Vendors", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "VendorCompletionDocuments", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + VendorId = table.Column(type: "int", nullable: false), + DispatchId = table.Column(type: "int", nullable: false), + WorkOrderId = table.Column(type: "int", nullable: false), + OriginalFileName = table.Column(type: "nvarchar(max)", nullable: false), + StoredFileName = table.Column(type: "nvarchar(max)", nullable: false), + ContentType = table.Column(type: "nvarchar(max)", nullable: false), + SizeBytes = table.Column(type: "bigint", nullable: false), + ScanStatus = table.Column(type: "nvarchar(max)", nullable: false), + ReviewStatus = table.Column(type: "nvarchar(max)", nullable: false), + RejectionReason = table.Column(type: "nvarchar(max)", nullable: true), + Version = table.Column(type: "int", nullable: false), + ReplacesDocumentId = table.Column(type: "int", nullable: true), + ScannedAt = table.Column(type: "datetime2", nullable: true), + ReviewedAt = table.Column(type: "datetime2", nullable: true), + IsDeleted = table.Column(type: "bit", nullable: true), + createdby = table.Column(type: "nvarchar(max)", nullable: true), + DeleterUserId = table.Column(type: "nvarchar(max)", nullable: true), + DeletionTime = table.Column(type: "datetime2", nullable: true), + CreatedDate = table.Column(type: "datetime2", nullable: true), + LastModificationTime = table.Column(type: "datetime2", nullable: true), + LastModifierUserId = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_VendorCompletionDocuments", x => x.Id); + table.ForeignKey( + name: "FK_VendorCompletionDocuments_Dispatches_DispatchId", + column: x => x.DispatchId, + principalTable: "Dispatches", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_VendorCompletionDocuments_Vendors_VendorId", + column: x => x.VendorId, + principalTable: "Vendors", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_VendorCompletionDocuments_workOrders_WorkOrderId", + column: x => x.WorkOrderId, + principalTable: "workOrders", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_SitePreferredVendors_LocationId_VendorId", + table: "SitePreferredVendors", + columns: new[] { "LocationId", "VendorId" }, + unique: true, + filter: "[Trade] IS NULL"); + + migrationBuilder.CreateIndex( + name: "IX_SitePreferredVendors_LocationId_VendorId_Trade", + table: "SitePreferredVendors", + columns: new[] { "LocationId", "VendorId", "Trade" }, + unique: true, + filter: "[Trade] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_SitePreferredVendors_VendorId", + table: "SitePreferredVendors", + column: "VendorId"); + + migrationBuilder.CreateIndex( + name: "IX_VendorCompletionDocuments_DispatchId", + table: "VendorCompletionDocuments", + column: "DispatchId"); + + migrationBuilder.CreateIndex( + name: "IX_VendorCompletionDocuments_VendorId", + table: "VendorCompletionDocuments", + column: "VendorId"); + + migrationBuilder.CreateIndex( + name: "IX_VendorCompletionDocuments_WorkOrderId", + table: "VendorCompletionDocuments", + column: "WorkOrderId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SitePreferredVendors"); + + migrationBuilder.DropTable( + name: "VendorCompletionDocuments"); + + migrationBuilder.DropColumn( + name: "AvailabilityStatus", + table: "Vendors"); + + migrationBuilder.DropColumn( + name: "AvailabilityUpdatedAt", + table: "Vendors"); + + migrationBuilder.DropColumn( + name: "CommercialStatusUpdatedAt", + table: "Dispatches"); + + migrationBuilder.DropColumn( + name: "EstimatedArrivalAt", + table: "Dispatches"); + + migrationBuilder.DropColumn( + name: "EtaManualOverride", + table: "Dispatches"); + + migrationBuilder.DropColumn( + name: "InvoiceNumber", + table: "Dispatches"); + + migrationBuilder.DropColumn( + name: "InvoiceStatus", + table: "Dispatches"); + + migrationBuilder.DropColumn( + name: "PaymentStatus", + table: "Dispatches"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 09b4ef3..7601a5f 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -107,7 +107,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Accounts"); + b.ToTable("Accounts", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => @@ -175,7 +175,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("ContactId"); - b.ToTable("Addresses"); + b.ToTable("Addresses", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => @@ -334,7 +334,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("LocationId"); - b.ToTable("Assets"); + b.ToTable("Assets", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => @@ -371,7 +371,62 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Categories"); + b.ToTable("Categories", (string)null); + }); + + 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") + .HasDatabaseName("IX_CompletionDocTemplates_ServiceKey_IsActive"); + + b.ToTable("CompletionDocTemplates", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => @@ -438,7 +493,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkerOrderId"); - b.ToTable("Comments"); + b.ToTable("Comments", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => @@ -504,7 +559,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("ContactId"); - b.ToTable("ContactDetails"); + b.ToTable("ContactDetails", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => @@ -596,7 +651,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("LocationId"); - b.ToTable("Contacts"); + b.ToTable("Contacts", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => @@ -614,7 +669,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Departments"); + b.ToTable("Departments", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => @@ -631,6 +686,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("AcknowledgedAt") .HasColumnType("datetime2"); + b.Property("CommercialStatusUpdatedAt") + .HasColumnType("datetime2"); + b.Property("CompletedDate") .HasColumnType("datetime2"); @@ -655,6 +713,19 @@ namespace Data.SeaHavenIndustries.Migrations 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"); @@ -670,6 +741,10 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("PONumber") .HasColumnType("nvarchar(max)"); + b.Property("PaymentStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + b.Property("ReplyToAddress") .HasColumnType("nvarchar(max)"); @@ -684,9 +759,17 @@ namespace Data.SeaHavenIndustries.Migrations 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"); @@ -705,7 +788,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("Dispatches"); + b.ToTable("Dispatches", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => @@ -764,7 +847,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("DispatchChecklistItems"); + b.ToTable("DispatchChecklistItems", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => @@ -818,7 +901,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("DispatchId"); - b.ToTable("DispatchSignoffs"); + b.ToTable("DispatchSignoffs", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => @@ -888,7 +971,7 @@ namespace Data.SeaHavenIndustries.Migrations .IsUnique() .HasFilter("[Status] = 'Pending'"); - b.ToTable("DispatchUpliftRequests"); + b.ToTable("DispatchUpliftRequests", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => @@ -911,7 +994,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("DispatchWorkOrders"); + b.ToTable("DispatchWorkOrders", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => @@ -941,7 +1024,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("DropdownOptions"); + b.ToTable("DropdownOptions", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => @@ -1046,7 +1129,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("RegionId"); - b.ToTable("Employees"); + b.ToTable("Employees", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => @@ -1086,7 +1169,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("EmployeeId") .IsUnique(); - b.ToTable("EmployeeAddresses"); + b.ToTable("EmployeeAddresses", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => @@ -1114,7 +1197,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("EmployeeId"); - b.ToTable("EmployeeEmails"); + b.ToTable("EmployeeEmails", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => @@ -1144,7 +1227,87 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("EmployeeId"); - b.ToTable("EmployeePhones"); + b.ToTable("EmployeePhones", (string)null); + }); + + 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 => @@ -1212,7 +1375,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("FollowUps"); + b.ToTable("FollowUps", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => @@ -1237,7 +1400,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("ForgetPasswordCodes"); + b.ToTable("ForgetPasswordCodes", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => @@ -1255,7 +1418,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("JobTitles"); + b.ToTable("JobTitles", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => @@ -1325,7 +1488,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Locations"); + b.ToTable("Locations", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => @@ -1383,7 +1546,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("LocationId"); - b.ToTable("PMSchedules"); + b.ToTable("PMSchedules", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => @@ -1457,7 +1620,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("Quotes"); + b.ToTable("Quotes", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => @@ -1505,7 +1668,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("QuoteId"); - b.ToTable("QuotesLines"); + b.ToTable("QuotesLines", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => @@ -1523,7 +1686,66 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Regions"); + b.ToTable("Regions", (string)null); + }); + + 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 => @@ -1566,7 +1788,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("TaskListTemplates"); + b.ToTable("TaskListTemplates", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => @@ -1611,7 +1833,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("TaskListTemplateId"); - b.ToTable("TaskListTemplateItems"); + b.ToTable("TaskListTemplateItems", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => @@ -1713,7 +1935,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("LocationId"); - b.ToTable("Templates"); + b.ToTable("Templates", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => @@ -1727,6 +1949,13 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Address") .HasColumnType("nvarchar(max)"); + b.Property("AvailabilityStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityUpdatedAt") + .HasColumnType("datetime2"); + b.Property("City") .HasColumnType("nvarchar(max)"); @@ -1794,7 +2023,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("CompanyId"); - b.ToTable("Vendors"); + b.ToTable("Vendors", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => @@ -1853,7 +2082,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("VendorId"); - b.ToTable("VendorAccessTokens"); + b.ToTable("VendorAccessTokens", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => @@ -1958,6 +2187,93 @@ namespace Data.SeaHavenIndustries.Migrations 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("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") @@ -2024,10 +2340,20 @@ namespace Data.SeaHavenIndustries.Migrations .HasColumnType("datetime2"); b.Property("ExternalWorkOrderId") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExtraServices") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FlagColor") + .HasMaxLength(7) + .HasColumnType("nvarchar(7)"); b.Property("InternalWONumber") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); b.Property("IsDeleted") .HasColumnType("bit"); @@ -2059,6 +2385,18 @@ namespace Data.SeaHavenIndustries.Migrations 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"); @@ -2092,6 +2430,10 @@ namespace Data.SeaHavenIndustries.Migrations .HasMaxLength(128) .HasColumnType("nvarchar(128)"); + b.Property("ServiceNotes") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + b.Property("Severity") .HasColumnType("nvarchar(max)"); @@ -2105,7 +2447,8 @@ namespace Data.SeaHavenIndustries.Migrations .HasColumnType("nvarchar(max)"); b.Property("SiteCode") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); b.Property("Source") .HasColumnType("nvarchar(max)"); @@ -2125,12 +2468,20 @@ namespace Data.SeaHavenIndustries.Migrations 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"); @@ -2150,11 +2501,30 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("AssignTo"); + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("ExternalWorkOrderId") + .IsUnique() + .HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); + + b.HasIndex("InternalWONumber") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + b.HasIndex("LocationId"); b.HasIndex("PrimaryDispatchId"); - b.ToTable("workOrders"); + b.HasIndex("ScheduledDate"); + + b.HasIndex("SiteCode") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("WorkOrderType"); + + b.ToTable("workOrders", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => @@ -2168,6 +2538,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Attachments") .HasColumnType("nvarchar(max)"); + b.Property("Category") + .HasColumnType("int"); + b.Property("CreatedDate") .HasColumnType("datetime2"); @@ -2196,7 +2569,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("workOrderAttachments"); + b.ToTable("workOrderAttachments", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => @@ -2246,7 +2619,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("WorkOrderAuditLogs"); + b.ToTable("WorkOrderAuditLogs", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => @@ -2290,7 +2663,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("workOrderCategories"); + b.ToTable("workOrderCategories", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => @@ -2337,7 +2710,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("WorkOrderContacts"); + b.ToTable("WorkOrderContacts", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => @@ -2366,7 +2739,65 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId", "FieldName") .IsUnique(); - b.ToTable("WorkOrderFieldLocks"); + b.ToTable("WorkOrderFieldLocks", (string)null); + }); + + 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", (string)null); + }); + + 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 => @@ -2796,6 +3227,25 @@ namespace Data.SeaHavenIndustries.Migrations 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") @@ -2870,6 +3320,33 @@ namespace Data.SeaHavenIndustries.Migrations 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.ApplicationUser", "AssignToUser") @@ -2956,6 +3433,17 @@ namespace Data.SeaHavenIndustries.Migrations 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("Data.SeaHavenIndustries.WorkOrderFieldLock", b => { b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") @@ -3060,6 +3548,8 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("Comments"); + b.Navigation("CompletionDocuments"); + b.Navigation("DispatchWorkOrders"); b.Navigation("Signoffs"); @@ -3106,6 +3596,8 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("AuditLogs"); b.Navigation("Dispatches"); + + b.Navigation("SitePreferences"); }); modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => diff --git a/Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs b/Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs new file mode 100644 index 0000000..17cb4d3 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs @@ -0,0 +1,13 @@ +using Data.SeaHavenIndustries.Enums; + +namespace Data.SeaHavenIndustries +{ + public class CompletionDocTemplate : FullAuditEntity + { + public string Name { get; set; } = ""; + public string ServiceKey { get; set; } = ""; + public WorkOrderType? WorkOrderType { get; set; } + public string TemplateUrl { get; set; } = ""; + public bool IsActive { get; set; } = true; + } +} diff --git a/Data.SeaHavenIndustries/Models/Dispatch.cs b/Data.SeaHavenIndustries/Models/Dispatch.cs index 831cc17..dddd2d5 100644 --- a/Data.SeaHavenIndustries/Models/Dispatch.cs +++ b/Data.SeaHavenIndustries/Models/Dispatch.cs @@ -19,6 +19,12 @@ namespace Data.SeaHavenIndustries public string? Description { get; set; } public string? Status { get; set; } public DateTime? ScheduledDate { get; set; } + public DateTime? EstimatedArrivalAt { get; set; } + public bool EtaManualOverride { get; set; } + public string InvoiceStatus { get; set; } = "Not Submitted"; + public string PaymentStatus { get; set; } = "Unavailable"; + public string? InvoiceNumber { get; set; } + public DateTime? CommercialStatusUpdatedAt { get; set; } public DateTime? CompletedDate { get; set; } public DateTime? DispatchedAt { get; set; } public DateTime? AcknowledgedAt { get; set; } @@ -28,6 +34,13 @@ namespace Data.SeaHavenIndustries public string? VerifiedBy { get; set; } public DateTime? VerifiedAt { get; set; } + /// Tech/crew phone for this dispatch (overrides Vendor.Phone on board). + [MaxLength(50)] + public string? TechPhone { get; set; } + /// Instructions for the vendor for this appointment/dispatch. + [MaxLength(2000)] + public string? VendorNotes { get; set; } + [Timestamp] public byte[]? RowVersion { get; set; } @@ -35,5 +48,6 @@ namespace Data.SeaHavenIndustries public ICollection? DispatchWorkOrders { get; set; } public ICollection? ChecklistItems { get; set; } public ICollection? Signoffs { get; set; } + public ICollection? CompletionDocuments { get; set; } } } diff --git a/Data.SeaHavenIndustries/Models/SitePreferredVendor.cs b/Data.SeaHavenIndustries/Models/SitePreferredVendor.cs new file mode 100644 index 0000000..7dc34eb --- /dev/null +++ b/Data.SeaHavenIndustries/Models/SitePreferredVendor.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + public class SitePreferredVendor : FullAuditEntity + { + public int LocationId { get; set; } + [ForeignKey(nameof(LocationId))] + public Locations? Location { get; set; } + + public int VendorId { get; set; } + [ForeignKey(nameof(VendorId))] + public Vendor? Vendor { get; set; } + + public string? Trade { get; set; } + public int SortOrder { get; set; } + public string? UpdatedBy { get; set; } + } +} diff --git a/Data.SeaHavenIndustries/Models/Vendor.cs b/Data.SeaHavenIndustries/Models/Vendor.cs index 9f8071a..62ba763 100644 --- a/Data.SeaHavenIndustries/Models/Vendor.cs +++ b/Data.SeaHavenIndustries/Models/Vendor.cs @@ -18,6 +18,8 @@ namespace Data.SeaHavenIndustries public string? Notes { get; set; } public bool IsActive { get; set; } = true; public string? PreferredContact { get; set; } + public string AvailabilityStatus { get; set; } = "Unknown"; + public DateTime? AvailabilityUpdatedAt { get; set; } public int? CompanyId { get; set; } [ForeignKey(nameof(CompanyId))] @@ -27,5 +29,6 @@ namespace Data.SeaHavenIndustries public int TotalJobs { get; set; } public ICollection? Dispatches { get; set; } public ICollection? AuditLogs { get; set; } + public ICollection? SitePreferences { get; set; } } } diff --git a/Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs b/Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs new file mode 100644 index 0000000..2058e00 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + public class VendorCompletionDocument : FullAuditEntity + { + public int VendorId { get; set; } + [ForeignKey(nameof(VendorId))] + public Vendor? Vendor { get; set; } + + public int DispatchId { get; set; } + [ForeignKey(nameof(DispatchId))] + public Dispatch? Dispatch { get; set; } + + public int WorkOrderId { get; set; } + [ForeignKey(nameof(WorkOrderId))] + public WorkOrder? WorkOrder { get; set; } + + public string OriginalFileName { get; set; } = ""; + public string StoredFileName { get; set; } = ""; + public string ContentType { get; set; } = ""; + public long SizeBytes { get; set; } + public string ScanStatus { get; set; } = "Pending"; + public string ReviewStatus { get; set; } = "Processing"; + public string? RejectionReason { get; set; } + public int Version { get; set; } = 1; + public int? ReplacesDocumentId { get; set; } + public DateTime? ScannedAt { get; set; } + public DateTime? ReviewedAt { get; set; } + } +} diff --git a/Data.SeaHavenIndustries/Models/WorkOrderAttachments.cs b/Data.SeaHavenIndustries/Models/WorkOrderAttachments.cs index ff8672b..fbeee87 100644 --- a/Data.SeaHavenIndustries/Models/WorkOrderAttachments.cs +++ b/Data.SeaHavenIndustries/Models/WorkOrderAttachments.cs @@ -1,15 +1,14 @@ -using System; -using System.ComponentModel.DataAnnotations.Schema; -using static System.Runtime.InteropServices.JavaScript.JSType; +using System.ComponentModel.DataAnnotations.Schema; +using Data.SeaHavenIndustries.Enums; namespace Data.SeaHavenIndustries { public class WorkOrderAttachments : FullAuditEntity { public string? Attachments { get; set; } + public WorkOrderMediaCategory? Category { get; set; } public int? WorkorderId { get; set; } [ForeignKey(nameof(WorkorderId))] public virtual WorkOrder? WorkOrder { get; set; } - } } diff --git a/Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs b/Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs new file mode 100644 index 0000000..efa661a --- /dev/null +++ b/Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + public class WorkOrderWeekRolledLedger + { + public int Id { get; set; } + public int WorkOrderId { get; set; } + [ForeignKey(nameof(WorkOrderId))] + public virtual WorkOrder? WorkOrder { get; set; } + public DateOnly SourceWeekStart { get; set; } + public DateTime ProcessedAt { get; set; } + public string CorrelationId { get; set; } = ""; + } +} diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 18e51bf..9ffe49e 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -27,11 +27,31 @@ namespace Data.SeaHavenIndustries public string? LegacyStatus { get; set; } public WorkOrderType? WorkOrderType { get; set; } /// SHOC board "Service" column (PM type label). Not Trade/Problem. + [MaxLength(128)] public string? Service { get; set; } public int? PrimaryDispatchId { get; set; } [ForeignKey(nameof(PrimaryDispatchId))] public virtual Dispatch? PrimaryDispatch { get; set; } + // --- Service & Notes / POC (structured create) --- + [MaxLength(200)] + public string? PocName { get; set; } + [MaxLength(50)] + public string? PocPhone { get; set; } + [MaxLength(2000)] + public string? PocNotes { get; set; } + /// JSON array of extra service labels beyond primary (Trade). + [MaxLength(2000)] + public string? ExtraServices { get; set; } + [MaxLength(4000)] + public string? ServiceNotes { get; set; } + /// Tech/crew phone when no primary dispatch; otherwise prefer Dispatch.TechPhone. + [MaxLength(50)] + public string? TechPhone { get; set; } + /// Vendor instructions when no primary dispatch; otherwise prefer Dispatch.VendorNotes. + [MaxLength(2000)] + public string? VendorNotes { get; set; } + // --- Scheduling slice --- public DateTime? ScheduledDate { get; set; } public DateTime? ScheduledStart { get; set; } @@ -51,6 +71,10 @@ namespace Data.SeaHavenIndustries // --- Completion slice --- public DocStatus? DocStatus { get; set; } + /// Board flag color (#RRGGBB from fixed palette). Null = no flag. + [MaxLength(7)] + public string? FlagColor { get; set; } + // --- Legacy SQL columns (drift reconciliation) --- public string? AvettaTask { get; set; } public DateOnly? AssignDate { get; set; } diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs new file mode 100644 index 0000000..80d18df --- /dev/null +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -0,0 +1,127 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Helpers +{ + public static class WorkOrderBoardProjection + { + public static async Task> ProjectRowsAsync( + IQueryable query, + bool isUnscheduled) + { + var rows = await query + .Select(w => new + { + w.Id, + w.InternalWONumber, + w.RescheduleCount, + w.CarriedOver, + w.WorkOrderType, + w.SiteCode, + LocationName = w.Locations != null ? w.Locations.Name : null, + WoPocName = w.PocName, + WoPocPhone = w.PocPhone, + WoPocNotes = w.PocNotes, + ContactPoc = w.WorkOrderContacts! + .OrderBy(c => c.Id) + .Select(c => new + { + Name = c.POC != null + ? ((c.POC.FirstName ?? "") + " " + (c.POC.LastName ?? "")).Trim() + : null, + PhoneNumber = c.POC != null ? c.POC.PhoneNumber : null, + c.Notes + }) + .FirstOrDefault(), + w.LifecycleStatus, + w.LegacyStatus, + w.Status, + w.AssignTo, + w.AssignToUser!.FirstName, + w.AssignToUser!.LastName, + w.AssignToUser!.Initials, + w.AssignToUser!.Color, + w.DueDate, + w.ScheduledDate, + w.ScheduledStart, + w.ScheduledEnd, + w.TargetWeek, + w.ScheduleWeekOnly, + VendorId = w.PrimaryDispatch != null ? (int?)w.PrimaryDispatch.VendorId : null, + VendorName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null + ? w.PrimaryDispatch.Vendor.CompanyName + : null, + TechName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null + ? w.PrimaryDispatch.Vendor.ContactName + : null, + DispatchTechPhone = w.PrimaryDispatch != null ? w.PrimaryDispatch.TechPhone : null, + WoTechPhone = w.TechPhone, + VendorPhone = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null + ? w.PrimaryDispatch.Vendor.Phone + : null, + DispatchApptDate = w.PrimaryDispatch != null ? w.PrimaryDispatch.ScheduledDate : null, + w.Trade, + w.Problem, + w.ServiceNotes, + w.ExtraServices, + w.DocStatus, + w.FlagColor, + w.PrimaryDispatchId, + w.RowVersion, + DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null + }) + .ToListAsync(); + + return rows.Select(w => + { + var pocName = !string.IsNullOrWhiteSpace(w.WoPocName) ? w.WoPocName : w.ContactPoc?.Name; + var pocPhone = !string.IsNullOrWhiteSpace(w.WoPocPhone) ? w.WoPocPhone : w.ContactPoc?.PhoneNumber; + var pocNotes = !string.IsNullOrWhiteSpace(w.WoPocNotes) ? w.WoPocNotes : w.ContactPoc?.Notes; + var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone) + ? w.DispatchTechPhone + : (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone); + + return new WorkOrderBoardRawRow( + w.Id, + w.InternalWONumber, + w.RescheduleCount, + w.CarriedOver, + w.WorkOrderType, + w.SiteCode, + w.LocationName, + pocName, + pocPhone, + pocNotes, + w.LifecycleStatus, + w.LegacyStatus ?? w.Status, + w.AssignTo, + w.FirstName, + w.LastName, + w.Initials, + w.Color, + w.DueDate, + w.ScheduledDate, + w.ScheduledStart, + w.ScheduledEnd, + w.TargetWeek, + w.ScheduleWeekOnly, + w.VendorId, + w.VendorName, + w.TechName, + techPhone, + w.DispatchApptDate, + w.Trade ?? w.Problem, + w.Problem, + w.ServiceNotes, + w.ExtraServices, + w.DocStatus, + w.FlagColor, + w.PrimaryDispatchId, + w.RowVersion, + w.DispatchRowVersion, + isUnscheduled); + }).ToList(); + } + } +} diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs new file mode 100644 index 0000000..ffe6ef6 --- /dev/null +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -0,0 +1,148 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Helpers +{ + public static class WorkOrderBoardQueryFilters + { + public static IQueryable ApplyBaseScope(IQueryable query) + => query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); + + /// + /// Applies assignee filters. When is true and + /// is set, that takes precedence and + /// is ignored. + /// + public static IQueryable ApplyDispatcherFilter( + IQueryable query, + IReadOnlyList? dispatchers, + bool myWorkOrders, + string? currentUserId) + { + if (myWorkOrders && !string.IsNullOrEmpty(currentUserId)) + return query.Where(w => w.AssignTo == currentUserId); + + if (dispatchers == null || dispatchers.Count == 0) + return query; + + var ids = dispatchers + .Where(d => d != "__unassigned__") + .ToList(); + var includeUnassigned = dispatchers.Contains("__unassigned__"); + + if (includeUnassigned && ids.Count == 0) + return query.Where(w => w.AssignTo == null || w.AssignTo == ""); + + if (includeUnassigned && ids.Count > 0) + { + return query.Where(w => + w.AssignTo == null || w.AssignTo == "" || ids.Contains(w.AssignTo)); + } + + return query.Where(w => w.AssignTo != null && ids.Contains(w.AssignTo)); + } + + public static IQueryable ApplyTypeFilter( + IQueryable query, + IReadOnlyList? types) + { + if (types == null || types.Count == 0) + return query; + + return query.Where(w => w.WorkOrderType != null && types.Contains(w.WorkOrderType.Value)); + } + + #region Phase 2 filter helpers (not wired in Phase 1 board API) + + public static IQueryable ApplySiteFilter( + IQueryable query, + IReadOnlyList? sites) + { + if (sites == null || sites.Count == 0) + return query; + + var normalized = sites + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s.Trim()) + .ToList(); + + if (normalized.Count == 0) + return query; + + return query.Where(w => w.SiteCode != null && normalized.Contains(w.SiteCode)); + } + + public static IQueryable ApplyStatusFilter( + IQueryable query, + IReadOnlyList? statuses) + { + if (statuses == null || statuses.Count == 0) + return query; + + return query.Where(w => w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value)); + } + + public static IQueryable ApplyDocStatusFilter( + IQueryable query, + IReadOnlyList? docStatuses) + { + if (docStatuses == null || docStatuses.Count == 0) + return query; + + return query.Where(w => w.DocStatus != null && docStatuses.Contains(w.DocStatus.Value)); + } + + public static IQueryable ApplyVendorFilter( + IQueryable query, + IReadOnlyList? vendorIds) + { + if (vendorIds == null || vendorIds.Count == 0) + return query; + + return query.Where(w => + w.PrimaryDispatch != null && vendorIds.Contains(w.PrimaryDispatch.VendorId)); + } + + public static IQueryable ApplyPmTypeFilter( + IQueryable query, + IReadOnlyList? pmTypes) + { + if (pmTypes == null || pmTypes.Count == 0) + return query; + + var normalized = pmTypes + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p.Trim().ToLower()) + .ToList(); + + if (normalized.Count == 0) + return query; + + return query.Where(w => + normalized.Any(pm => + (w.Trade ?? "").ToLower().Contains(pm) + || (w.Problem ?? "").ToLower().Contains(pm))); + } + + public static IQueryable ApplyDateRangeFilter( + IQueryable query, + DateOnly dateFrom, + DateOnly dateTo) + { + var fromDate = dateFrom.ToDateTime(TimeOnly.MinValue).Date; + var toDate = dateTo.ToDateTime(TimeOnly.MinValue).Date; + + return query.Where(w => + (w.ScheduledDate != null + && w.ScheduledDate.Value.Date >= fromDate + && w.ScheduledDate.Value.Date <= toDate) + || (w.ScheduleWeekOnly == true + && w.TargetWeek != null + && w.TargetWeek >= dateFrom + && w.TargetWeek <= dateTo)); + } + + #endregion + + } +} diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs new file mode 100644 index 0000000..eac12ba --- /dev/null +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs @@ -0,0 +1,47 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Helpers +{ + public static class WorkOrderBoardSearchFilter + { + public static string? NormalizeSearch(string? search) + { + if (string.IsNullOrWhiteSpace(search)) + return null; + + var trimmed = search.Trim(); + return trimmed.Length < 2 ? null : trimmed; + } + + public static IQueryable Apply(IQueryable query, string? search) + { + var normalized = NormalizeSearch(search); + if (normalized == null) + return query; + + var s = normalized.ToLower(); + return query.Where(w => + (w.SiteCode ?? "").ToLower().Contains(s) + || (w.InternalWONumber ?? "").ToLower().Contains(s) + || (w.WorkerOrderNumber ?? "").ToLower().Contains(s) + || (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s)) + || (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s)) + || (w.Trade ?? "").ToLower().Contains(s) + || (w.Problem ?? "").ToLower().Contains(s) + || (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s)) + || (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.ContactName ?? "").ToLower().Contains(s)) + || (w.Status ?? "").ToLower().Contains(s) + || (w.LifecycleStatus == LifecycleStatus.Incomplete && "incomplete".Contains(s)) + || (w.LifecycleStatus == LifecycleStatus.Pending && ("pending".Contains(s) || "on hold".Contains(s) || "onhold".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.Scheduled && "scheduled".Contains(s)) + || (w.LifecycleStatus == LifecycleStatus.EnRoute && ("en route".Contains(s) || "enroute".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.OnSite && ("on site".Contains(s) || "onsite".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.InProgress && ("in progress".Contains(s) || "inprogress".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.Completed && ("completed".Contains(s) || "complete".Contains(s) || "done".Contains(s) || "closed".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.Rescheduled && "rescheduled".Contains(s)) + || (w.LifecycleStatus == LifecycleStatus.Canceled && ("canceled".Contains(s) || "cancelled".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.PendingQuote && ("pending quote".Contains(s) || "pendingquote".Contains(s) || "pending approval".Contains(s) || "pendingapproval".Contains(s)))); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs b/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs new file mode 100644 index 0000000..356b623 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs @@ -0,0 +1,103 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class CompletionDocTemplateDataService : ICompletionDocTemplateDataService + { + private readonly ApplicationDbContext _context; + + public CompletionDocTemplateDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType) + { + var query = _context.CompletionDocTemplates + .AsNoTracking() + .Where(t => t.IsActive && t.IsDeleted != true); + + if (!string.IsNullOrWhiteSpace(serviceKey)) + query = query.Where(t => t.ServiceKey == serviceKey); + + if (workOrderType.HasValue) + query = query.Where(t => t.WorkOrderType == null || t.WorkOrderType == workOrderType); + + return await query + .OrderBy(t => t.Name) + .Select(MapRow) + .ToListAsync(); + } + + public async Task GetByIdAsync(int id) + { + return await _context.CompletionDocTemplates + .AsNoTracking() + .Where(t => t.Id == id && t.IsDeleted != true) + .Select(MapRow) + .FirstOrDefaultAsync(); + } + + public async Task ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType) + { + if (!string.IsNullOrWhiteSpace(trade)) + { + var byTrade = await _context.CompletionDocTemplates + .AsNoTracking() + .Where(t => t.IsActive && t.IsDeleted != true && t.ServiceKey == trade) + .OrderBy(t => t.WorkOrderType == workOrderType ? 0 : 1) + .ThenBy(t => t.Name) + .ThenBy(t => t.Id) + .Select(MapRow) + .FirstOrDefaultAsync(); + + if (byTrade != null) + return byTrade; + } + + if (!workOrderType.HasValue) + return null; + + return await _context.CompletionDocTemplates + .AsNoTracking() + .Where(t => t.IsActive && t.IsDeleted != true && t.WorkOrderType == workOrderType) + .OrderBy(t => t.Name) + .ThenBy(t => t.Id) + .Select(MapRow) + .FirstOrDefaultAsync(); + } + + public async Task CreateAsync(CompletionDocTemplate template) + { + template.CreatedDate = DateTime.UtcNow; + await _context.CompletionDocTemplates.AddAsync(template); + await _context.SaveChangesAsync(); + return template; + } + + public async Task UpdateAsync(CompletionDocTemplate template) + { + template.LastModificationTime = DateTime.UtcNow; + _context.CompletionDocTemplates.Update(template); + await _context.SaveChangesAsync(); + } + + public async Task DeleteAsync(int id) + { + var entity = await _context.CompletionDocTemplates.FindAsync(id); + if (entity == null) + return false; + + entity.IsDeleted = true; + entity.DeletionTime = DateTime.UtcNow; + await _context.SaveChangesAsync(); + return true; + } + + private static readonly System.Linq.Expressions.Expression> MapRow = + t => new CompletionDocTemplateRow(t.Id, t.Name, t.ServiceKey, t.WorkOrderType, t.TemplateUrl, t.IsActive); + } +} diff --git a/SeaHaven.DataServices/Implementation/LocationDataService.cs b/SeaHaven.DataServices/Implementation/LocationDataService.cs index 56ccf52..7ccb6b8 100644 --- a/SeaHaven.DataServices/Implementation/LocationDataService.cs +++ b/SeaHaven.DataServices/Implementation/LocationDataService.cs @@ -60,6 +60,24 @@ namespace SeaHaven.DataServices.Implementation return (items, totalCount); } + public async Task> GetSiteOptionsAsync(string? search = null) + { + var query = _context.Locations.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.ToLower(); + query = query.Where(l => + (l.Name ?? "").ToLower().Contains(s) || + (l.City ?? "").ToLower().Contains(s)); + } + + return await query + .OrderBy(l => l.Name) + .Select(l => new SiteOptionRow(l.Id, l.Name, l.City, l.State)) + .ToListAsync(); + } + public async Task AddAsync(Locations location) { location.CreatedDate = DateTime.Now; diff --git a/SeaHaven.DataServices/Implementation/UserDataService.cs b/SeaHaven.DataServices/Implementation/UserDataService.cs index 1e1c4bb..6ad82cc 100644 --- a/SeaHaven.DataServices/Implementation/UserDataService.cs +++ b/SeaHaven.DataServices/Implementation/UserDataService.cs @@ -151,5 +151,27 @@ namespace SeaHaven.DataServices.Implementation .Select(user => user.Email) .FirstOrDefaultAsync(cancellationToken); } + + public async Task> GetDisplayNamesByIdsAsync(IEnumerable ids) + { + var idList = ids + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (idList.Count == 0) + return new Dictionary(StringComparer.Ordinal); + + var users = await _context.Users + .AsNoTracking() + .Where(u => idList.Contains(u.Id)) + .Select(u => new { u.Id, u.FirstName, u.LastName }) + .ToListAsync(); + + return users.ToDictionary( + u => u.Id, + u => $"{u.FirstName ?? ""} {u.LastName ?? ""}".Trim(), + StringComparer.Ordinal); + } } } diff --git a/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs b/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs new file mode 100644 index 0000000..2ee5192 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs @@ -0,0 +1,43 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class VendorDocumentDataService : IVendorDocumentDataService + { + private readonly ApplicationDbContext _context; + + public VendorDocumentDataService(ApplicationDbContext context) + { + _context = context; + } + + public Task GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken) + { + return _context.VendorCompletionDocuments + .Where(document => document.DispatchId == dispatchId) + .OrderByDescending(document => document.Version) + .FirstOrDefaultAsync(cancellationToken); + } + + public Task GetForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken) + { + return _context.VendorCompletionDocuments + .FirstOrDefaultAsync(document => document.Id == documentId + && document.DispatchId == dispatchId + && document.VendorId == vendorId + && (document.IsDeleted == null || document.IsDeleted == false), cancellationToken); + } + + public async Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken) + { + await _context.VendorCompletionDocuments.AddAsync(document, cancellationToken); + } + + public Task SaveChangesAsync(CancellationToken cancellationToken) + { + return _context.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs b/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs new file mode 100644 index 0000000..e1eefc3 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs @@ -0,0 +1,231 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; + +namespace SeaHaven.DataServices.Implementation +{ + public class VendorOperationsDataService : IVendorOperationsDataService + { + private static readonly string[] TerminalDispatchStatuses = { "Completed", "Verified", "Cancelled", "Canceled" }; + + private readonly ApplicationDbContext _context; + private readonly IDispatchDataService _dispatchDataService; + + public VendorOperationsDataService(ApplicationDbContext context, IDispatchDataService dispatchDataService) + { + _context = context; + _dispatchDataService = dispatchDataService; + } + + public async Task> GetAssignedWorkOrderIdsAsync(CancellationToken cancellationToken) + { + var directlyAssignedIds = await _context.Dispatches + .AsNoTracking() + .Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") && d.WorkOrderId.HasValue) + .Select(d => d.WorkOrderId!.Value) + .ToListAsync(cancellationToken); + var multiplyAssignedIds = await _context.DispatchWorkOrders + .AsNoTracking() + .Where(link => link.Dispatch != null + && !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")) + .Select(link => link.WorkOrderId) + .ToListAsync(cancellationToken); + return directlyAssignedIds.Concat(multiplyAssignedIds).Distinct().ToList(); + } + + public async Task> GetNoVendorWorkOrdersAsync( + DateTime cutoff, IReadOnlyCollection excludeIds, CancellationToken cancellationToken) + { + return await _context.workOrders + .AsNoTracking() + .Where(wo => wo.ScheduledDate.HasValue + && wo.ScheduledDate.Value <= cutoff + && wo.LifecycleStatus != LifecycleStatus.Completed + && wo.LifecycleStatus != LifecycleStatus.Canceled + && !excludeIds.Contains(wo.Id)) + .OrderBy(wo => wo.ScheduledDate) + .Select(wo => new NoVendorCandidateReadModel + { + Id = wo.Id, + InternalWONumber = wo.InternalWONumber, + WorkerOrderNumber = wo.WorkerOrderNumber, + ScheduledDate = wo.ScheduledDate + }) + .ToListAsync(cancellationToken); + } + + public async Task> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken) + { + var direct = _context.Dispatches.AsNoTracking() + .Where(dispatch => !TerminalDispatchStatuses.Contains(dispatch.Status ?? "") + && (dispatch.IsDeleted == null || dispatch.IsDeleted == false) + && dispatch.WorkOrderId.HasValue + && (dispatch.ScheduledDate.HasValue + || (dispatch.WorkOrder != null && dispatch.WorkOrder.ScheduledStart.HasValue))) + .Select(dispatch => new ScheduledDispatchReadModel + { + Id = dispatch.Id, + VendorId = dispatch.VendorId, + VendorName = dispatch.Vendor!.CompanyName, + WorkOrderId = dispatch.WorkOrderId!.Value, + WorkOrderNumber = dispatch.WorkOrder!.InternalWONumber ?? dispatch.WorkOrder.WorkerOrderNumber, + Start = dispatch.WorkOrder!.ScheduledStart ?? dispatch.ScheduledDate!.Value, + End = dispatch.WorkOrder.ScheduledEnd + ?? (dispatch.WorkOrder.ScheduledStart ?? dispatch.ScheduledDate!.Value).AddHours(2) + }); + + var linked = _context.DispatchWorkOrders.AsNoTracking() + .Where(link => link.Dispatch != null + && link.WorkOrder != null + && link.Dispatch.WorkOrderId != link.WorkOrderId + && !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "") + && (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false) + && (link.Dispatch.ScheduledDate.HasValue || link.WorkOrder.ScheduledStart.HasValue)) + .Select(link => new ScheduledDispatchReadModel + { + Id = link.DispatchId, + VendorId = link.Dispatch!.VendorId, + VendorName = link.Dispatch.Vendor!.CompanyName, + WorkOrderId = link.WorkOrderId, + WorkOrderNumber = link.WorkOrder!.InternalWONumber ?? link.WorkOrder.WorkerOrderNumber, + Start = link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value, + End = link.WorkOrder.ScheduledEnd + ?? (link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value).AddHours(2) + }); + + return await direct.Concat(linked).ToListAsync(cancellationToken); + } + + public async Task> GetActiveVendorsAsync(CancellationToken cancellationToken) + { + return await _context.Vendors.AsNoTracking() + .Where(v => v.IsActive && (v.IsDeleted == null || v.IsDeleted == false)) + .Select(v => new ActiveVendorReadModel + { + Id = v.Id, + CompanyName = v.CompanyName, + ContactName = v.ContactName, + TradeSpecialties = v.TradeSpecialties, + AvailabilityStatus = v.AvailabilityStatus, + AvailabilityUpdatedAt = v.AvailabilityUpdatedAt + }) + .ToListAsync(cancellationToken); + } + + public async Task> GetSitePreferredVendorsAsync(int locationId, CancellationToken cancellationToken) + { + return await _context.SitePreferredVendors + .AsNoTracking() + .Where(p => p.LocationId == locationId) + .Include(p => p.Vendor) + .OrderBy(p => p.SortOrder) + .ToListAsync(cancellationToken); + } + + public async Task> GetSitePreferredVendorsForReplaceAsync(int locationId, CancellationToken cancellationToken) + { + return await _context.SitePreferredVendors + .Where(p => p.LocationId == locationId) + .ToListAsync(cancellationToken); + } + + public Task LocationExistsAsync(int locationId, CancellationToken cancellationToken) + => _context.Locations.AnyAsync(location => location.Id == locationId, cancellationToken); + + public async Task> GetVendorsByIdsAsync(IReadOnlyCollection vendorIds, CancellationToken cancellationToken) + => await _context.Vendors.Where(vendor => vendorIds.Contains(vendor.Id)) + .ToDictionaryAsync(vendor => vendor.Id, cancellationToken); + + public async Task> GetInsightDispatchDataAsync( + DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId, CancellationToken cancellationToken) + { + var query = _context.Dispatches.AsNoTracking() + .Include(dispatch => dispatch.Vendor) + .Include(dispatch => dispatch.WorkOrder) + .Where(dispatch => dispatch.Vendor != null); + if (from.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) >= from); + if (to.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) <= to); + if (locationId.HasValue) query = query.Where(dispatch => dispatch.WorkOrder!.LocationId == locationId); + if (!string.IsNullOrWhiteSpace(trade)) query = query.Where(dispatch => dispatch.WorkOrder!.Trade == trade); + if (vendorId.HasValue) query = query.Where(dispatch => dispatch.VendorId == vendorId); + + return await query.Select(dispatch => new InsightDispatchData + { + VendorId = dispatch.VendorId, + VendorName = dispatch.Vendor!.CompanyName, + Status = dispatch.Status, + DispatchedAt = dispatch.DispatchedAt, + CreatedDate = dispatch.CreatedDate, + ScheduledDate = dispatch.ScheduledDate, + AcknowledgedAt = dispatch.AcknowledgedAt, + CompletedDate = dispatch.CompletedDate, + DueDate = dispatch.WorkOrder != null ? dispatch.WorkOrder.DueDate : null + }).ToListAsync(cancellationToken); + } + + public Task GetVendorForUpdateAsync(int vendorId, CancellationToken cancellationToken) + => _context.Vendors.FirstOrDefaultAsync(v => v.Id == vendorId, cancellationToken); + + public Task GetCompletionDocumentForUpdateAsync(int documentId, CancellationToken cancellationToken) + => _context.VendorCompletionDocuments.FirstOrDefaultAsync(d => d.Id == documentId, cancellationToken); + + public Task GetDispatchForUpdateAsync(int dispatchId, CancellationToken cancellationToken) + => _context.Dispatches.FirstOrDefaultAsync(d => d.Id == dispatchId, cancellationToken); + + public Task GetWorkOrderForUpdateAsync(int workOrderId, CancellationToken cancellationToken) + => _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken); + + public async Task GetExistingActiveDispatchForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) + { + var linkedDispatchIds = _context.DispatchWorkOrders + .Where(link => link.WorkOrderId == workOrderId) + .Select(link => link.DispatchId); + return await _context.Dispatches + .Where(item => (item.WorkOrderId == workOrderId || linkedDispatchIds.Contains(item.Id)) + && !TerminalDispatchStatuses.Contains(item.Status ?? "")) + .OrderByDescending(item => item.CreatedDate) + .FirstOrDefaultAsync(cancellationToken); + } + + public Task GenerateNextDispatchNumberAsync(CancellationToken cancellationToken) + => _dispatchDataService.GenerateNextDispatchNumberAsync(); + + public Task GenerateNextPONumberAsync(CancellationToken cancellationToken) + => _dispatchDataService.GenerateNextPONumberAsync(); + + public Task AddDispatchAsync(Dispatch dispatch, CancellationToken cancellationToken) + { + _context.Dispatches.Add(dispatch); + return Task.CompletedTask; + } + + public Task StageWorkOrderAuditAsync(WorkOrderAuditLog log, CancellationToken cancellationToken) + { + _context.WorkOrderAuditLogs.Add(log); + return Task.CompletedTask; + } + + public Task StageVendorAuditAsync(VendorAuditLog log, CancellationToken cancellationToken) + { + _context.VendorAuditLogs.Add(log); + return Task.CompletedTask; + } + + public Task AddSitePreferredVendorAsync(SitePreferredVendor preference, CancellationToken cancellationToken) + { + _context.SitePreferredVendors.Add(preference); + return Task.CompletedTask; + } + + public Task RemoveSitePreferredVendorsAsync(IEnumerable preferences, CancellationToken cancellationToken) + { + _context.SitePreferredVendors.RemoveRange(preferences); + return Task.CompletedTask; + } + + public Task SaveChangesAsync(CancellationToken cancellationToken) + => _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs new file mode 100644 index 0000000..556fe4f --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs @@ -0,0 +1,64 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderAdvancedSearchDataService : IWorkOrderAdvancedSearchDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderAdvancedSearchDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task SearchAsync(WorkOrderAdvancedSearchQuery query) + { + var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo); + baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites); + baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types); + baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter( + baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId); + baseQuery = WorkOrderBoardQueryFilters.ApplyStatusFilter(baseQuery, query.Statuses); + baseQuery = WorkOrderBoardQueryFilters.ApplyPmTypeFilter(baseQuery, query.PmTypes); + baseQuery = WorkOrderBoardQueryFilters.ApplyVendorFilter(baseQuery, query.VendorIds); + baseQuery = WorkOrderBoardQueryFilters.ApplyDocStatusFilter(baseQuery, query.DocStatuses); + + var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search); + if (normalizedSearch != null) + baseQuery = WorkOrderBoardSearchFilter.Apply(baseQuery, normalizedSearch); + + var totalCount = await baseQuery.CountAsync(); + + var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir); + var paged = sorted + .Skip((query.Page - 1) * query.PageSize) + .Take(query.PageSize); + + var rows = await WorkOrderBoardProjection.ProjectRowsAsync(paged, isUnscheduled: false); + return new WorkOrderAdvancedSearchResult(rows, totalCount); + } + + private static IQueryable ApplySort(IQueryable query, string sortBy, string sortDir) + { + var descending = string.Equals(sortDir, "desc", StringComparison.OrdinalIgnoreCase); + var field = (sortBy ?? "scheduledDate").Trim().ToLowerInvariant(); + + return field switch + { + "wonumber" or "woNumber" => descending + ? query.OrderByDescending(w => w.InternalWONumber) + : query.OrderBy(w => w.InternalWONumber), + "duedate" or "dueDate" => descending + ? query.OrderByDescending(w => w.DueDate).ThenBy(w => w.InternalWONumber) + : query.OrderBy(w => w.DueDate).ThenBy(w => w.InternalWONumber), + _ => descending + ? query.OrderByDescending(w => w.ScheduledDate).ThenByDescending(w => w.InternalWONumber) + : query.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber) + }; + } + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs new file mode 100644 index 0000000..17e21b0 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs @@ -0,0 +1,95 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderBoardDataService : IWorkOrderBoardDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderBoardDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task GetBoardRowsAsync(WorkOrderBoardQuery query) + { + var weekStartDate = query.WeekStart.ToDateTime(TimeOnly.MinValue); + var weekEndDate = query.WeekEnd.ToDateTime(TimeOnly.MinValue); + + var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter( + baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId); + baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types); + + var scheduledBase = baseQuery.Where(w => + (w.ScheduledDate != null + && w.ScheduledDate.Value.Date >= weekStartDate.Date + && w.ScheduledDate.Value.Date <= weekEndDate.Date) + || (w.ScheduleWeekOnly == true && w.TargetWeek == query.WeekStart)); + + var unscheduledBase = baseQuery.Where(w => + w.ScheduledDate == null + && !LifecycleStatusSets.Terminal.Contains(w.LifecycleStatus)); + + var scheduledTotal = await scheduledBase.CountAsync(); + + var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search); + if (normalizedSearch != null) + { + scheduledBase = WorkOrderBoardSearchFilter.Apply(scheduledBase, normalizedSearch); + unscheduledBase = WorkOrderBoardSearchFilter.Apply(unscheduledBase, normalizedSearch); + } + + var scheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync( + scheduledBase.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber), + isUnscheduled: false); + var unscheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync( + unscheduledBase.OrderBy(w => w.InternalWONumber), + isUnscheduled: true); + + return new WorkOrderBoardQueryResult(scheduledRows, unscheduledRows, scheduledTotal); + } + + public async Task> GetDispatcherLookupsAsync() + { + return await _context.Users + .AsNoTracking() + .Where(u => u.IsDeleted != true) + .OrderBy(u => u.FirstName) + .ThenBy(u => u.LastName) + .Select(u => new DispatcherLookupRow( + u.Id, + u.FirstName ?? "", + u.LastName, + u.Initials, + u.Color)) + .ToListAsync(); + } + + public async Task GetBoardRowByIdAsync(int workOrderId) + { + var query = _context.workOrders + .AsNoTracking() + .Where(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); + + var rows = await WorkOrderBoardProjection.ProjectRowsAsync(query, isUnscheduled: false); + return rows.FirstOrDefault(); + } + + public async Task InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId) + { + return await _context.workOrders + .AsNoTracking() + .AnyAsync(w => + w.Id != excludeWorkOrderId + && w.istemplate != true + && (w.IsDeleted != true || w.IsDeleted == null) + && w.InternalWONumber == normalizedWoNumber); + } + + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs new file mode 100644 index 0000000..dbab5d4 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs @@ -0,0 +1,92 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderBoardMutationDataService : IWorkOrderBoardMutationDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderBoardMutationDataService(ApplicationDbContext context) + { + _context = context; + } + + public Task VendorExistsAsync(int vendorId, CancellationToken cancellationToken) + => _context.Vendors.AnyAsync(v => v.Id == vendorId && v.IsDeleted != true, cancellationToken); + + public async Task GetMaxWorkOrderIdAsync(CancellationToken cancellationToken) + => await _context.workOrders.MaxAsync(w => (int?)w.Id, cancellationToken) ?? 0; + + public Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) + => _context.workOrders.FirstOrDefaultAsync( + w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null), + cancellationToken); + + public Task GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken) + => _context.Dispatches.FirstOrDefaultAsync( + d => d.Id == dispatchId && d.WorkOrderId == workOrderId, + cancellationToken); + + public Task GetTrackedDispatchByIdAsync(int dispatchId, CancellationToken cancellationToken) + => _context.Dispatches.FirstOrDefaultAsync(d => d.Id == dispatchId, cancellationToken); + + public void TrackNewWorkOrder(WorkOrder workOrder) + => _context.workOrders.Add(workOrder); + + public void TrackNewDispatch(Dispatch dispatch) + => _context.Dispatches.Add(dispatch); + + public void TrackWorkOrderContact(int workOrderId, int contactId, string? notes) + => _context.WorkOrderContacts.Add(new WorkOrderContacts + { + WorkorderId = workOrderId, + ContactId = contactId, + Notes = notes + }); + + public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version) + => _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version; + + public void SetExpectedDispatchVersion(Dispatch dispatch, byte[] version) + => _context.Entry(dispatch).Property(d => d.RowVersion).OriginalValue = version; + + public bool HasPendingChanges() + => _context.ChangeTracker.HasChanges(); + + public async Task ExecuteTransactionalAsync(Func work, CancellationToken cancellationToken) + { + await using var transaction = _context.Database.IsRelational() + ? await _context.Database.BeginTransactionAsync(cancellationToken) + : null; + + try + { + await work(cancellationToken); + if (transaction is not null) + await transaction.CommitAsync(cancellationToken); + } + catch + { + if (transaction is not null) + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + public async Task SaveAsync(CancellationToken cancellationToken) + { + try + { + await _context.SaveChangesAsync(cancellationToken); + return BoardSaveOutcome.Saved; + } + catch (DbUpdateConcurrencyException) + { + _context.ChangeTracker.Clear(); + return BoardSaveOutcome.ConcurrencyConflict; + } + } + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderCompletionDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderCompletionDataService.cs new file mode 100644 index 0000000..25369bd --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderCompletionDataService.cs @@ -0,0 +1,30 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderCompletionDataService : IWorkOrderCompletionDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderCompletionDataService(ApplicationDbContext context) + { + _context = context; + } + + public Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) + => _context.workOrders.FirstOrDefaultAsync( + w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null), + cancellationToken); + + public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version) + => _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version; + + public Task GetTrackedTemplateAsync(int id, CancellationToken cancellationToken) + => _context.CompletionDocTemplates.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken); + + public Task SaveAsync(CancellationToken cancellationToken) + => _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs new file mode 100644 index 0000000..e2526ab --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs @@ -0,0 +1,95 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderDetailDataService : IWorkOrderDetailDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderDetailDataService(ApplicationDbContext context) + { + _context = context; + } + + public Task ExistsAsync(int workOrderId) + => WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()) + .AnyAsync(w => w.Id == workOrderId); + + public async Task GetExtendedFieldsAsync(int workOrderId) + { + return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()) + .Where(w => w.Id == workOrderId) + .Select(w => new WorkOrderDetailExtendedFields( + w.Description, + w.Trade, + w.Problem, + w.SubTrade, + w.OriginalWeek, + w.OriginalDate, + w.CompletedDate, + w.SignOffName, + w.SignOffAttachment, + w.SignOffSignature, + w.ExtraServices, + w.ServiceNotes, + w.PrimaryDispatch != null && w.PrimaryDispatch.VendorNotes != null + ? w.PrimaryDispatch.VendorNotes + : w.VendorNotes)) + .FirstOrDefaultAsync(); + } + + public async Task> GetCommentsAsync(int workOrderId) + { + return await _context.Comments + .AsNoTracking() + .Where(c => c.WorkerOrderId == workOrderId && c.IsDeleted != true) + .OrderByDescending(c => c.CreatedDate) + .ToListAsync(); + } + + public async Task> GetAuditLogsAsync(int workOrderId, int? limit = null) + { + var query = _context.WorkOrderAuditLogs + .AsNoTracking() + .Where(a => a.WorkOrderId == workOrderId) + .OrderByDescending(a => a.CreatedAt); + + if (limit.HasValue) + return await query.Take(limit.Value).ToListAsync(); + + return await query.ToListAsync(); + } + + public async Task> GetAttachmentsAsync(int workOrderId) + { + return await _context.workOrderAttachments + .AsNoTracking() + .Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true) + .OrderByDescending(a => a.CreatedDate) + .ToListAsync(); + } + + public async Task> GetDispatchSignoffsAsync(int workOrderId) + { + return await _context.DispatchSignoffs + .AsNoTracking() + .Where(s => s.Dispatch != null && s.Dispatch.WorkOrderId == workOrderId && s.IsDeleted != true) + .OrderByDescending(s => s.SignedAt) + .Select(s => new DispatchSignoffRow( + s.DispatchId, + s.SignoffType, + s.Name, + s.SignedAt)) + .ToListAsync(); + } + + public async Task GetWorkOrderForMediaAsync(int workOrderId) + { + return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()) + .FirstOrDefaultAsync(w => w.Id == workOrderId); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs new file mode 100644 index 0000000..65d98bc --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs @@ -0,0 +1,166 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderDomainJobDataService : IWorkOrderDomainJobDataService + { + private static readonly LifecycleStatus?[] TerminalStatuses = + { + LifecycleStatus.Completed, + LifecycleStatus.Canceled + }; + + private readonly ApplicationDbContext _context; + + public WorkOrderDomainJobDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task> GetWeekRolledCandidatesAsync( + DateOnly sourceWeekStart, + DateOnly sourceWeekEnd, + int batchSize = 500, + CancellationToken cancellationToken = default) + { + var weekStartDate = sourceWeekStart.ToDateTime(TimeOnly.MinValue); + var weekEndDate = sourceWeekEnd.ToDateTime(TimeOnly.MinValue); + + // Always return the first page of remaining unprocessed candidates. + // Callers must not offset-page: ledger writes shrink this set after each batch. + return await _context.workOrders + .AsNoTracking() + .Where(w => w.IsDeleted != true) + .Where(w => w.istemplate != true) + .Where(w => w.ScheduledDate != null) + .Where(w => + w.ScheduledDate!.Value.Date >= weekStartDate.Date + && w.ScheduledDate.Value.Date <= weekEndDate.Date) + .Where(w => !TerminalStatuses.Contains(w.LifecycleStatus)) + .Where(w => !_context.WorkOrderWeekRolledLedgers.Any(l => + l.WorkOrderId == w.Id && l.SourceWeekStart == sourceWeekStart)) + .OrderBy(w => w.Id) + .Take(batchSize) + .Select(w => w.Id) + .ToListAsync(cancellationToken); + } + + public async Task TryProcessWeekRolledAsync( + int workOrderId, + DateOnly sourceWeekStart, + string correlationId, + CancellationToken cancellationToken = default) + { + await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + + var alreadyProcessed = await _context.WorkOrderWeekRolledLedgers + .AnyAsync(l => l.WorkOrderId == workOrderId && l.SourceWeekStart == sourceWeekStart, cancellationToken); + + if (alreadyProcessed) + { + await transaction.RollbackAsync(cancellationToken); + return new WeekRolledProcessOutcome { Skipped = true }; + } + + var workOrder = await _context.workOrders + .FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken); + + if (workOrder == null) + { + await transaction.RollbackAsync(cancellationToken); + return new WeekRolledProcessOutcome { Skipped = true }; + } + + _context.WorkOrderWeekRolledLedgers.Add(new WorkOrderWeekRolledLedger + { + WorkOrderId = workOrderId, + SourceWeekStart = sourceWeekStart, + ProcessedAt = DateTime.UtcNow, + CorrelationId = correlationId + }); + + var oldCarriedOver = workOrder.CarriedOver; + workOrder.CarriedOver = oldCarriedOver + 1; + + try + { + await _context.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch (DbUpdateException) + { + await transaction.RollbackAsync(cancellationToken); + return new WeekRolledProcessOutcome { Skipped = true }; + } + + return new WeekRolledProcessOutcome + { + Processed = true, + OldCarriedOver = oldCarriedOver, + NewCarriedOver = workOrder.CarriedOver + }; + } + + public async Task RefreshPastDueFlagsAsync( + DateTime utcNow, + int batchSize = 500, + CancellationToken cancellationToken = default) + { + var today = utcNow.Date; + var setCount = 0; + var clearedCount = 0; + var examinedCount = 0; + var skip = 0; + + while (true) + { + var batch = await _context.workOrders + .Where(w => w.IsDeleted != true) + .Where(w => w.istemplate != true) + .Where(w => w.ScheduledDate != null) + .OrderBy(w => w.Id) + .Skip(skip) + .Take(batchSize) + .ToListAsync(cancellationToken); + + if (batch.Count == 0) + break; + + foreach (var workOrder in batch) + { + examinedCount++; + var shouldBePastDue = workOrder.ScheduledDate!.Value.Date < today + && !TerminalStatuses.Contains(workOrder.LifecycleStatus); + var hasFlag = workOrder.OperationalFlags.HasFlag(OperationalFlags.PastDue); + + if (shouldBePastDue && !hasFlag) + { + workOrder.OperationalFlags |= OperationalFlags.PastDue; + setCount++; + } + else if (!shouldBePastDue && hasFlag) + { + workOrder.OperationalFlags &= ~OperationalFlags.PastDue; + clearedCount++; + } + } + + await _context.SaveChangesAsync(cancellationToken); + skip += batchSize; + + if (batch.Count < batchSize) + break; + } + + return new PastDueRefreshResult + { + SetCount = setCount, + ClearedCount = clearedCount, + ExaminedCount = examinedCount + }; + } + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderFieldLockDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderFieldLockDataService.cs index 22792d0..74341d2 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderFieldLockDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderFieldLockDataService.cs @@ -22,9 +22,13 @@ namespace SeaHaven.DataServices.Implementation .AnyAsync(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName, cancellationToken); } - public void EnqueueLock(int workOrderId, string fieldName, string? userId) + public async Task EnqueueLockAsync( + int workOrderId, + string fieldName, + string? userId, + CancellationToken cancellationToken) { - if (IsTracked(workOrderId, fieldName)) + if (await IsLockedAsync(workOrderId, fieldName, cancellationToken)) return; _context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock diff --git a/SeaHaven.DataServices/Implementation/WorkOrderIngestDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderIngestDataService.cs new file mode 100644 index 0000000..5a2a835 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderIngestDataService.cs @@ -0,0 +1,57 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderIngestDataService : IWorkOrderIngestDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderIngestDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task GetMaxWorkOrderIdAsync(CancellationToken cancellationToken) + => await _context.workOrders.MaxAsync(w => (int?)w.Id, cancellationToken) ?? 0; + + public Task GetTrackedByExternalIdAsync(string externalWorkOrderId, CancellationToken cancellationToken) + => _context.workOrders.FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalWorkOrderId, cancellationToken); + + public void TrackWorkOrder(WorkOrder workOrder) + => _context.workOrders.Add(workOrder); + + public Task FindLocationAsync(string name, CancellationToken cancellationToken) + => _context.Locations.FirstOrDefaultAsync(l => l.Name == name || l.Title == name, cancellationToken); + + public async Task AddAndSaveLocationAsync(Locations location, CancellationToken cancellationToken) + { + _context.Locations.Add(location); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task ExecuteTransactionalAsync(Func work, CancellationToken cancellationToken) + { + await using var transaction = _context.Database.IsRelational() + ? await _context.Database.BeginTransactionAsync(cancellationToken) + : null; + + try + { + await work(cancellationToken); + if (transaction is not null) + await transaction.CommitAsync(cancellationToken); + } + catch + { + if (transaction is not null) + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + public Task SaveAsync(CancellationToken cancellationToken) + => _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs new file mode 100644 index 0000000..d21a804 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs @@ -0,0 +1,30 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderMediaDataService : IWorkOrderMediaDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderMediaDataService(ApplicationDbContext context) + { + _context = context; + } + + public Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) + => _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken); + + public Task GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken) + => _context.workOrderAttachments.FirstOrDefaultAsync( + a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true, + cancellationToken); + + public void TrackAttachment(WorkOrderAttachments attachment) + => _context.workOrderAttachments.Add(attachment); + + public Task SaveAsync(CancellationToken cancellationToken) + => _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderOpsHealthDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderOpsHealthDataService.cs new file mode 100644 index 0000000..ee9cc0c --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderOpsHealthDataService.cs @@ -0,0 +1,32 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderOpsHealthDataService : IWorkOrderOpsHealthDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderOpsHealthDataService(ApplicationDbContext context) + { + _context = context; + } + + public Task CountSyncRejectedSinceAsync(DateTime since, CancellationToken cancellationToken) + => _context.WorkOrderAuditLogs + .CountAsync(l => l.Action == "SyncRejected" && l.CreatedAt >= since, cancellationToken); + + public Task CountFieldLocksAsync(CancellationToken cancellationToken) + => _context.WorkOrderFieldLocks.CountAsync(cancellationToken); + + public async Task GetLastWeekRolledProcessedAtAsync(CancellationToken cancellationToken) + { + if (!await _context.WorkOrderWeekRolledLedgers.AnyAsync(cancellationToken)) + return null; + + return await _context.WorkOrderWeekRolledLedgers + .MaxAsync(l => (DateTime?)l.ProcessedAt, cancellationToken); + } + } +} diff --git a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs index d3ee23e..a88609a 100644 --- a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs +++ b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs @@ -2,6 +2,8 @@ using Data.SeaHavenIndustries; namespace SeaHaven.DataServices.Interfaces { + public record SiteOptionRow(int Id, string? Code, string? City, string? State); + public interface ILocationDataService { Task GetByIdAsync(int id); @@ -15,6 +17,8 @@ namespace SeaHaven.DataServices.Interfaces // For addressbook endpoint - returns simplified DTO Task<(IEnumerable Items, int TotalCount)> GetAddressbookPagedAsync(int page, int pageSize, string? search = null); + Task> GetSiteOptionsAsync(string? search = null); + Task AddAsync(Locations location); Task UpdateAsync(Locations location); Task DeleteAsync(int id); diff --git a/SeaHaven.DataServices/Interfaces/IUserDataService.cs b/SeaHaven.DataServices/Interfaces/IUserDataService.cs index 8ca1655..40dc64a 100644 --- a/SeaHaven.DataServices/Interfaces/IUserDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUserDataService.cs @@ -18,5 +18,6 @@ namespace SeaHaven.DataServices.Interfaces Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken); Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken); Task GetEmailByIdAsync(string userId, CancellationToken cancellationToken); + Task> GetDisplayNamesByIdsAsync(IEnumerable ids); } } diff --git a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs new file mode 100644 index 0000000..5849b51 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs @@ -0,0 +1,12 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IVendorDocumentDataService + { + Task GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken); + Task GetForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken); + Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken); + Task SaveChangesAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IVendorOperationsDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorOperationsDataService.cs new file mode 100644 index 0000000..b8c71c1 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IVendorOperationsDataService.cs @@ -0,0 +1,33 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Models; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IVendorOperationsDataService + { + Task> GetAssignedWorkOrderIdsAsync(CancellationToken cancellationToken); + Task> GetNoVendorWorkOrdersAsync(DateTime cutoff, IReadOnlyCollection excludeIds, CancellationToken cancellationToken); + Task> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken); + Task> GetActiveVendorsAsync(CancellationToken cancellationToken); + Task> GetSitePreferredVendorsAsync(int locationId, CancellationToken cancellationToken); + Task> GetSitePreferredVendorsForReplaceAsync(int locationId, CancellationToken cancellationToken); + Task LocationExistsAsync(int locationId, CancellationToken cancellationToken); + Task> GetVendorsByIdsAsync(IReadOnlyCollection vendorIds, CancellationToken cancellationToken); + Task> GetInsightDispatchDataAsync(DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId, CancellationToken cancellationToken); + + Task GetVendorForUpdateAsync(int vendorId, CancellationToken cancellationToken); + Task GetCompletionDocumentForUpdateAsync(int documentId, CancellationToken cancellationToken); + Task GetDispatchForUpdateAsync(int dispatchId, CancellationToken cancellationToken); + Task GetWorkOrderForUpdateAsync(int workOrderId, CancellationToken cancellationToken); + Task GetExistingActiveDispatchForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); + + Task GenerateNextDispatchNumberAsync(CancellationToken cancellationToken); + Task GenerateNextPONumberAsync(CancellationToken cancellationToken); + Task AddDispatchAsync(Dispatch dispatch, CancellationToken cancellationToken); + Task StageWorkOrderAuditAsync(WorkOrderAuditLog log, CancellationToken cancellationToken); + Task StageVendorAuditAsync(VendorAuditLog log, CancellationToken cancellationToken); + Task AddSitePreferredVendorAsync(SitePreferredVendor preference, CancellationToken cancellationToken); + Task RemoveSitePreferredVendorsAsync(IEnumerable preferences, CancellationToken cancellationToken); + Task SaveChangesAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs new file mode 100644 index 0000000..f758550 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs @@ -0,0 +1,9 @@ +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderAdvancedSearchDataService + { + Task SearchAsync(WorkOrderAdvancedSearchQuery query); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs new file mode 100644 index 0000000..4f23d5a --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs @@ -0,0 +1,12 @@ +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderBoardDataService + { + Task GetBoardRowsAsync(WorkOrderBoardQuery query); + Task> GetDispatcherLookupsAsync(); + Task GetBoardRowByIdAsync(int workOrderId); + Task InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderBoardMutationDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardMutationDataService.cs new file mode 100644 index 0000000..291728b --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardMutationDataService.cs @@ -0,0 +1,27 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public enum BoardSaveOutcome + { + Saved, + ConcurrencyConflict + } + + public interface IWorkOrderBoardMutationDataService + { + Task VendorExistsAsync(int vendorId, CancellationToken cancellationToken); + Task GetMaxWorkOrderIdAsync(CancellationToken cancellationToken); + Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); + Task GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken); + Task GetTrackedDispatchByIdAsync(int dispatchId, CancellationToken cancellationToken); + void TrackNewWorkOrder(WorkOrder workOrder); + void TrackNewDispatch(Dispatch dispatch); + void TrackWorkOrderContact(int workOrderId, int contactId, string? notes); + void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version); + void SetExpectedDispatchVersion(Dispatch dispatch, byte[] version); + bool HasPendingChanges(); + Task ExecuteTransactionalAsync(Func work, CancellationToken cancellationToken); + Task SaveAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderCompletionDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderCompletionDataService.cs new file mode 100644 index 0000000..aa6174c --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderCompletionDataService.cs @@ -0,0 +1,12 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderCompletionDataService + { + Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); + void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version); + Task GetTrackedTemplateAsync(int id, CancellationToken cancellationToken); + Task SaveAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs new file mode 100644 index 0000000..3f61a70 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs @@ -0,0 +1,27 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderDetailDataService + { + Task ExistsAsync(int workOrderId); + Task GetExtendedFieldsAsync(int workOrderId); + Task> GetCommentsAsync(int workOrderId); + Task> GetAuditLogsAsync(int workOrderId, int? limit = null); + Task> GetAttachmentsAsync(int workOrderId); + Task> GetDispatchSignoffsAsync(int workOrderId); + Task GetWorkOrderForMediaAsync(int workOrderId); + } + + public interface ICompletionDocTemplateDataService + { + Task> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType); + Task GetByIdAsync(int id); + Task ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType); + Task CreateAsync(CompletionDocTemplate template); + Task UpdateAsync(CompletionDocTemplate template); + Task DeleteAsync(int id); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs new file mode 100644 index 0000000..5fac4ba --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs @@ -0,0 +1,37 @@ +namespace SeaHaven.DataServices.Interfaces +{ + public sealed class WeekRolledProcessOutcome + { + public bool Processed { get; init; } + public bool Skipped { get; init; } + public int OldCarriedOver { get; init; } + public int NewCarriedOver { get; init; } + } + + public sealed class PastDueRefreshResult + { + public int SetCount { get; init; } + public int ClearedCount { get; init; } + public int ExaminedCount { get; init; } + } + + public interface IWorkOrderDomainJobDataService + { + Task> GetWeekRolledCandidatesAsync( + DateOnly sourceWeekStart, + DateOnly sourceWeekEnd, + int batchSize = 500, + CancellationToken cancellationToken = default); + + Task TryProcessWeekRolledAsync( + int workOrderId, + DateOnly sourceWeekStart, + string correlationId, + CancellationToken cancellationToken = default); + + Task RefreshPastDueFlagsAsync( + DateTime utcNow, + int batchSize = 500, + CancellationToken cancellationToken = default); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderFieldLockDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderFieldLockDataService.cs index 2f63cea..cc95713 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderFieldLockDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderFieldLockDataService.cs @@ -6,6 +6,10 @@ namespace SeaHaven.DataServices.Interfaces { Task IsLockedAsync(int workOrderId, string fieldName, CancellationToken cancellationToken); - void EnqueueLock(int workOrderId, string fieldName, string? userId); + Task EnqueueLockAsync( + int workOrderId, + string fieldName, + string? userId, + CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderIngestDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderIngestDataService.cs new file mode 100644 index 0000000..b97d7c1 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderIngestDataService.cs @@ -0,0 +1,15 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderIngestDataService + { + Task GetMaxWorkOrderIdAsync(CancellationToken cancellationToken); + Task GetTrackedByExternalIdAsync(string externalWorkOrderId, CancellationToken cancellationToken); + void TrackWorkOrder(WorkOrder workOrder); + Task FindLocationAsync(string name, CancellationToken cancellationToken); + Task AddAndSaveLocationAsync(Locations location, CancellationToken cancellationToken); + Task ExecuteTransactionalAsync(Func work, CancellationToken cancellationToken); + Task SaveAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs new file mode 100644 index 0000000..db1d07e --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs @@ -0,0 +1,12 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderMediaDataService + { + Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); + Task GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken); + void TrackAttachment(WorkOrderAttachments attachment); + Task SaveAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderOpsHealthDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderOpsHealthDataService.cs new file mode 100644 index 0000000..1ef4a52 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderOpsHealthDataService.cs @@ -0,0 +1,11 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderOpsHealthDataService + { + Task CountSyncRejectedSinceAsync(DateTime since, CancellationToken cancellationToken); + Task CountFieldLocksAsync(CancellationToken cancellationToken); + Task GetLastWeekRolledProcessedAtAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs new file mode 100644 index 0000000..0cede89 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs @@ -0,0 +1,27 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Interfaces +{ + public record WorkOrderAdvancedSearchQuery( + string? Search, + DateOnly DateFrom, + DateOnly DateTo, + IReadOnlyList? Sites, + IReadOnlyList? Types, + IReadOnlyList? Dispatchers, + IReadOnlyList? Statuses, + IReadOnlyList? PmTypes, + IReadOnlyList? VendorIds, + IReadOnlyList? DocStatuses, + bool MyWorkOrders, + string? CurrentUserId, + int Page, + int PageSize, + string SortBy, + string SortDir); + + public record WorkOrderAdvancedSearchResult( + IReadOnlyList Rows, + int TotalCount); +} diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs new file mode 100644 index 0000000..c46ff55 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -0,0 +1,65 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Interfaces +{ + public record WorkOrderBoardQuery( + DateOnly WeekStart, + DateOnly WeekEnd, + IReadOnlyList? Dispatchers, + bool MyWorkOrders, + IReadOnlyList? Types, + string? Search, + string? CurrentUserId); + + public record WorkOrderBoardRawRow( + int Id, + string? InternalWONumber, + int RescheduleCount, + int CarriedOver, + WorkOrderType? WorkOrderType, + string? SiteCode, + string? LocationName, + string? PocName, + string? PocPhone, + string? PocNotes, + LifecycleStatus? LifecycleStatus, + string? LegacyStatus, + string? AssignTo, + string? DispatcherFirstName, + string? DispatcherLastName, + string? Initials, + string? Color, + DateTime? DueDate, + DateTime? ScheduledDate, + DateTime? ScheduledStart, + DateTime? ScheduledEnd, + DateOnly? TargetWeek, + bool? ScheduleWeekOnly, + int? VendorId, + string? VendorName, + string? TechName, + string? TechPhone, + DateTime? DispatchApptDate, + string? Trade, + string? Problem, + string? ServiceNotes, + string? ExtraServices, + DocStatus? DocStatus, + string? FlagColor, + int? PrimaryDispatchId, + byte[]? RowVersion, + byte[]? DispatchRowVersion, + bool IsUnscheduled); + + public record WorkOrderBoardQueryResult( + IReadOnlyList ScheduledRows, + IReadOnlyList UnscheduledRows, + int ScheduledTotalBeforeSearch); + + public record DispatcherLookupRow( + string Id, + string FirstName, + string? LastName, + string? Initials, + string? Color); +} diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs new file mode 100644 index 0000000..433b080 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs @@ -0,0 +1,33 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Interfaces +{ + public record WorkOrderDetailExtendedFields( + string? Description, + string? Trade, + string? Problem, + string? SubTrade, + DateOnly? OriginalWeek, + DateOnly? OriginalDate, + DateTime? CompletedDate, + string? SignOffName, + string? SignOffAttachment, + string? SignOffSignature, + string? ExtraServices, + string? ServiceNotes, + string? VendorNotes); + + public record DispatchSignoffRow( + int DispatchId, + string? SignoffType, + string? Name, + DateTime? SignedAt); + + public record CompletionDocTemplateRow( + int Id, + string Name, + string ServiceKey, + WorkOrderType? WorkOrderType, + string TemplateUrl, + bool IsActive); +} diff --git a/SeaHaven.DataServices/Models/VendorOperationsReadModels.cs b/SeaHaven.DataServices/Models/VendorOperationsReadModels.cs new file mode 100644 index 0000000..2eb630e --- /dev/null +++ b/SeaHaven.DataServices/Models/VendorOperationsReadModels.cs @@ -0,0 +1,43 @@ +namespace SeaHaven.DataServices.Models; + +public sealed class ScheduledDispatchReadModel +{ + public int Id { get; set; } + public int VendorId { get; set; } + public string? VendorName { get; set; } + public int WorkOrderId { get; set; } + public string? WorkOrderNumber { get; set; } + public DateTime Start { get; set; } + public DateTime End { get; set; } +} + +public sealed class NoVendorCandidateReadModel +{ + public int Id { get; set; } + public string? InternalWONumber { get; set; } + public string? WorkerOrderNumber { get; set; } + public DateTime? ScheduledDate { get; set; } +} + +public sealed class ActiveVendorReadModel +{ + public int Id { get; set; } + public string? CompanyName { get; set; } + public string? ContactName { get; set; } + public string? TradeSpecialties { get; set; } + public string? AvailabilityStatus { get; set; } + public DateTime? AvailabilityUpdatedAt { get; set; } +} + +public sealed class InsightDispatchData +{ + public int VendorId { get; set; } + public string? VendorName { get; set; } + public string? Status { get; set; } + public DateTime? DispatchedAt { get; set; } + public DateTime? CreatedDate { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? AcknowledgedAt { get; set; } + public DateTime? CompletedDate { get; set; } + public DateTime? DueDate { get; set; } +} diff --git a/SeaHaven.Services/Configuration/ServiceOptions.cs b/SeaHaven.Services/Configuration/ServiceOptions.cs index dad10e0..40999c1 100644 --- a/SeaHaven.Services/Configuration/ServiceOptions.cs +++ b/SeaHaven.Services/Configuration/ServiceOptions.cs @@ -29,3 +29,10 @@ public sealed class VendorPortalOptions public int? TokenLifetimeDays { get; set; } } + +public sealed class VendorDocumentsOptions +{ + public const string SectionName = "VendorDocuments"; + + public long MaxSizeBytes { get; set; } = 10 * 1024 * 1024; +} diff --git a/SeaHaven.Services/Constants/WorkOrderFieldNames.cs b/SeaHaven.Services/Constants/WorkOrderFieldNames.cs index 967e102..f97a394 100644 --- a/SeaHaven.Services/Constants/WorkOrderFieldNames.cs +++ b/SeaHaven.Services/Constants/WorkOrderFieldNames.cs @@ -5,6 +5,7 @@ namespace SeaHaven.Services.Constants public const string LifecycleStatus = nameof(LifecycleStatus); public const string AssignTo = nameof(AssignTo); public const string ScheduledDate = nameof(ScheduledDate); + public const string ScheduledStart = nameof(ScheduledStart); public const string ScheduledEnd = nameof(ScheduledEnd); public const string TargetWeek = nameof(TargetWeek); public const string ScheduleWeekOnly = nameof(ScheduleWeekOnly); diff --git a/SeaHaven.Services/DTOs/LocationDTOs.cs b/SeaHaven.Services/DTOs/LocationDTOs.cs index 541ba14..3a6edcd 100644 --- a/SeaHaven.Services/DTOs/LocationDTOs.cs +++ b/SeaHaven.Services/DTOs/LocationDTOs.cs @@ -62,4 +62,12 @@ namespace SeaHaven.Services.DTOs public string? ContactEmail { get; set; } public string? Status { get; set; } } + + public class SiteOptionDTO + { + public int Id { get; set; } + public string? Code { get; set; } + public string? City { get; set; } + public string? State { get; set; } + } } diff --git a/SeaHaven.Services/DTOs/VendorDocumentDTOs.cs b/SeaHaven.Services/DTOs/VendorDocumentDTOs.cs new file mode 100644 index 0000000..9b98fc7 --- /dev/null +++ b/SeaHaven.Services/DTOs/VendorDocumentDTOs.cs @@ -0,0 +1,36 @@ +namespace SeaHaven.Services.DTOs +{ + public sealed class UploadCompletionDocumentResultDTO + { + public int Id { get; set; } + public int Version { get; set; } + public string ScanStatus { get; set; } = "Pending"; + public string ReviewStatus { get; set; } = "Processing"; + } + + public enum VendorDocumentDownloadOutcome + { + Ok, + Locked, + NotFound + } + + public sealed class DownloadCompletionDocumentResultDTO + { + public VendorDocumentDownloadOutcome Outcome { get; set; } + public Stream? Content { get; set; } + public string? ContentType { get; set; } + public string? FileName { get; set; } + + public static DownloadCompletionDocumentResultDTO Ok(Stream content, string contentType, string fileName) => new() + { + Outcome = VendorDocumentDownloadOutcome.Ok, + Content = content, + ContentType = contentType, + FileName = fileName + }; + + public static DownloadCompletionDocumentResultDTO Locked() => new() { Outcome = VendorDocumentDownloadOutcome.Locked }; + public static DownloadCompletionDocumentResultDTO NotFound() => new() { Outcome = VendorDocumentDownloadOutcome.NotFound }; + } +} diff --git a/SeaHaven.Services/DTOs/VendorOperationsRequests.cs b/SeaHaven.Services/DTOs/VendorOperationsRequests.cs new file mode 100644 index 0000000..412aea8 --- /dev/null +++ b/SeaHaven.Services/DTOs/VendorOperationsRequests.cs @@ -0,0 +1,36 @@ +namespace SeaHaven.Services.DTOs +{ + public sealed class VendorAvailabilityRequest + { + public string? Status { get; set; } + } + + public sealed class SitePreferenceRequest + { + public int VendorId { get; set; } + public string? Trade { get; set; } + public int? SortOrder { get; set; } + } + + public sealed class DocumentReviewRequest + { + public string ReviewStatus { get; set; } = ""; + public string? RejectionReason { get; set; } + } + + public sealed class CommercialStatusRequest + { + public string InvoiceStatus { get; set; } = "Not Submitted"; + public string PaymentStatus { get; set; } = "Unavailable"; + public string? InvoiceNumber { get; set; } + } + + public sealed class VendorAssignmentRequest + { + public int VendorId { get; set; } + public DateTime? ScheduledStart { get; set; } + public DateTime? ScheduledEnd { get; set; } + public DateTime? EstimatedArrivalAt { get; set; } + public bool EtaManualOverride { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/VendorOperationsResults.cs b/SeaHaven.Services/DTOs/VendorOperationsResults.cs new file mode 100644 index 0000000..98ededb --- /dev/null +++ b/SeaHaven.Services/DTOs/VendorOperationsResults.cs @@ -0,0 +1,155 @@ +namespace SeaHaven.Services.DTOs +{ + public enum VendorOperationOutcomeKind { Ok, NotFound, BadRequest, Conflict } + + public sealed class VendorOperationOutcome + { + public VendorOperationOutcomeKind Kind { get; init; } + public T? Value { get; init; } + public string? Message { get; init; } + + public static VendorOperationOutcome Ok(T value) => new() { Kind = VendorOperationOutcomeKind.Ok, Value = value }; + public static VendorOperationOutcome NotFound(string? message = null) => new() { Kind = VendorOperationOutcomeKind.NotFound, Message = message }; + public static VendorOperationOutcome BadRequest(string message) => new() { Kind = VendorOperationOutcomeKind.BadRequest, Message = message }; + public static VendorOperationOutcome Conflict(string message) => new() { Kind = VendorOperationOutcomeKind.Conflict, Message = message }; + } + + public abstract class VendorNotificationItem + { + public DateTime TriggeredAt { get; set; } + } + + public sealed class NotificationActionDto + { + public string Label { get; set; } = ""; + public string Href { get; set; } = ""; + } + + public sealed class NoVendorNotification : VendorNotificationItem + { + public string Id { get; set; } = ""; + public string Type { get; set; } = ""; + public string Severity { get; set; } = ""; + public string Group { get; set; } = ""; + public string Title { get; set; } = ""; + public int WorkOrderId { get; set; } + public string? WorkOrderNumber { get; set; } + public DateTime ScheduledAt { get; set; } + public NotificationActionDto Action { get; set; } = new(); + } + + public sealed class ConflictWorkOrderDto + { + public int WorkOrderId { get; set; } + public string? WorkOrderNumber { get; set; } + public DateTime Start { get; set; } + public DateTime End { get; set; } + } + + public sealed class VendorConflictNotification : VendorNotificationItem + { + public string Id { get; set; } = ""; + public string Type { get; set; } = ""; + public string Severity { get; set; } = ""; + public string Group { get; set; } = ""; + public string Title { get; set; } = ""; + public int VendorId { get; set; } + public string? VendorName { get; set; } + public IReadOnlyList WorkOrders { get; set; } = Array.Empty(); + public IReadOnlyList Actions { get; set; } = Array.Empty(); + } + + public sealed class VendorNotificationsResult + { + public DateTime GeneratedAt { get; set; } + public IReadOnlyList Items { get; set; } = Array.Empty(); + } + + public sealed class AvailabilityConflictDto + { + public int? WorkOrderId { get; set; } + public string? WorkOrderNumber { get; set; } + public DateTime Start { get; set; } + public DateTime End { get; set; } + } + + public sealed class VendorAvailabilityRow + { + public int Id { get; set; } + public string? CompanyName { get; set; } + public string? ContactName { get; set; } + public string? TradeSpecialties { get; set; } + public string AvailabilityStatus { get; set; } = ""; + public DateTime? AvailabilityUpdatedAt { get; set; } + public bool IsPreferred { get; set; } + public int? PreferenceRank { get; set; } + public IReadOnlyList Conflicts { get; set; } = Array.Empty(); + } + + public sealed class VendorAvailabilityResult + { + public DateTime GeneratedAt { get; set; } + public int StaleAfterHours { get; set; } + public IReadOnlyList Data { get; set; } = Array.Empty(); + } + + public sealed class VendorAvailabilityUpdateResult + { + public int Id { get; set; } + public string? AvailabilityStatus { get; set; } + public DateTime? AvailabilityUpdatedAt { get; set; } + } + + public sealed class SitePreferenceRow + { + public int Id { get; set; } + public int LocationId { get; set; } + public int VendorId { get; set; } + public string? VendorName { get; set; } + public string? Trade { get; set; } + public int? SortOrder { get; set; } + public bool IsStale { get; set; } + public DateTime? LastModificationTime { get; set; } + public string? UpdatedBy { get; set; } + } + + public sealed class DocumentReviewResult + { + public int Id { get; set; } + public string? ReviewStatus { get; set; } + public string? RejectionReason { get; set; } + public DateTime? ReviewedAt { get; set; } + } + + public sealed class CommercialStatusResult + { + public int Id { get; set; } + public string? PONumber { get; set; } + public string? InvoiceNumber { get; set; } + public string? InvoiceStatus { get; set; } + public string? PaymentStatus { get; set; } + public DateTime? CommercialStatusUpdatedAt { get; set; } + } + + public sealed class VendorAssignmentResult + { + public int Id { get; set; } + public int VendorId { get; set; } + public string? VendorName { get; set; } + public DateTime? EstimatedArrivalAt { get; set; } + public bool EtaManualOverride { get; set; } + public string AvailabilityStatus { get; set; } = ""; + public IReadOnlyList Conflicts { get; set; } = Array.Empty(); + } + + public sealed class VendorInsightRow + { + public int VendorId { get; set; } + public string Vendor { get; set; } = ""; + public int CompletedJobs { get; set; } + public decimal CancellationRefusalRate { get; set; } + public decimal OnTimeArrivalRate { get; set; } + public decimal OnTimeCompletionRate { get; set; } + public double? AverageCycleHours { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs new file mode 100644 index 0000000..f0b13f9 --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -0,0 +1,101 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.Helpers; + +namespace SeaHaven.Services.DTOs +{ + public class WorkOrderBoardQueryDto + { + public DateOnly WeekStart { get; set; } + public DateOnly? WeekEnd { get; set; } + public List? Dispatchers { get; set; } + public bool MyWorkOrders { get; set; } + public List? Types { get; set; } + public string? Search { get; set; } + } + + public class WorkOrderBoardCountsDto + { + public int Returned { get; set; } + public int Total { get; set; } + } + + public class WorkOrderBoardRowDto + { + public int Id { get; set; } + public string? WoNumber { get; set; } + public int RescheduleCount { get; set; } + public int CarriedOver { get; set; } + public WorkOrderType? WorkOrderType { get; set; } + public bool IsPastDue { get; set; } + public string? SiteCode { get; set; } + public string? LocationName { get; set; } + public string? PocName { get; set; } + public string? PocPhone { get; set; } + public string? PocNotes { get; set; } + public LifecycleStatus? LifecycleStatus { get; set; } + public string? LifecycleStatusLabel { get; set; } + public string? LegacyStatus { get; set; } + public string? DispatcherId { get; set; } + public string? DispatcherName { get; set; } + public string? Initials { get; set; } + public string? Color { get; set; } + public DateTime? DueDate { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateOnly? TargetWeek { get; set; } + public bool? ScheduleWeekOnly { get; set; } + public string? DayGroup { get; set; } + public int? VendorId { get; set; } + public string? VendorName { get; set; } + public string? TechName { get; set; } + public string? TechPhone { get; set; } + public DateTime? ApptDate { get; set; } + public string? ApptTime { get; set; } + public string? Pm { get; set; } + public string? ServiceNotes { get; set; } + public List? ExtraServices { get; set; } + public DocStatus? DocStatus { get; set; } + /// Board flag color (#RRGGBB). Null = no flag. Distinct from Color (dispatcher avatar). + public string? FlagColor { get; set; } + public int? PrimaryDispatchId { get; set; } + public byte[]? RowVersion { get; set; } + public byte[]? DispatchRowVersion { get; set; } + } + + public class WorkOrderBoardPatchRequestDto + { + public string Field { get; set; } = ""; + public string? Value { get; set; } + public string? WorkOrderVersion { get; set; } + public string? DispatchVersion { get; set; } + public int? PrimaryDispatchId { get; set; } + } + + public class WorkOrderBoardConflictDto + { + public string Message { get; set; } = "The record was modified by another user. Refresh and retry."; + public WorkOrderBoardRowDto? CurrentState { get; set; } + } + + public class WorkOrderBoardValidationErrorDto + { + public string Code { get; set; } = ""; + public string Message { get; set; } = ""; + } + + public class WorkOrderBoardResponseDto + { + public DateOnly WeekStart { get; set; } + public DateOnly WeekEnd { get; set; } + public WorkOrderBoardCountsDto Counts { get; set; } = new(); + public List Unscheduled { get; set; } = new(); + public List Scheduled { get; set; } = new(); + } + + public class DispatcherLookupDto + { + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public string? Initials { get; set; } + public string? Color { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs new file mode 100644 index 0000000..d93e123 --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -0,0 +1,63 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.Helpers; + +namespace SeaHaven.Services.DTOs +{ + /// + /// Payload for creating a board work order (SHOC create wizard). + /// FE follow-up: extend WorkOrderBoardCreatePayload + mapper with PrimaryService, + /// ExtraServices, ServiceNotes, PocName, PocPhone, VendorNotes, TechPhone; stop embedding + /// POC/vendor/service notes in description (pocNotes already exists). Prefer structured + /// fields; keep Trade+Description temporarily for legacy clients. + /// + public class WorkOrderBoardCreateRequestDto + { + public string? WoNumber { get; set; } + public WorkOrderType WorkOrderType { get; set; } + public string? SiteCode { get; set; } + public string? Description { get; set; } + /// Legacy alias for primary service; prefer . + public string? Trade { get; set; } + /// Primary service label; persisted as Trade / board pm. + public string? PrimaryService { get; set; } + public List? ExtraServices { get; set; } + public string? ServiceNotes { get; set; } + public int? LocationId { get; set; } + public DateTime? DueDate { get; set; } + public string? AssignTo { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateOnly? TargetWeek { get; set; } + public bool? ScheduleWeekOnly { get; set; } + public DocStatus? DocStatus { get; set; } + public int? VendorId { get; set; } + public DateTime? ApptDate { get; set; } + public string? ApptTime { get; set; } + public int? PocContactId { get; set; } + public string? PocName { get; set; } + public string? PocPhone { get; set; } + public string? PocNotes { get; set; } + public string? TechPhone { get; set; } + public string? VendorNotes { get; set; } + } + + /// Advanced search / filter query for the board. + public class WorkOrderAdvancedSearchQueryDto + { + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 50; + public string? Search { get; set; } + public WorkOrderAdvancedSearchDatePreset? DatePreset { get; set; } + public DateOnly? DateFrom { get; set; } + public DateOnly? DateTo { get; set; } + public List? Sites { get; set; } + public List? Types { get; set; } + public List? Dispatchers { get; set; } + public List? Statuses { get; set; } + public List? PmTypes { get; set; } + public List? VendorIds { get; set; } + public List? DocStatuses { get; set; } + public bool MyWorkOrders { get; set; } + public string? SortBy { get; set; } + public string? SortDir { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs new file mode 100644 index 0000000..105e112 --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs @@ -0,0 +1,106 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.DTOs +{ + public class WorkOrderDetailDto + { + public WorkOrderDetailInfoDto Info { get; set; } = new(); + public WorkOrderCompletionDto Completion { get; set; } = new(); + public List Comments { get; set; } = new(); + public List Audit { get; set; } = new(); + public List Media { get; set; } = new(); + } + + public class WorkOrderDetailInfoDto : WorkOrderBoardRowDto + { + public string? Description { get; set; } + public string? Trade { get; set; } + public string? Problem { get; set; } + public string? SubTrade { get; set; } + public DateOnly? OriginalWeek { get; set; } + public DateOnly? OriginalDate { get; set; } + public DateTime? CompletedDate { get; set; } + public string? VendorNotes { get; set; } + } + + public class CompletionDocTemplateDto + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string ServiceKey { get; set; } = ""; + public WorkOrderType? WorkOrderType { get; set; } + public string TemplateUrl { get; set; } = ""; + public bool IsActive { get; set; } + } + + public class CompletionDocTemplateCreateDto + { + public string Name { get; set; } = ""; + public string ServiceKey { get; set; } = ""; + public WorkOrderType? WorkOrderType { get; set; } + public string TemplateUrl { get; set; } = ""; + public bool IsActive { get; set; } = true; + } + + public class WorkOrderCompletionDto + { + public DocStatus? DocStatus { get; set; } + public CompletionDocTemplateDto? Template { get; set; } + public string? SignOffName { get; set; } + public string? SignOffAttachment { get; set; } + public string? SignOffSignature { get; set; } + public List DispatchSignoffs { get; set; } = new(); + } + + public class DispatchSignoffDto + { + public int DispatchId { get; set; } + public string? SignoffType { get; set; } + public string? Name { get; set; } + public string? SignedAt { get; set; } + } + + public class WorkOrderCommentDto + { + public int Id { get; set; } + public string? AuthorId { get; set; } + public string? AuthorName { get; set; } + public string Text { get; set; } = ""; + public string Time { get; set; } = ""; + public string? Documents { get; set; } + } + + public class WorkOrderCommentCreateDto + { + public string Text { get; set; } = ""; + } + + public class WorkOrderAuditEntryDto + { + public string Type { get; set; } = "manual"; + public string? DispatcherId { get; set; } + public string Action { get; set; } = ""; + public string? FieldName { get; set; } + public string OldValue { get; set; } = ""; + public string NewValue { get; set; } = ""; + public string Time { get; set; } = ""; + public int? DispatchId { get; set; } + } + + public class WorkOrderMediaFileDto + { + public int Id { get; set; } + public WorkOrderMediaCategory Category { get; set; } + public string Url { get; set; } = ""; + public string? FileName { get; set; } + public string? UploadedAt { get; set; } + public bool IsLegacy { get; set; } + } + + public class WorkOrderCompletionDocUploadDto + { + public string? SignOffName { get; set; } + public string? SignOffSignature { get; set; } + public string? WorkOrderVersion { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs new file mode 100644 index 0000000..885c363 --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs @@ -0,0 +1,21 @@ +namespace SeaHaven.Services.DTOs +{ + public sealed class WeekRolledJobResult + { + public int Processed { get; set; } + public int Skipped { get; set; } + public int Failed { get; set; } + public string CorrelationId { get; set; } = ""; + public DateOnly SourceWeekStart { get; set; } + public DateOnly SourceWeekEnd { get; set; } + public long DurationMs { get; set; } + } + + public sealed class PastDueCacheJobResult + { + public int SetCount { get; set; } + public int ClearedCount { get; set; } + public int ExaminedCount { get; set; } + public long DurationMs { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs b/SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs new file mode 100644 index 0000000..4f1087a --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs @@ -0,0 +1,54 @@ +namespace SeaHaven.Services.DTOs +{ + public sealed class WorkOrderIngestPayloadDto + { + public required string ExternalWorkOrderId { get; set; } + public string? Description { get; set; } + public string? WoStatus { get; set; } + public string? Severity { get; set; } + public string? Customer { get; set; } + public string? SiteCode { get; set; } + public string? Building { get; set; } + public string? Address { get; set; } + public DateTime? DueDate { get; set; } + public DateTime? DateReported { get; set; } + public DateTime? ScheduledStart { get; set; } + public string? SourceEmailS3Key { get; set; } + public DateTime? CreatedAt { get; set; } + } + + public sealed class WorkOrderIngestBatchRequestDto + { + public List? Items { get; set; } + } + + public sealed class WorkOrderIngestItemResultDto + { + public required string ExternalWorkOrderId { get; set; } + public int WorkOrderId { get; set; } + public bool Created { get; set; } + } + + public sealed class WorkOrderIngestBatchResultDto + { + public int Created { get; set; } + public int Updated { get; set; } + public int Skipped { get; set; } + public List Results { get; set; } = new(); + } + + public sealed class WorkOrderOpsHealthDto + { + public DateTime? LastWeekRolledRunUtc { get; set; } + public DateTime? LastPastDueCacheRunUtc { get; set; } + public string? LastWeekRolledError { get; set; } + public string? LastPastDueCacheError { get; set; } + public int SyncRejectedLast24h { get; set; } + public int FieldLockCount { get; set; } + public bool SyncEnabled { get; set; } + public bool IngestEnabled { get; set; } + public bool LegacyDeprecationEnabled { get; set; } + public string? LegacySunsetDate { get; set; } + public DateTime CheckedAtUtc { get; set; } + } +} diff --git a/SeaHaven.Services/DependencyInjection/ServicesModule.cs b/SeaHaven.Services/DependencyInjection/ServicesModule.cs index d5fddc2..aa6f98f 100644 --- a/SeaHaven.Services/DependencyInjection/ServicesModule.cs +++ b/SeaHaven.Services/DependencyInjection/ServicesModule.cs @@ -16,6 +16,7 @@ namespace SeaHaven.Services.DependencyInjection services.Configure(configuration.GetSection(JwtOptions.SectionName)); services.Configure(configuration.GetSection(ApprovalsOptions.SectionName)); services.Configure(configuration.GetSection(VendorPortalOptions.SectionName)); + services.Configure(configuration.GetSection(VendorDocumentsOptions.SectionName)); var assembly = Assembly.GetExecutingAssembly(); diff --git a/SeaHaven.Services/Exceptions/WorkOrderBoardExceptions.cs b/SeaHaven.Services/Exceptions/WorkOrderBoardExceptions.cs new file mode 100644 index 0000000..c15ecd0 --- /dev/null +++ b/SeaHaven.Services/Exceptions/WorkOrderBoardExceptions.cs @@ -0,0 +1,25 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Exceptions +{ + public class WorkOrderBoardValidationException : Exception + { + public WorkOrderBoardValidationException(string code, string message) : base(message) + { + Code = code; + } + + public string Code { get; } + } + + public class WorkOrderBoardConcurrencyException : Exception + { + public WorkOrderBoardConcurrencyException(WorkOrderBoardRowDto? currentState) + : base("The record was modified by another user. Refresh and retry.") + { + CurrentState = currentState; + } + + public WorkOrderBoardRowDto? CurrentState { get; } + } +} diff --git a/SeaHaven.Services/Helpers/LifecycleStatusMapper.cs b/SeaHaven.Services/Helpers/LifecycleStatusMapper.cs index b7341d6..551fa66 100644 --- a/SeaHaven.Services/Helpers/LifecycleStatusMapper.cs +++ b/SeaHaven.Services/Helpers/LifecycleStatusMapper.cs @@ -1,86 +1,85 @@ -using Data.SeaHavenIndustries.Enums; - -namespace SeaHaven.Services.Helpers -{ - public static class LifecycleStatusMapper - { - public static LifecycleStatus? FromLegacyStatus(string? status) - { - if (string.IsNullOrWhiteSpace(status)) return null; - - return status.Trim().ToLowerInvariant() switch - { - "open" or "incomplete" => LifecycleStatus.Incomplete, - "pending" or "onhold" or "on hold" => LifecycleStatus.Pending, - "scheduled" => LifecycleStatus.Scheduled, - "en route" or "enroute" => LifecycleStatus.EnRoute, - "on site" or "onsite" => LifecycleStatus.OnSite, - "inprogress" or "in progress" => LifecycleStatus.InProgress, - "completed" or "complete" or "done" or "closed" => LifecycleStatus.Completed, - "rescheduled" => LifecycleStatus.Rescheduled, - "cancelled" or "canceled" => LifecycleStatus.Canceled, - "pending quote" or "pendingquote" or "pendingapproval" or "pending approval" => LifecycleStatus.PendingQuote, - _ => null - }; - } - - public static LifecycleStatus? ParseLifecycleStatus(string? value) - { - if (string.IsNullOrWhiteSpace(value)) return null; - - if (TryParseFeLabel(value, out var fromLabel)) - return fromLabel; - - if (Enum.TryParse(value, true, out var parsed)) - return parsed; - - return FromLegacyStatus(value); - } - - public static bool TryParseFeLabel(string? label, out LifecycleStatus status) - { - status = default; - if (string.IsNullOrWhiteSpace(label)) return false; - - return label.Trim().ToLowerInvariant() switch - { - "incomplete" => Set(LifecycleStatus.Incomplete, out status), - "pending" => Set(LifecycleStatus.Pending, out status), - "scheduled" => Set(LifecycleStatus.Scheduled, out status), - "en route" => Set(LifecycleStatus.EnRoute, out status), - "on site" => Set(LifecycleStatus.OnSite, out status), - "in progress" => Set(LifecycleStatus.InProgress, out status), - "completed" => Set(LifecycleStatus.Completed, out status), - "rescheduled" => Set(LifecycleStatus.Rescheduled, out status), - "canceled" => Set(LifecycleStatus.Canceled, out status), - "pending quote" => Set(LifecycleStatus.PendingQuote, out status), - _ => false - }; - } - - public static string ToFeLabel(LifecycleStatus status) => status switch - { - LifecycleStatus.Incomplete => "Incomplete", - LifecycleStatus.Pending => "Pending", - LifecycleStatus.Scheduled => "Scheduled", - LifecycleStatus.EnRoute => "En Route", - LifecycleStatus.OnSite => "On Site", - LifecycleStatus.InProgress => "In Progress", - LifecycleStatus.Completed => "Completed", - LifecycleStatus.Rescheduled => "Rescheduled", - LifecycleStatus.Canceled => "Canceled", - LifecycleStatus.PendingQuote => "Pending Quote", - _ => status.ToString() - }; - - public static bool IsTerminal(LifecycleStatus? status) - => status is LifecycleStatus.Completed or LifecycleStatus.Canceled; - - private static bool Set(LifecycleStatus value, out LifecycleStatus status) - { - status = value; - return true; - } - } -} - \ No newline at end of file +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Helpers +{ + public static class LifecycleStatusMapper + { + public static LifecycleStatus? FromLegacyStatus(string? status) + { + if (string.IsNullOrWhiteSpace(status)) return null; + + return status.Trim().ToLowerInvariant() switch + { + "open" or "incomplete" => LifecycleStatus.Incomplete, + "pending" or "onhold" or "on hold" => LifecycleStatus.Pending, + "scheduled" => LifecycleStatus.Scheduled, + "en route" or "enroute" => LifecycleStatus.EnRoute, + "on site" or "onsite" => LifecycleStatus.OnSite, + "inprogress" or "in progress" => LifecycleStatus.InProgress, + "completed" or "complete" or "done" or "closed" => LifecycleStatus.Completed, + "rescheduled" => LifecycleStatus.Rescheduled, + "cancelled" or "canceled" => LifecycleStatus.Canceled, + "pending quote" or "pendingquote" or "pendingapproval" or "pending approval" => LifecycleStatus.PendingQuote, + _ => null + }; + } + + public static LifecycleStatus? ParseLifecycleStatus(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + + if (TryParseFeLabel(value, out var fromLabel)) + return fromLabel; + + if (Enum.TryParse(value, true, out var parsed)) + return parsed; + + return FromLegacyStatus(value); + } + + public static bool TryParseFeLabel(string? label, out LifecycleStatus status) + { + status = default; + if (string.IsNullOrWhiteSpace(label)) return false; + + return label.Trim().ToLowerInvariant() switch + { + "incomplete" => Set(LifecycleStatus.Incomplete, out status), + "pending" => Set(LifecycleStatus.Pending, out status), + "scheduled" => Set(LifecycleStatus.Scheduled, out status), + "en route" => Set(LifecycleStatus.EnRoute, out status), + "on site" => Set(LifecycleStatus.OnSite, out status), + "in progress" => Set(LifecycleStatus.InProgress, out status), + "completed" => Set(LifecycleStatus.Completed, out status), + "rescheduled" => Set(LifecycleStatus.Rescheduled, out status), + "canceled" => Set(LifecycleStatus.Canceled, out status), + "pending quote" => Set(LifecycleStatus.PendingQuote, out status), + _ => false + }; + } + + public static string ToFeLabel(LifecycleStatus status) => status switch + { + LifecycleStatus.Incomplete => "Incomplete", + LifecycleStatus.Pending => "Pending", + LifecycleStatus.Scheduled => "Scheduled", + LifecycleStatus.EnRoute => "En Route", + LifecycleStatus.OnSite => "On Site", + LifecycleStatus.InProgress => "In Progress", + LifecycleStatus.Completed => "Completed", + LifecycleStatus.Rescheduled => "Rescheduled", + LifecycleStatus.Canceled => "Canceled", + LifecycleStatus.PendingQuote => "Pending Quote", + _ => status.ToString() + }; + + public static bool IsTerminal(LifecycleStatus? status) + => status is LifecycleStatus.Completed or LifecycleStatus.Canceled; + + private static bool Set(LifecycleStatus value, out LifecycleStatus status) + { + status = value; + return true; + } + } +} diff --git a/SeaHaven.Services/Helpers/LifecycleStatusSets.cs b/SeaHaven.Services/Helpers/LifecycleStatusSets.cs new file mode 100644 index 0000000..5427ea6 --- /dev/null +++ b/SeaHaven.Services/Helpers/LifecycleStatusSets.cs @@ -0,0 +1,20 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Helpers +{ + /// + /// Shared terminal lifecycle statuses for board filters, read-only checks, and jobs. + /// Keep this as the single source of truth (EF expressions should copy these values inline). + /// + public static class LifecycleStatusSets + { + public static readonly LifecycleStatus[] Terminal = + { + LifecycleStatus.Completed, + LifecycleStatus.Canceled + }; + + public static bool IsTerminal(LifecycleStatus? status) + => status is LifecycleStatus.Completed or LifecycleStatus.Canceled; + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs new file mode 100644 index 0000000..6a7e295 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs @@ -0,0 +1,87 @@ +using Data.SeaHavenIndustries; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderAuditProjection + { + private const string UnassignedLabel = "Unassigned"; + + public static WorkOrderAuditEntryDto ToDto( + WorkOrderAuditLog log, + IReadOnlyDictionary? assignToDisplayNames = null) + { + var oldValue = log.OldValue ?? ""; + var newValue = log.NewValue ?? ""; + + if (IsAssignToField(log.FieldName)) + { + oldValue = ResolveAssignToDisplay(oldValue, assignToDisplayNames); + newValue = ResolveAssignToDisplay(newValue, assignToDisplayNames); + } + + return new WorkOrderAuditEntryDto + { + Type = MapEventType(log.EventType), + DispatcherId = log.UserId, + Action = log.Action ?? "", + FieldName = log.FieldName, + OldValue = oldValue, + NewValue = newValue, + Time = log.CreatedAt.ToString("o"), + DispatchId = log.DispatchId + }; + } + + public static List ToDtos( + IEnumerable logs, + IReadOnlyDictionary? assignToDisplayNames = null) + => logs.Select(log => ToDto(log, assignToDisplayNames)).ToList(); + + public static IEnumerable CollectAssignToUserIds(IEnumerable logs) + { + return logs + .Where(l => IsAssignToField(l.FieldName)) + .SelectMany(l => new[] { l.OldValue, l.NewValue }) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v!) + .Distinct(StringComparer.Ordinal); + } + + internal static bool IsAssignToField(string? fieldName) + => string.Equals(fieldName, "AssignTo", StringComparison.OrdinalIgnoreCase) + || string.Equals(fieldName, "AssignedTo", StringComparison.OrdinalIgnoreCase); + + private static string ResolveAssignToDisplay( + string value, + IReadOnlyDictionary? assignToDisplayNames) + { + if (string.IsNullOrWhiteSpace(value)) + return UnassignedLabel; + + if (assignToDisplayNames != null + && assignToDisplayNames.TryGetValue(value, out var name) + && !string.IsNullOrWhiteSpace(name)) + { + return name; + } + + return value; + } + + private static string MapEventType(string? eventType) + { + if (string.IsNullOrWhiteSpace(eventType)) + return "manual"; + + return eventType.ToLowerInvariant() switch + { + "manual" => "manual", + "system" => "system", + "sync" => "sync", + "vendor" => "vendor", + _ => eventType.ToLowerInvariant() + }; + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs b/SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs new file mode 100644 index 0000000..f50949a --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs @@ -0,0 +1,57 @@ +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderBoardApptTimeParser + { + public static bool TryParse(string? value, out TimeSpan? start, out TimeSpan? end, out string? error) + { + start = null; + end = null; + error = null; + + if (string.IsNullOrWhiteSpace(value)) + return true; + + var trimmed = value.Trim(); + var parts = trimmed.Split(new[] { '–', '—', '-' }) + .Select(p => p.Trim()) + .Where(p => !string.IsNullOrEmpty(p)) + .ToArray(); + + if (parts.Length == 1) + { + if (!TimeSpan.TryParse(parts[0], out var single)) + { + error = "Invalid appointment time format."; + return false; + } + + start = single; + return true; + } + + if (parts.Length == 2) + { + if (!TimeSpan.TryParse(parts[0], out var startTime) || !TimeSpan.TryParse(parts[1], out var endTime)) + { + error = "Invalid appointment time range format."; + return false; + } + + start = startTime; + end = endTime; + return true; + } + + error = "Invalid appointment time format."; + return false; + } + + public static DateTime? CombineDateAndTime(DateTime? date, TimeSpan? time) + { + if (!date.HasValue || !time.HasValue) + return date; + + return date.Value.Date.Add(time.Value); + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs new file mode 100644 index 0000000..2477cb3 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs @@ -0,0 +1,136 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.Exceptions; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderBoardFieldMutations + { + public static List ApplyAssignTo(WorkOrder workOrder, string? value, string auditField = "AssignTo") + { + var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + var old = workOrder.AssignTo; + var changes = new List(); + + if (old != normalized) + { + workOrder.AssignTo = normalized; + changes.Add(BoardFieldChange.AssignmentChanged(auditField, old, normalized)); + } + + changes.AddRange(ApplyAutoScheduleSideEffects(workOrder)); + return changes; + } + + public static List ApplyScheduledDate( + WorkOrder workOrder, + DateTime? parsed, + string auditField = "ScheduledDate", + bool incrementRescheduleOnChange = true) + { + var previousDate = workOrder.ScheduledDate; + var changes = new List(); + var oldFormatted = FormatDate(previousDate); + var newFormatted = FormatDate(parsed); + + if (oldFormatted == newFormatted) + return changes; + + workOrder.ScheduledDate = parsed; + changes.Add(BoardFieldChange.Changed(auditField, oldFormatted, newFormatted)); + + if (!workOrder.OriginalDate.HasValue && parsed.HasValue) + { + workOrder.OriginalDate = DateOnly.FromDateTime(parsed.Value); + var originalWeek = WorkOrderBoardMutationRules.ComputeOriginalWeek(parsed); + if (originalWeek.HasValue) + workOrder.OriginalWeek = originalWeek; + } + + if (incrementRescheduleOnChange && WorkOrderBoardMutationRules.IsReschedule(previousDate, parsed)) + { + var oldCount = workOrder.RescheduleCount.ToString(); + workOrder.RescheduleCount++; + changes.Add(BoardFieldChange.Changed( + WorkOrderBoardFieldNames.RescheduleCount, + oldCount, + workOrder.RescheduleCount.ToString())); + } + + changes.AddRange(ApplyAutoScheduleSideEffects(workOrder)); + return changes; + } + + public static List ApplyAutoScheduleSideEffects(WorkOrder workOrder) + { + var changes = new List(); + if (!WorkOrderBoardMutationRules.ShouldAutoSchedule( + workOrder.LifecycleStatus, + workOrder.ScheduledDate, + workOrder.AssignTo, + workOrder.ScheduleWeekOnly)) + return changes; + + var old = workOrder.LifecycleStatus?.ToString(); + workOrder.LifecycleStatus = LifecycleStatus.Scheduled; + workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Scheduled); + changes.Add(BoardFieldChange.StatusChanged("LifecycleStatus", old, LifecycleStatus.Scheduled.ToString())); + return changes; + } + + public static BoardFieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value) + { + if (!WorkOrderBoardApptTimeParser.TryParse(value, out var start, out var end, out var error)) + throw new WorkOrderBoardValidationException("InvalidValue", error ?? "Invalid apptTime."); + + var apptDate = dispatch.ScheduledDate ?? workOrder.ScheduledDate; + var oldStart = FormatDateTime(workOrder.ScheduledStart); + var oldEnd = FormatDateTime(workOrder.ScheduledEnd); + + workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start); + workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end); + + var newStart = FormatDateTime(workOrder.ScheduledStart); + var newEnd = FormatDateTime(workOrder.ScheduledEnd); + var oldCombined = FormatApptRange(oldStart, oldEnd); + var newCombined = FormatApptRange(newStart, newEnd); + + if (oldCombined == newCombined) + return BoardFieldChange.Unchanged("ApptTime", dispatch.Id); + + return BoardFieldChange.Changed("ApptTime", oldCombined, newCombined, dispatch.Id); + } + + public static void SetInitialLifecycleStatus(WorkOrder workOrder) + { + workOrder.LifecycleStatus = LifecycleStatus.Incomplete; + workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Incomplete); + } + + private static string? FormatDate(DateTime? value) => value?.ToString("yyyy-MM-dd"); + private static string? FormatDateTime(DateTime? value) => value?.ToString("O"); + private static string? FormatApptRange(string? start, string? end) + => start == null && end == null ? null : $"{start}|{end}"; + + public sealed record BoardFieldChange( + string FieldName, + string? OldValue, + string? NewValue, + AuditActionType Action, + int? DispatchId, + bool HasChanged) + { + public static BoardFieldChange Unchanged(string fieldName, int? dispatchId = null) + => new(fieldName, null, null, AuditActionType.FieldChanged, dispatchId, false); + + public static BoardFieldChange Changed(string fieldName, string? oldValue, string? newValue, int? dispatchId = null) + => new(fieldName, oldValue, newValue, AuditActionType.FieldChanged, dispatchId, true); + + public static BoardFieldChange StatusChanged(string fieldName, string? oldValue, string? newValue) + => new(fieldName, oldValue, newValue, AuditActionType.StatusChanged, null, true); + + public static BoardFieldChange AssignmentChanged(string fieldName, string? oldValue, string? newValue) + => new(fieldName, oldValue, newValue, AuditActionType.AssignmentChanged, null, true); + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs new file mode 100644 index 0000000..a76a7fe --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs @@ -0,0 +1,94 @@ +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderBoardFieldNames + { + public const string WoNumber = "woNumber"; + public const string WorkOrderType = "workOrderType"; + public const string SiteCode = "siteCode"; + public const string LifecycleStatus = "lifecycleStatus"; + public const string AssignTo = "assignTo"; + public const string DueDate = "dueDate"; + public const string ScheduledDate = "scheduledDate"; + public const string TargetWeek = "targetWeek"; + public const string ScheduleWeekOnly = "scheduleWeekOnly"; + public const string VendorId = "vendorId"; + public const string ApptDate = "apptDate"; + public const string ApptTime = "apptTime"; + public const string DocStatus = "docStatus"; + public const string Pm = "pm"; + public const string ServiceNotes = "serviceNotes"; + public const string ExtraServices = "extraServices"; + public const string FlagColor = "flagColor"; + public const string RescheduleCount = "RescheduleCount"; + + private static readonly HashSet DispatchFields = new(StringComparer.OrdinalIgnoreCase) + { + VendorId, + ApptDate, + ApptTime + }; + + private static readonly HashSet AllEditable = new(StringComparer.OrdinalIgnoreCase) + { + WoNumber, + WorkOrderType, + SiteCode, + LifecycleStatus, + AssignTo, + DueDate, + ScheduledDate, + TargetWeek, + ScheduleWeekOnly, + VendorId, + ApptDate, + ApptTime, + DocStatus, + Pm, + ServiceNotes, + ExtraServices, + FlagColor + }; + + public static bool IsEditable(string field) => Canonicalize(field) != null; + + public static bool IsDispatchField(string field) + { + var canonical = Canonicalize(field); + return canonical != null && DispatchFields.Contains(canonical); + } + + public static string? Canonicalize(string field) + { + if (string.IsNullOrWhiteSpace(field)) + return null; + + return AllEditable.FirstOrDefault(f => string.Equals(f, field, StringComparison.OrdinalIgnoreCase)); + } + + public static string ToAuditFieldName(string field) + { + var canonical = Canonicalize(field) ?? field; + return canonical switch + { + WoNumber => "InternalWONumber", + WorkOrderType => "WorkOrderType", + SiteCode => "SiteCode", + LifecycleStatus => "LifecycleStatus", + AssignTo => "AssignTo", + DueDate => "DueDate", + ScheduledDate => "ScheduledDate", + TargetWeek => "TargetWeek", + ScheduleWeekOnly => "ScheduleWeekOnly", + VendorId => "VendorId", + ApptDate => "ApptDate", + ApptTime => "ApptTime", + DocStatus => "DocStatus", + Pm => "Trade", + ServiceNotes => "ServiceNotes", + ExtraServices => "ExtraServices", + FlagColor => "FlagColor", + _ => canonical + }; + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs new file mode 100644 index 0000000..3067947 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs @@ -0,0 +1,45 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderBoardMutationRules + { + private static readonly HashSet ReadOnlyStatuses = + LifecycleStatusSets.Terminal.Cast().ToHashSet(); + + public static bool IsReadOnly(LifecycleStatus? status) => ReadOnlyStatuses.Contains(status); + + public static bool ShouldBlockStatusChangeWhenPastDue(string field, bool isPastDue) + => field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue; + + /// + /// SHOC rule: Incomplete + specific scheduled date + assignee → Scheduled. + /// Week-only targets do not auto-schedule (same as ). + /// + public static bool ShouldAutoSchedule( + LifecycleStatus? status, + DateTime? scheduledDate, + string? assignTo, + bool? scheduleWeekOnly = null) + => status == LifecycleStatus.Incomplete + && scheduleWeekOnly != true + && scheduledDate.HasValue + && !string.IsNullOrWhiteSpace(assignTo); + + public static bool IsReschedule(DateTime? previousDate, DateTime? newDate) + => previousDate.HasValue + && newDate.HasValue + && previousDate.Value.Date != newDate.Value.Date; + + public static DateOnly? ComputeOriginalWeek(DateTime? scheduledDate) + => scheduledDate.HasValue + ? DateOnly.FromDateTime(GetWeekStartMonday(scheduledDate.Value)) + : null; + + private static DateTime GetWeekStartMonday(DateTime date) + { + var diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7; + return date.Date.AddDays(-diff); + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs new file mode 100644 index 0000000..cb4a2d5 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs @@ -0,0 +1,52 @@ +using Data.SeaHavenIndustries; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderCommentProjection + { + public static WorkOrderCommentDto ToDto( + Comments comment, + IReadOnlyDictionary? displayNames = null) + { + return new WorkOrderCommentDto + { + Id = comment.Id, + AuthorId = comment.UserId, + AuthorName = ResolveAuthorName(comment, displayNames), + Text = comment.Commenttext ?? "", + Time = (comment.CreatedDate ?? DateTime.UtcNow).ToUniversalTime().ToString("o"), + Documents = comment.Documents + }; + } + + public static List ToDtos( + IEnumerable comments, + IReadOnlyDictionary? displayNames = null) + => comments.Select(c => ToDto(c, displayNames)).ToList(); + + public static IEnumerable CollectAuthorUserIds(IEnumerable comments) + => comments + .Where(c => !string.IsNullOrWhiteSpace(c.UserId)) + .Select(c => c.UserId!) + .Distinct(StringComparer.Ordinal); + + private static string? ResolveAuthorName( + Comments comment, + IReadOnlyDictionary? displayNames) + { + if (!string.IsNullOrWhiteSpace(comment.UserId) + && displayNames != null + && displayNames.TryGetValue(comment.UserId, out var name) + && !string.IsNullOrWhiteSpace(name)) + { + return name; + } + + if (!string.IsNullOrWhiteSpace(comment.Commenter)) + return comment.Commenter.Trim(); + + return null; + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs index 873126d..b64d16c 100644 --- a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs +++ b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs @@ -1,55 +1,107 @@ -using Data.SeaHavenIndustries; -using Data.SeaHavenIndustries.Enums; - -namespace SeaHaven.Services.Helpers -{ - public static class WorkOrderDerivedFields - { - /// - /// Past due overlay — never a WorkOrderType. Matches SHOC board design. - /// - public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null) - { - var today = asOf ?? DateOnly.FromDateTime(DateTime.UtcNow); - if (workOrder.ScheduledDate == null) - return false; - - if (LifecycleStatusMapper.IsTerminal(workOrder.LifecycleStatus)) - return false; - - return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today; - } - - /// - /// Sets initial lifecycle on create. New WOs start Incomplete unless auto-scheduled. - /// - public static void SetInitialLifecycleStatus(WorkOrder workOrder) - { - workOrder.LifecycleStatus ??= LifecycleStatus.Incomplete; - ApplyAutoScheduleIfEligible(workOrder); - } - - /// - /// SHOC rule: specific scheduled date + assignee + Incomplete → Scheduled. - /// Week-only targets do not auto-schedule. - /// - public static bool ApplyAutoScheduleIfEligible(WorkOrder workOrder) - { - if (workOrder.LifecycleStatus != LifecycleStatus.Incomplete) - return false; - - if (workOrder.ScheduleWeekOnly == true) - return false; - - if (workOrder.ScheduledDate == null) - return false; - - if (string.IsNullOrWhiteSpace(workOrder.AssignTo)) - return false; - - workOrder.LifecycleStatus = LifecycleStatus.Scheduled; - return true; - } - } -} - \ No newline at end of file +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderDerivedFields + { + /// + /// Past due overlay — never a WorkOrderType. Matches SHOC board design. + /// + public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null) + { + var today = asOf ?? DateOnly.FromDateTime(DateTime.UtcNow); + if (workOrder.ScheduledDate == null) + return false; + + if (LifecycleStatusMapper.IsTerminal(workOrder.LifecycleStatus)) + return false; + + return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today; + } + + /// + /// Board read-model past-due check. Phase 1 compares UTC calendar dates; + /// business/location timezone support is planned for a later phase. + /// + public static bool IsPastDue( + DateTime? scheduledDate, + LifecycleStatus? lifecycleStatus, + DateTime? utcNow = null) + { + if (!scheduledDate.HasValue) + return false; + + if (LifecycleStatusMapper.IsTerminal(lifecycleStatus)) + return false; + + var today = (utcNow ?? DateTime.UtcNow).Date; + return scheduledDate.Value.Date < today; + } + + /// + /// Sets initial lifecycle on create. New WOs start Incomplete unless auto-scheduled. + /// + public static void SetInitialLifecycleStatus(WorkOrder workOrder) + { + workOrder.LifecycleStatus ??= LifecycleStatus.Incomplete; + ApplyAutoScheduleIfEligible(workOrder); + } + + /// + /// SHOC rule: specific scheduled date + assignee + Incomplete → Scheduled. + /// Week-only targets do not auto-schedule. + /// + public static bool ApplyAutoScheduleIfEligible(WorkOrder workOrder) + { + if (workOrder.LifecycleStatus != LifecycleStatus.Incomplete) + return false; + + if (workOrder.ScheduleWeekOnly == true) + return false; + + if (workOrder.ScheduledDate == null) + return false; + + if (string.IsNullOrWhiteSpace(workOrder.AssignTo)) + return false; + + workOrder.LifecycleStatus = LifecycleStatus.Scheduled; + return true; + } + + /// + /// Maps scheduled date to Mon–Fri board column. Phase 1 uses the stored + /// date's day-of-week (UTC); timezone-aware grouping is planned for a later phase. + /// + public static string? GetDayGroup(DateTime? scheduledDate) + { + if (!scheduledDate.HasValue) + return null; + + return scheduledDate.Value.DayOfWeek switch + { + DayOfWeek.Monday => "monday", + DayOfWeek.Tuesday => "tuesday", + DayOfWeek.Wednesday => "wednesday", + DayOfWeek.Thursday => "thursday", + DayOfWeek.Friday => "friday", + _ => null + }; + } + + public static string? FormatApptTime(DateTime? start, DateTime? end) + { + if (!start.HasValue) + return null; + + if (end.HasValue) + return $"{start.Value:HH:mm} – {end.Value:HH:mm}"; + + return start.Value.ToString("HH:mm"); + } + + public static string GetLifecycleStatusLabel(LifecycleStatus? status) + => status.HasValue ? LifecycleStatusMapper.ToFeLabel(status.Value) : ""; + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderFlagColors.cs b/SeaHaven.Services/Helpers/WorkOrderFlagColors.cs new file mode 100644 index 0000000..6892bd8 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderFlagColors.cs @@ -0,0 +1,50 @@ +namespace SeaHaven.Services.Helpers +{ + /// + /// Fixed board flag palette (must match FE FLAG_COLORS). Values stored as canonical #RRGGBB. + /// + public static class WorkOrderFlagColors + { + public static readonly HashSet Allowed = new(StringComparer.OrdinalIgnoreCase) + { + "#EF4444", + "#F97316", + "#F59E0B", + "#EAB308", + "#22C55E", + "#14B8A6", + "#3B82F6", + "#8B5CF6" + }; + + /// + /// Normalizes to # + 6 uppercase hex digits, or null if empty. + /// Returns false if the value is non-empty but not a valid hex color. + /// + public static bool TryNormalize(string? value, out string? canonical) + { + canonical = null; + if (string.IsNullOrWhiteSpace(value)) + return true; + + var trimmed = value.Trim(); + if (trimmed.Length == 6 && trimmed[0] != '#') + trimmed = "#" + trimmed; + + if (trimmed.Length != 7 || trimmed[0] != '#') + return false; + + for (var i = 1; i < 7; i++) + { + if (!Char.IsAsciiHexDigit(trimmed[i])) + return false; + } + + canonical = "#" + trimmed[1..].ToUpperInvariant(); + return true; + } + + public static bool IsAllowed(string? canonical) + => canonical != null && Allowed.Contains(canonical); + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs b/SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs new file mode 100644 index 0000000..cc48e5e --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs @@ -0,0 +1,27 @@ +namespace SeaHaven.Services.Helpers +{ + /// DynamoDB / ingest field mapping shared by Sync and direct ingest. + public static class WorkOrderIngestFieldMapper + { + public static string? MapStatus(string? dynamoStatus) + { + return dynamoStatus switch + { + "new" => "Open", + "assigned" => "Open", + "in_progress" => "In Progress", + "on_hold" => "On Hold", + "completed" => "Done", + "cancelled" => "Cancelled", + "unknown" => "Open", + _ => string.IsNullOrWhiteSpace(dynamoStatus) ? null : "Open" + }; + } + + public static string? MapSeverityToPriority(string? severity) + { + if (string.IsNullOrWhiteSpace(severity)) return null; + return $"Sev {severity}"; + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs b/SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs new file mode 100644 index 0000000..cbacc09 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs @@ -0,0 +1,91 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderMediaProjection + { + public static List ProjectAll( + WorkOrder workOrder, + IEnumerable attachments) + { + var media = new List(); + var attachmentId = -1; + + if (!string.IsNullOrWhiteSpace(workOrder.BeforPhotoAttachment)) + { + media.Add(new WorkOrderMediaFileDto + { + Id = attachmentId--, + Category = WorkOrderMediaCategory.Before, + Url = workOrder.BeforPhotoAttachment, + FileName = ExtractFileName(workOrder.BeforPhotoAttachment), + IsLegacy = true + }); + } + + if (!string.IsNullOrWhiteSpace(workOrder.AfterPhotoAttachment)) + { + media.Add(new WorkOrderMediaFileDto + { + Id = attachmentId--, + Category = WorkOrderMediaCategory.After, + Url = workOrder.AfterPhotoAttachment, + FileName = ExtractFileName(workOrder.AfterPhotoAttachment), + IsLegacy = true + }); + } + + if (!string.IsNullOrWhiteSpace(workOrder.SignOffAttachment)) + { + media.Add(new WorkOrderMediaFileDto + { + Id = attachmentId--, + Category = WorkOrderMediaCategory.Completion, + Url = workOrder.SignOffAttachment, + FileName = ExtractFileName(workOrder.SignOffAttachment), + IsLegacy = true + }); + } + + foreach (var attachment in attachments.Where(a => a.IsDeleted != true)) + { + if (string.IsNullOrWhiteSpace(attachment.Attachments)) + continue; + + media.Add(new WorkOrderMediaFileDto + { + Id = attachment.Id, + Category = attachment.Category ?? WorkOrderMediaCategory.Extra, + Url = attachment.Attachments, + FileName = ExtractFileName(attachment.Attachments), + UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"), + IsLegacy = false + }); + } + + return media; + } + + private static string? ExtractFileName(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return null; + + var lastSlash = url.LastIndexOf('/'); + var segment = lastSlash >= 0 && lastSlash < url.Length - 1 + ? url[(lastSlash + 1)..] + : url; + + try + { + return Uri.UnescapeDataString(segment); + } + catch (Exception) + { + return segment; + } + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs b/SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs new file mode 100644 index 0000000..e8795f1 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs @@ -0,0 +1,35 @@ +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderOperationalWeek + { + public static DateOnly GetMondayOfWeek(DateOnly date) + { + var daysFromMonday = ((int)date.DayOfWeek + 6) % 7; + return date.AddDays(-daysFromMonday); + } + + public static DateOnly GetPreviousOperationalWeekStart(DateTime utcNow) + => GetMondayOfWeek(DateOnly.FromDateTime(utcNow)).AddDays(-7); + + public static DateOnly GetOperationalWeekEnd(DateOnly weekStartMonday) + => weekStartMonday.AddDays(4); + + public static string BuildWeekCorrelationId(DateOnly sourceWeekStart) + => $"week:{sourceWeekStart:yyyy-MM-dd}"; + + /// + /// Returns a validation error message when the week window is invalid; otherwise null. + /// + public static string? ValidateWeekWindow(DateOnly weekStart, DateOnly weekEnd) + { + if (weekEnd < weekStart) + return "weekEnd must be on or after weekStart."; + + var spanDays = weekEnd.DayNumber - weekStart.DayNumber; + if (spanDays > 6) + return "Week window cannot exceed 7 days."; + + return null; + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs b/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs new file mode 100644 index 0000000..d41df2f --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs @@ -0,0 +1,95 @@ +namespace SeaHaven.Services.Helpers +{ + public enum WorkOrderAdvancedSearchDatePreset + { + ThisWeek, + LastWeek, + ThisMonth, + Last3Months, + NextWeek, + NextMonth, + Custom + } + + public record WorkOrderSearchDateRange(DateOnly From, DateOnly To); + + public static class WorkOrderSearchDateRangeResolver + { + public static WorkOrderSearchDateRange Resolve( + WorkOrderAdvancedSearchDatePreset? preset, + DateOnly? dateFrom, + DateOnly? dateTo, + DateOnly? referenceDate = null) + { + var today = referenceDate ?? DateOnly.FromDateTime(DateTime.UtcNow); + var resolvedPreset = preset ?? WorkOrderAdvancedSearchDatePreset.ThisWeek; + + if (resolvedPreset == WorkOrderAdvancedSearchDatePreset.Custom) + { + if (!dateFrom.HasValue || !dateTo.HasValue) + throw new ArgumentException("dateFrom and dateTo are required when datePreset is custom."); + + if (dateTo.Value < dateFrom.Value) + throw new ArgumentException("dateTo must be on or after dateFrom."); + + return new WorkOrderSearchDateRange(dateFrom.Value, dateTo.Value); + } + + return resolvedPreset switch + { + WorkOrderAdvancedSearchDatePreset.ThisWeek => ThisIsoWeek(today), + WorkOrderAdvancedSearchDatePreset.LastWeek => LastIsoWeek(today), + WorkOrderAdvancedSearchDatePreset.ThisMonth => ThisMonth(today), + WorkOrderAdvancedSearchDatePreset.Last3Months => Last3Months(today), + WorkOrderAdvancedSearchDatePreset.NextWeek => NextIsoWeek(today), + WorkOrderAdvancedSearchDatePreset.NextMonth => NextMonth(today), + _ => ThisIsoWeek(today) + }; + } + + public static DateOnly GetIsoWeekStart(DateOnly date) + { + var diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7; + return date.AddDays(-diff); + } + + private static WorkOrderSearchDateRange ThisIsoWeek(DateOnly today) + { + var start = GetIsoWeekStart(today); + return new WorkOrderSearchDateRange(start, start.AddDays(6)); + } + + private static WorkOrderSearchDateRange LastIsoWeek(DateOnly today) + { + var start = GetIsoWeekStart(today).AddDays(-7); + return new WorkOrderSearchDateRange(start, start.AddDays(6)); + } + + private static WorkOrderSearchDateRange NextIsoWeek(DateOnly today) + { + var start = GetIsoWeekStart(today).AddDays(7); + return new WorkOrderSearchDateRange(start, start.AddDays(6)); + } + + private static WorkOrderSearchDateRange ThisMonth(DateOnly today) + { + var start = new DateOnly(today.Year, today.Month, 1); + var end = start.AddMonths(1).AddDays(-1); + return new WorkOrderSearchDateRange(start, end); + } + + private static WorkOrderSearchDateRange NextMonth(DateOnly today) + { + var start = new DateOnly(today.Year, today.Month, 1).AddMonths(1); + var end = start.AddMonths(1).AddDays(-1); + return new WorkOrderSearchDateRange(start, end); + } + + private static WorkOrderSearchDateRange Last3Months(DateOnly today) + { + var end = today; + var start = today.AddMonths(-3); + return new WorkOrderSearchDateRange(start, end); + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderTypeMapper.cs b/SeaHaven.Services/Helpers/WorkOrderTypeMapper.cs index 64c355c..4eda69f 100644 --- a/SeaHaven.Services/Helpers/WorkOrderTypeMapper.cs +++ b/SeaHaven.Services/Helpers/WorkOrderTypeMapper.cs @@ -1,48 +1,47 @@ -using Data.SeaHavenIndustries.Enums; - -namespace SeaHaven.Services.Helpers -{ - public static class WorkOrderTypeMapper - { - /// Board FE types. Overdue is not a type — use . - public static bool TryParseFeLabel(string? label, out WorkOrderType type) - { - type = default; - if (string.IsNullOrWhiteSpace(label)) return false; - - return label.Trim().ToLowerInvariant() switch - { - "pm" => Set(WorkOrderType.PM, out type), - "reactive" => Set(WorkOrderType.Reactive, out type), - "emergency" => Set(WorkOrderType.Emergency, out type), - "add-on" or "addon" => Set(WorkOrderType.AddOn, out type), - "po" => Set(WorkOrderType.PO, out type), - "project" => Set(WorkOrderType.Project, out type), - "inspection" => Set(WorkOrderType.Inspection, out type), - "other" => Set(WorkOrderType.Other, out type), - "overdue" => false, - _ => Enum.TryParse(label, true, out type) - }; - } - - public static string ToFeLabel(WorkOrderType type) => type switch - { - WorkOrderType.PM => "PM", - WorkOrderType.Reactive => "Reactive", - WorkOrderType.Emergency => "Emergency", - WorkOrderType.AddOn => "Add-On", - WorkOrderType.PO => "PO", - WorkOrderType.Project => "Project", - WorkOrderType.Inspection => "Inspection", - WorkOrderType.Other => "Other", - _ => type.ToString() - }; - - private static bool Set(WorkOrderType value, out WorkOrderType type) - { - type = value; - return true; - } - } -} - \ No newline at end of file +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderTypeMapper + { + /// Board FE types. Overdue is not a type — use . + public static bool TryParseFeLabel(string? label, out WorkOrderType type) + { + type = default; + if (string.IsNullOrWhiteSpace(label)) return false; + + return label.Trim().ToLowerInvariant() switch + { + "pm" => Set(WorkOrderType.PM, out type), + "reactive" => Set(WorkOrderType.Reactive, out type), + "emergency" => Set(WorkOrderType.Emergency, out type), + "add-on" or "addon" => Set(WorkOrderType.AddOn, out type), + "po" => Set(WorkOrderType.PO, out type), + "project" => Set(WorkOrderType.Project, out type), + "inspection" => Set(WorkOrderType.Inspection, out type), + "other" => Set(WorkOrderType.Other, out type), + "overdue" => false, + _ => Enum.TryParse(label, true, out type) + }; + } + + public static string ToFeLabel(WorkOrderType type) => type switch + { + WorkOrderType.PM => "PM", + WorkOrderType.Reactive => "Reactive", + WorkOrderType.Emergency => "Emergency", + WorkOrderType.AddOn => "Add-On", + WorkOrderType.PO => "PO", + WorkOrderType.Project => "Project", + WorkOrderType.Inspection => "Inspection", + WorkOrderType.Other => "Other", + _ => type.ToString() + }; + + private static bool Set(WorkOrderType value, out WorkOrderType type) + { + type = value; + return true; + } + } +} diff --git a/SeaHaven.Services/Implementation/LocationService.cs b/SeaHaven.Services/Implementation/LocationService.cs index 7edd2c4..da98b66 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -64,6 +64,20 @@ namespace SeaHaven.Services.Implementation }; } + public async Task> GetSiteOptionsAsync(string? search = null) + { + var rows = await _locationDataService.GetSiteOptionsAsync(search); + return rows + .Select(r => new SiteOptionDTO + { + Id = r.Id, + Code = r.Code, + City = r.City, + State = r.State + }) + .ToList(); + } + public async Task CreateLocationAsync(CreateLocationDTO dto, string userId) { var validationResult = await _createValidator.ValidateAsync(dto); diff --git a/SeaHaven.Services/Implementation/PastDueCacheService.cs b/SeaHaven.Services/Implementation/PastDueCacheService.cs new file mode 100644 index 0000000..33c1646 --- /dev/null +++ b/SeaHaven.Services/Implementation/PastDueCacheService.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class PastDueCacheService : IPastDueCacheService + { + private readonly IWorkOrderDomainJobDataService _dataService; + private readonly ILogger _logger; + + public PastDueCacheService( + IWorkOrderDomainJobDataService dataService, + ILogger logger) + { + _dataService = dataService; + _logger = logger; + } + + public async Task RefreshAsync(CancellationToken cancellationToken = default) + { + var stopwatch = Stopwatch.StartNew(); + var utcNow = DateTime.UtcNow; + + _logger.LogInformation("PastDue cache refresh started at {UtcNow}", utcNow); + + var refreshResult = await _dataService.RefreshPastDueFlagsAsync(utcNow, cancellationToken: cancellationToken); + + stopwatch.Stop(); + + var result = new PastDueCacheJobResult + { + SetCount = refreshResult.SetCount, + ClearedCount = refreshResult.ClearedCount, + ExaminedCount = refreshResult.ExaminedCount, + DurationMs = stopwatch.ElapsedMilliseconds + }; + + _logger.LogInformation( + "PastDue cache refresh completed. Set={SetCount}, Cleared={ClearedCount}, Examined={ExaminedCount}, DurationMs={DurationMs}", + result.SetCount, result.ClearedCount, result.ExaminedCount, result.DurationMs); + + return result; + } + } +} diff --git a/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs b/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs index eefdcbd..a14eed8 100644 --- a/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs +++ b/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs @@ -12,6 +12,7 @@ namespace SeaHaven.Services.Implementation WorkOrderFieldNames.LifecycleStatus, WorkOrderFieldNames.AssignTo, WorkOrderFieldNames.ScheduledDate, + WorkOrderFieldNames.ScheduledStart, WorkOrderFieldNames.ScheduledEnd, WorkOrderFieldNames.TargetWeek, WorkOrderFieldNames.ScheduleWeekOnly, @@ -68,6 +69,7 @@ namespace SeaHaven.Services.Implementation WorkOrderFieldNames.Status => wo.Status, WorkOrderFieldNames.DueDate => wo.DueDate?.ToString("O"), WorkOrderFieldNames.ScheduledDate => wo.ScheduledDate?.ToString("O"), + WorkOrderFieldNames.ScheduledStart => wo.ScheduledStart?.ToString("O"), WorkOrderFieldNames.ScheduledEnd => wo.ScheduledEnd?.ToString("O"), WorkOrderFieldNames.TargetWeek => wo.TargetWeek?.ToString("O"), WorkOrderFieldNames.ScheduleWeekOnly => wo.ScheduleWeekOnly?.ToString(), @@ -105,6 +107,9 @@ namespace SeaHaven.Services.Implementation case WorkOrderFieldNames.ScheduledDate when TryParseDateTime(incomingValue, out var scheduledDate): wo.ScheduledDate = scheduledDate; return true; + case WorkOrderFieldNames.ScheduledStart when TryParseDateTime(incomingValue, out var scheduledStart): + wo.ScheduledStart = scheduledStart; + return true; case WorkOrderFieldNames.ScheduledEnd when TryParseDateTime(incomingValue, out var scheduledEnd): wo.ScheduledEnd = scheduledEnd; return true; diff --git a/SeaHaven.Services/Implementation/VendorOperationsService.cs b/SeaHaven.Services/Implementation/VendorOperationsService.cs new file mode 100644 index 0000000..92d5779 --- /dev/null +++ b/SeaHaven.Services/Implementation/VendorOperationsService.cs @@ -0,0 +1,469 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class VendorOperationsService : IVendorOperationsService + { + private static readonly string[] ValidAvailabilityStatuses = { "Available", "Unavailable", "Unknown" }; + private static readonly string[] InvoiceStatuses = { "Not Submitted", "Processing", "Confirmed", "Rejected" }; + private static readonly string[] PaymentStatuses = { "Unavailable", "Pending", "Scheduled", "Paid", "On Hold" }; + + private readonly IVendorOperationsDataService _data; + private readonly TimeProvider _timeProvider; + + public VendorOperationsService(IVendorOperationsDataService data, TimeProvider timeProvider) + { + _data = data; + _timeProvider = timeProvider; + } + + public DateTime UtcNow => _timeProvider.GetUtcNow().UtcDateTime; + + public async Task GetNotificationsAsync(CancellationToken cancellationToken) + { + var now = UtcNow; + var cutoff = now.AddHours(48); + + var assignedWorkOrderIds = await _data.GetAssignedWorkOrderIdsAsync(cancellationToken); + var candidates = await _data.GetNoVendorWorkOrdersAsync(cutoff, assignedWorkOrderIds, cancellationToken); + + var noVendor = candidates.Select(wo => new NoVendorNotification + { + Id = $"no-vendor-{wo.Id}", + Type = "NoVendor", + Severity = "Medium", + Group = "No Vendor", + Title = "No vendor assigned — 48h to service", + WorkOrderId = wo.Id, + WorkOrderNumber = wo.InternalWONumber ?? wo.WorkerOrderNumber, + TriggeredAt = wo.ScheduledDate!.Value.AddHours(-48), + ScheduledAt = wo.ScheduledDate!.Value, + Action = new NotificationActionDto { Label = "Choose vendor", Href = $"/workorders/{wo.Id}?assignVendor=1" } + }).Cast().ToList(); + + var dispatches = await _data.GetActiveScheduledDispatchesAsync(cancellationToken); + var conflicts = BuildConflictPairs(dispatches) + .Select(pair => (VendorNotificationItem)new VendorConflictNotification + { + Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}", + Type = "VendorConflict", + Severity = "Medium", + Group = "Vendor Conflict", + Title = "Vendor has overlapping appointments", + VendorId = pair.Left.VendorId, + VendorName = pair.Left.VendorName, + WorkOrders = new[] + { + new ConflictWorkOrderDto { WorkOrderId = pair.Left.WorkOrderId, WorkOrderNumber = pair.Left.WorkOrderNumber, Start = pair.Left.Start, End = pair.Left.End }, + new ConflictWorkOrderDto { WorkOrderId = pair.Right.WorkOrderId, WorkOrderNumber = pair.Right.WorkOrderNumber, Start = pair.Right.Start, End = pair.Right.End } + }, + TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start, + Actions = new[] + { + new NotificationActionDto { Label = pair.Left.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Left.WorkOrderId}" }, + new NotificationActionDto { Label = pair.Right.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Right.WorkOrderId}" } + } + }) + .ToList(); + + var items = noVendor.Concat(conflicts).OrderBy(item => item.TriggeredAt).ToList(); + return new VendorNotificationsResult { GeneratedAt = now, Items = items }; + } + + public async Task> GetAvailabilityAsync( + DateTime? start, DateTime? end, int? locationId, string? trade, CancellationToken cancellationToken) + { + var now = UtcNow; + var requestedStart = start ?? now; + var requestedEnd = end ?? requestedStart.AddHours(2); + if (requestedEnd <= requestedStart) + return VendorOperationOutcome.BadRequest("end must be after start"); + + var vendors = await _data.GetActiveVendorsAsync(cancellationToken); + var preferences = locationId.HasValue + ? await _data.GetSitePreferredVendorsAsync(locationId.Value, cancellationToken) + : new List(); + var dispatches = await _data.GetActiveScheduledDispatchesAsync(cancellationToken); + + var result = vendors.Select(vendor => + { + var matchingDispatches = dispatches + .Where(dispatch => dispatch.VendorId == vendor.Id + && WindowsOverlap(requestedStart, requestedEnd, dispatch.Start, dispatch.End)) + .ToList(); + var matchingPreference = preferences + .Where(preference => preference.VendorId == vendor.Id + && (string.IsNullOrWhiteSpace(preference.Trade) + || string.Equals(preference.Trade, trade, StringComparison.OrdinalIgnoreCase))) + .OrderBy(preference => preference.SortOrder) + .FirstOrDefault(); + var signalIsFresh = vendor.AvailabilityUpdatedAt.HasValue + && vendor.AvailabilityUpdatedAt.Value >= now.AddHours(-24); + var status = matchingDispatches.Count > 0 + ? "Unavailable" + : signalIsFresh && ValidAvailabilityStatuses.Contains(vendor.AvailabilityStatus ?? "") + ? vendor.AvailabilityStatus + : "Unknown"; + + return new VendorAvailabilityRow + { + Id = vendor.Id, + CompanyName = vendor.CompanyName, + ContactName = vendor.ContactName, + TradeSpecialties = vendor.TradeSpecialties, + AvailabilityStatus = status, + AvailabilityUpdatedAt = vendor.AvailabilityUpdatedAt, + IsPreferred = matchingPreference != null, + PreferenceRank = matchingPreference?.SortOrder, + Conflicts = matchingDispatches.Select(dispatch => new AvailabilityConflictDto + { + WorkOrderId = dispatch.WorkOrderId, + WorkOrderNumber = dispatch.WorkOrderNumber, + Start = dispatch.Start, + End = dispatch.End + }).ToList() + }; + }) + .OrderBy(vendor => vendor.IsPreferred ? 0 : 1) + .ThenBy(vendor => vendor.PreferenceRank ?? int.MaxValue) + .ThenBy(vendor => vendor.AvailabilityStatus == "Available" ? 0 + : vendor.AvailabilityStatus == "Unknown" ? 1 : 2) + .ThenBy(vendor => vendor.CompanyName) + .ToList(); + + return VendorOperationOutcome.Ok( + new VendorAvailabilityResult { GeneratedAt = now, StaleAfterHours = 24, Data = result }); + } + + public async Task> UpdateAvailabilityAsync( + int vendorId, string? status, string actor, CancellationToken cancellationToken) + { + var normalized = status?.Trim(); + if (normalized is not ("Available" or "Unavailable" or "Unknown")) + return VendorOperationOutcome.BadRequest("status must be Available, Unavailable, or Unknown"); + + var vendor = await _data.GetVendorForUpdateAsync(vendorId, cancellationToken); + if (vendor == null) + return VendorOperationOutcome.NotFound(); + + var now = UtcNow; + var previous = vendor.AvailabilityStatus; + vendor.AvailabilityStatus = normalized; + vendor.AvailabilityUpdatedAt = now; + vendor.LastModificationTime = now; + await _data.StageVendorAuditAsync(new VendorAuditLog + { + VendorId = vendorId, + FieldName = nameof(Vendor.AvailabilityStatus), + OldValue = previous, + NewValue = normalized, + Actor = actor, + CreatedAt = now + }, cancellationToken); + await _data.SaveChangesAsync(cancellationToken); + return VendorOperationOutcome.Ok( + new VendorAvailabilityUpdateResult { Id = vendor.Id, AvailabilityStatus = vendor.AvailabilityStatus, AvailabilityUpdatedAt = vendor.AvailabilityUpdatedAt }); + } + + public async Task> GetSitePreferencesAsync(int locationId, CancellationToken cancellationToken) + { + var preferences = await _data.GetSitePreferredVendorsAsync(locationId, cancellationToken); + return preferences.Select(p => new SitePreferenceRow + { + Id = p.Id, + LocationId = p.LocationId, + VendorId = p.VendorId, + VendorName = p.Vendor!.CompanyName, + Trade = p.Trade, + SortOrder = p.SortOrder, + IsStale = !p.Vendor.IsActive || (p.Vendor.IsDeleted ?? false), + LastModificationTime = p.LastModificationTime, + UpdatedBy = p.UpdatedBy + }).ToList(); + } + + public async Task>> ReplaceSitePreferencesAsync( + int locationId, IReadOnlyList body, string actor, CancellationToken cancellationToken) + { + if (!await _data.LocationExistsAsync(locationId, cancellationToken)) + return VendorOperationOutcome>.NotFound("Location not found"); + if (body.GroupBy(item => new { item.VendorId, Trade = item.Trade?.Trim().ToLowerInvariant() }).Any(group => group.Count() > 1)) + return VendorOperationOutcome>.BadRequest("Duplicate vendor/trade preference"); + + var vendorIds = body.Select(item => item.VendorId).Distinct().ToList(); + var vendors = await _data.GetVendorsByIdsAsync(vendorIds, cancellationToken); + foreach (var item in body) + { + if (!vendors.TryGetValue(item.VendorId, out var vendor) || !vendor.IsActive || (vendor.IsDeleted ?? false)) + return VendorOperationOutcome>.BadRequest($"Vendor {item.VendorId} is inactive or missing"); + if (!string.IsNullOrWhiteSpace(item.Trade) + && !(vendor.TradeSpecialties ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Contains(item.Trade.Trim(), StringComparer.OrdinalIgnoreCase)) + return VendorOperationOutcome>.BadRequest($"Vendor {item.VendorId} does not support trade '{item.Trade}'"); + } + + var now = UtcNow; + var existing = await _data.GetSitePreferredVendorsForReplaceAsync(locationId, cancellationToken); + await _data.RemoveSitePreferredVendorsAsync(existing, cancellationToken); + foreach (var (item, index) in body.Select((item, index) => (item, index))) + { + await _data.AddSitePreferredVendorAsync(new SitePreferredVendor + { + LocationId = locationId, + VendorId = item.VendorId, + Trade = string.IsNullOrWhiteSpace(item.Trade) ? null : item.Trade.Trim(), + SortOrder = item.SortOrder ?? index, + CreatedDate = now, + LastModificationTime = now, + createdby = actor, + UpdatedBy = actor + }, cancellationToken); + } + foreach (var vendorId in existing.Select(p => p.VendorId).Concat(vendorIds).Distinct()) + { + await _data.StageVendorAuditAsync(new VendorAuditLog + { + VendorId = vendorId, + FieldName = $"SitePreference:{locationId}", + OldValue = string.Join(",", existing.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")), + NewValue = string.Join(",", body.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")), + Actor = actor, + CreatedAt = now + }, cancellationToken); + } + await _data.SaveChangesAsync(cancellationToken); + return VendorOperationOutcome>.Ok(await GetSitePreferencesAsync(locationId, cancellationToken)); + } + + public async Task> ReviewDocumentAsync( + int documentId, DocumentReviewRequest body, string actor, CancellationToken cancellationToken) + { + if (body.ReviewStatus is not ("Approved" or "Rejected")) + return VendorOperationOutcome.BadRequest("reviewStatus must be Approved or Rejected"); + if (body.ReviewStatus == "Rejected" && string.IsNullOrWhiteSpace(body.RejectionReason)) + return VendorOperationOutcome.BadRequest("A rejection reason is required"); + + var document = await _data.GetCompletionDocumentForUpdateAsync(documentId, cancellationToken); + if (document == null) + return VendorOperationOutcome.NotFound(); + if (document.ScanStatus != "Passed") + return VendorOperationOutcome.Conflict("A document cannot be approved or reviewed until malware scanning passes"); + + var now = UtcNow; + var previous = document.ReviewStatus; + document.ReviewStatus = body.ReviewStatus; + document.RejectionReason = body.ReviewStatus == "Rejected" ? body.RejectionReason?.Trim() : null; + document.ReviewedAt = now; + document.LastModificationTime = now; + await _data.StageWorkOrderAuditAsync(new WorkOrderAuditLog + { + WorkOrderId = document.WorkOrderId, + UserId = actor, + FieldName = $"Completion Document {document.OriginalFileName}", + OldValue = previous, + NewValue = document.ReviewStatus, + Action = "completion_document_reviewed", + CreatedAt = now + }, cancellationToken); + await _data.SaveChangesAsync(cancellationToken); + return VendorOperationOutcome.Ok( + new DocumentReviewResult { Id = document.Id, ReviewStatus = document.ReviewStatus, RejectionReason = document.RejectionReason, ReviewedAt = document.ReviewedAt }); + } + + public async Task> UpdateCommercialStatusAsync( + int dispatchId, CommercialStatusRequest body, string actor, CancellationToken cancellationToken) + { + if (!InvoiceStatuses.Contains(body.InvoiceStatus)) + return VendorOperationOutcome.BadRequest("Invalid invoice status"); + if (!PaymentStatuses.Contains(body.PaymentStatus)) + return VendorOperationOutcome.BadRequest("Invalid payment status"); + + var dispatch = await _data.GetDispatchForUpdateAsync(dispatchId, cancellationToken); + if (dispatch == null) + return VendorOperationOutcome.NotFound(); + var now = UtcNow; + var oldValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}"; + dispatch.InvoiceStatus = body.InvoiceStatus; + dispatch.PaymentStatus = body.PaymentStatus; + dispatch.InvoiceNumber = string.IsNullOrWhiteSpace(body.InvoiceNumber) ? null : body.InvoiceNumber.Trim(); + dispatch.CommercialStatusUpdatedAt = now; + dispatch.LastModificationTime = now; + await _data.StageWorkOrderAuditAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + UserId = actor, + FieldName = $"Dispatch {dispatch.DispatchNumber} Commercial Status", + OldValue = oldValue, + NewValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}", + Action = "commercial_status_updated", + CreatedAt = now + }, cancellationToken); + await _data.SaveChangesAsync(cancellationToken); + return VendorOperationOutcome.Ok( + new CommercialStatusResult + { + Id = dispatch.Id, + PONumber = dispatch.PONumber, + InvoiceNumber = dispatch.InvoiceNumber, + InvoiceStatus = dispatch.InvoiceStatus, + PaymentStatus = dispatch.PaymentStatus, + CommercialStatusUpdatedAt = dispatch.CommercialStatusUpdatedAt + }); + } + + public async Task> AssignVendorAsync( + int workOrderId, VendorAssignmentRequest body, string actor, CancellationToken cancellationToken) + { + var workOrder = await _data.GetWorkOrderForUpdateAsync(workOrderId, cancellationToken); + if (workOrder == null) + return VendorOperationOutcome.NotFound("Work order not found"); + var vendor = await _data.GetVendorForUpdateAsync(body.VendorId, cancellationToken); + if (vendor == null || !vendor.IsActive || (vendor.IsDeleted ?? false)) + return VendorOperationOutcome.BadRequest("Only an active vendor can be assigned"); + + var start = body.ScheduledStart ?? workOrder.ScheduledStart ?? workOrder.ScheduledDate; + var end = body.ScheduledEnd ?? workOrder.ScheduledEnd ?? start?.AddHours(2); + var activeDispatches = start.HasValue && end.HasValue + ? (await _data.GetActiveScheduledDispatchesAsync(cancellationToken)) + .Where(dispatch => dispatch.VendorId == vendor.Id && dispatch.WorkOrderId != workOrderId).ToList() + : new List(); + var conflicts = start.HasValue && end.HasValue + ? activeDispatches.Where(dispatch => WindowsOverlap(start.Value, end.Value, dispatch.Start, dispatch.End)).ToList() + : new List(); + + var dispatch = await _data.GetExistingActiveDispatchForWorkOrderAsync(workOrderId, cancellationToken); + var now = UtcNow; + var previousVendorId = dispatch?.VendorId; + if (dispatch == null) + { + dispatch = new Dispatch + { + WorkOrderId = workOrderId, + VendorId = vendor.Id, + DispatchNumber = await _data.GenerateNextDispatchNumberAsync(cancellationToken), + PONumber = await _data.GenerateNextPONumberAsync(cancellationToken), + Status = "Sent", + ScheduledDate = start, + EstimatedArrivalAt = body.EstimatedArrivalAt, + EtaManualOverride = body.EtaManualOverride, + DispatchedAt = now, + CreatedDate = now, + createdby = actor + }; + await _data.AddDispatchAsync(dispatch, cancellationToken); + } + else + { + dispatch.VendorId = vendor.Id; + dispatch.ScheduledDate = start; + dispatch.EstimatedArrivalAt = body.EstimatedArrivalAt; + dispatch.EtaManualOverride = body.EtaManualOverride; + dispatch.LastModificationTime = now; + } + + workOrder.ScheduledStart = body.ScheduledStart ?? workOrder.ScheduledStart; + workOrder.ScheduledEnd = body.ScheduledEnd ?? workOrder.ScheduledEnd; + workOrder.ScheduledDate = start ?? workOrder.ScheduledDate; + workOrder.LastModificationTime = now; + await _data.StageWorkOrderAuditAsync(new WorkOrderAuditLog + { + WorkOrderId = workOrderId, + UserId = actor, + FieldName = "Vendor Assignment", + OldValue = previousVendorId?.ToString(), + NewValue = vendor.Id.ToString(), + Action = previousVendorId.HasValue ? "vendor_assignment_changed" : "vendor_assigned", + CreatedAt = now + }, cancellationToken); + await _data.SaveChangesAsync(cancellationToken); + + return VendorOperationOutcome.Ok(new VendorAssignmentResult + { + Id = dispatch.Id, + VendorId = dispatch.VendorId, + VendorName = vendor.CompanyName, + EstimatedArrivalAt = dispatch.EstimatedArrivalAt, + EtaManualOverride = dispatch.EtaManualOverride, + AvailabilityStatus = conflicts.Count > 0 ? "Unavailable" + : vendor.AvailabilityUpdatedAt >= now.AddHours(-24) ? vendor.AvailabilityStatus : "Unknown", + Conflicts = conflicts.Select(conflict => new AvailabilityConflictDto + { + WorkOrderId = conflict.WorkOrderId, + WorkOrderNumber = conflict.WorkOrderNumber, + Start = conflict.Start, + End = conflict.End + }).ToList() + }); + } + + public async Task> GetInsightRowsAsync( + DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId, CancellationToken cancellationToken) + { + var dispatches = await _data.GetInsightDispatchDataAsync(from, to, locationId, trade, vendorId, cancellationToken); + return dispatches.GroupBy(dispatch => dispatch.VendorId) + .Select(group => + { + var completed = group.Where(dispatch => dispatch.Status is "Completed" or "Verified").ToList(); + var canceled = group.Count(dispatch => dispatch.Status is "Cancelled" or "Canceled" or "Refused"); + var acknowledgedWithSchedule = group.Where(dispatch => dispatch.AcknowledgedAt.HasValue && dispatch.ScheduledDate.HasValue).ToList(); + var completedWithDue = completed.Where(dispatch => dispatch.CompletedDate.HasValue && dispatch.DueDate != null).ToList(); + var cycleRows = completed.Where(dispatch => dispatch.CompletedDate.HasValue && (dispatch.DispatchedAt ?? dispatch.CreatedDate).HasValue).ToList(); + return new VendorInsightRow + { + VendorId = group.Key, + Vendor = group.First().VendorName ?? $"Vendor {group.Key}", + CompletedJobs = completed.Count, + CancellationRefusalRate = Rate(canceled, group.Count()), + OnTimeArrivalRate = Rate( + acknowledgedWithSchedule.Count(dispatch => dispatch.AcknowledgedAt <= dispatch.ScheduledDate), + acknowledgedWithSchedule.Count), + OnTimeCompletionRate = Rate( + completedWithDue.Count(dispatch => dispatch.CompletedDate <= dispatch.DueDate), + completedWithDue.Count), + AverageCycleHours = cycleRows.Count == 0 ? null : cycleRows.Average(dispatch => + (dispatch.CompletedDate!.Value - (dispatch.DispatchedAt ?? dispatch.CreatedDate)!.Value).TotalHours) + }; + }) + .OrderBy(row => row.Vendor) + .ToList(); + } + + // Group by vendor so cross-vendor intervals are never compared, then sweep the + // start-sorted windows within a vendor. Every overlapping pair is still emitted; + // output is O(n log n + p) where p is the number of emitted pairs, so the worst + // case stays quadratic only when a single vendor has many mutually-overlapping + // windows, which is inherent to enumerating all such pairs. + private static IEnumerable<(ScheduledDispatchReadModel Left, ScheduledDispatchReadModel Right)> BuildConflictPairs( + IReadOnlyList dispatches) + { + foreach (var group in dispatches.GroupBy(dispatch => dispatch.VendorId)) + { + var sorted = group.OrderBy(dispatch => dispatch.Start).ToList(); + for (var leftIndex = 0; leftIndex < sorted.Count; leftIndex++) + { + var left = sorted[leftIndex]; + for (var rightIndex = leftIndex + 1; rightIndex < sorted.Count; rightIndex++) + { + var right = sorted[rightIndex]; + // Sorted by start: once a window starts at or after left's end, none that follow can overlap it. + if (right.Start >= left.End) + break; + if (left.WorkOrderId != right.WorkOrderId) + yield return (left, right); + } + } + } + } + + private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd) + => leftStart < rightEnd && rightStart < leftEnd; + + private static decimal Rate(int numerator, int denominator) + => denominator == 0 ? 0 : Math.Round((decimal)numerator / denominator, 4); + } +} diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 470eb98..70d1ddb 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; @@ -9,14 +10,22 @@ namespace SeaHaven.Services.Implementation { public class VendorPortalService : IVendorPortalService { + private static readonly HashSet AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase) + { + "application/pdf", "image/jpeg", "image/jpg", "image/png" + }; + private readonly IVendorPortalTokenService _tokens; private readonly IDispatchDataService _dispatchData; private readonly IUpliftDataService _upliftData; private readonly ICommentDataService _commentData; private readonly IUserDataService _userData; private readonly IEmailSender _emailSender; + private readonly IVendorDocumentDataService _documentData; + private readonly IVendorDocumentStoragePort _documentStorage; private readonly FrontendOptions _frontendOptions; private readonly ApprovalsOptions _approvalsOptions; + private readonly VendorDocumentsOptions _documentOptions; public VendorPortalService( IVendorPortalTokenService tokens, @@ -25,8 +34,11 @@ namespace SeaHaven.Services.Implementation ICommentDataService commentData, IUserDataService userData, IEmailSender emailSender, + IVendorDocumentDataService documentData, + IVendorDocumentStoragePort documentStorage, IOptions frontendOptions, - IOptions approvalsOptions) + IOptions approvalsOptions, + IOptions documentOptions) { _tokens = tokens; _dispatchData = dispatchData; @@ -34,8 +46,11 @@ namespace SeaHaven.Services.Implementation _commentData = commentData; _userData = userData; _emailSender = emailSender; + _documentData = documentData; + _documentStorage = documentStorage; _frontendOptions = frontendOptions.Value; _approvalsOptions = approvalsOptions.Value; + _documentOptions = documentOptions.Value; } public async Task ResolveSessionAsync(string? token, CancellationToken cancellationToken) @@ -494,6 +509,146 @@ namespace SeaHaven.Services.Implementation return new CancelUpliftResultDTO { Id = req.Id, Status = req.Status }; } + public async Task UploadCompletionDocumentAsync( + VendorPortalSession session, int dispatchId, IFormFile file, CancellationToken cancellationToken) + { + if (file is null || file.Length == 0) + { + throw new InvalidOperationException("A completion document file is required."); + } + + if (file.Length > _documentOptions.MaxSizeBytes) + { + throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size."); + } + + var contentType = (file.ContentType ?? string.Empty).Trim(); + if (!AllowedContentTypes.Contains(contentType)) + { + throw new InvalidOperationException("Only PDF, JPG, and PNG completion documents are accepted."); + } + + using var buffer = new MemoryStream(); + await file.CopyToAsync(buffer, cancellationToken); + var bytes = buffer.ToArray(); + + if (!MatchesSignature(contentType, bytes)) + { + throw new InvalidOperationException("The uploaded file signature does not match its declared content type."); + } + + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken); + if (dispatch == null) + { + throw new KeyNotFoundException("Dispatch not found"); + } + + var latest = await _documentData.GetLatestForDispatchAsync(dispatchId, cancellationToken); + var version = (latest?.Version ?? 0) + 1; + var storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}"; + + var now = DateTime.UtcNow; + var document = new VendorCompletionDocument + { + VendorId = session.Id, + DispatchId = dispatchId, + WorkOrderId = dispatch.WorkOrderId ?? 0, + OriginalFileName = Path.GetFileName(file.FileName), + StoredFileName = storedFileName, + ContentType = contentType, + SizeBytes = bytes.Length, + ScanStatus = "Pending", + ReviewStatus = "Processing", + Version = version, + ReplacesDocumentId = latest?.Id, + CreatedDate = now + }; + + await _documentData.AddAsync(document, cancellationToken); + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + DispatchId = dispatchId, + FieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document", + OldValue = latest == null ? null : $"v{latest.Version}", + NewValue = $"v{version}", + Action = "vendor_completion_document_uploaded", + ActorType = "vendor", + CreatedAt = now + }, cancellationToken); + await _documentData.SaveChangesAsync(cancellationToken); + + using var stored = new MemoryStream(bytes); + await _documentStorage.SaveAsync(session.Id, dispatchId, storedFileName, stored, cancellationToken); + + return new UploadCompletionDocumentResultDTO + { + Id = document.Id, + Version = document.Version, + ScanStatus = document.ScanStatus, + ReviewStatus = document.ReviewStatus + }; + } + + public async Task DownloadCompletionDocumentAsync( + VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken) + { + var document = await _documentData.GetForVendorDispatchAsync(documentId, dispatchId, session.Id, cancellationToken); + if (document == null) + { + return DownloadCompletionDocumentResultDTO.NotFound(); + } + + if (!string.Equals(document.ScanStatus, "Passed", StringComparison.OrdinalIgnoreCase)) + { + return DownloadCompletionDocumentResultDTO.Locked(); + } + + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken); + if (dispatch != null && (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")) + { + return DownloadCompletionDocumentResultDTO.Locked(); + } + + var content = _documentStorage.OpenRead(session.Id, dispatchId, document.StoredFileName); + return DownloadCompletionDocumentResultDTO.Ok(content, document.ContentType, document.OriginalFileName); + } + + private static bool MatchesSignature(string contentType, byte[] bytes) + { + if (bytes.Length == 0) + { + return false; + } + + if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)) + { + return bytes.Length >= 4 + && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46; // %PDF + } + + if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase)) + { + return bytes.Length >= 8 + && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 + && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A; + } + + if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) + || contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase)) + { + return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; + } + + return false; + } + + private static string GetSafeExtension(string fileName) + { + var extension = Path.GetExtension(fileName); + return string.IsNullOrWhiteSpace(extension) ? string.Empty : extension; + } + private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, VendorPortalSession session, CancellationToken cancellationToken) { try diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs new file mode 100644 index 0000000..285ec9e --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -0,0 +1,63 @@ +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderAdvancedSearchService : IWorkOrderAdvancedSearchService + { + private const int DefaultPageSize = 50; + private const int MaxPageSize = 100; + + private readonly IWorkOrderAdvancedSearchDataService _searchDataService; + + public WorkOrderAdvancedSearchService(IWorkOrderAdvancedSearchDataService searchDataService) + { + _searchDataService = searchDataService; + } + + public async Task> SearchAsync( + WorkOrderAdvancedSearchQueryDto query, + string? currentUserId) + { + var page = query.Page < 1 ? 1 : query.Page; + var pageSize = query.PageSize < 1 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize); + + var dateRange = WorkOrderSearchDateRangeResolver.Resolve( + query.DatePreset, + query.DateFrom, + query.DateTo); + + var dataQuery = new WorkOrderAdvancedSearchQuery( + WorkOrderBoardSearchFilter.NormalizeSearch(query.Search), + dateRange.From, + dateRange.To, + query.Sites, + query.Types, + query.Dispatchers, + query.Statuses, + query.PmTypes, + query.VendorIds, + query.DocStatuses, + query.MyWorkOrders, + currentUserId, + page, + pageSize, + query.SortBy ?? "scheduledDate", + query.SortDir ?? "asc"); + + var result = await _searchDataService.SearchAsync(dataQuery); + var utcNow = DateTime.UtcNow; + + return new PagedResult + { + Items = result.Rows.Select(r => WorkOrderBoardService.MapRawRow(r, utcNow)).ToList(), + TotalCount = result.TotalCount, + Page = page, + PageSize = pageSize + }; + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs index cfb3476..aa4a171 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs @@ -29,6 +29,29 @@ namespace SeaHaven.Services.Implementation => LogAsync(workOrderId, AuditActionType.SyncRejected, AuditEventType.Sync, AuditActorType.Sync, fieldName, oldValue, newValue); + public Task LogWeekRolledAsync(int workOrderId, int oldCarriedOver, int newCarriedOver, string correlationId) + => LogAsync( + workOrderId, + AuditActionType.WeekRolled, + AuditEventType.System, + AuditActorType.System, + "CarriedOver", + oldCarriedOver.ToString(), + newCarriedOver.ToString(), + correlationId: correlationId); + + public Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null) + => StageAuditAsync(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId); + + public Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId) + => StageAuditAsync(workOrderId, AuditActionType.StatusChanged, WorkOrderFieldNames.LifecycleStatus, oldStatus, newStatus, actorId); + + public Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId) + => StageAuditAsync(workOrderId, AuditActionType.AssignmentChanged, WorkOrderFieldNames.AssignTo, oldValue, newValue, actorId); + + public Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber) + => StageAuditAsync(workOrderId, AuditActionType.Create, "WorkOrder", null, woNumber, actorId); + public async Task LogAsync( int workOrderId, AuditActionType action, @@ -40,6 +63,51 @@ namespace SeaHaven.Services.Implementation string? actorId = null, int? dispatchId = null, string? correlationId = null) + { + Enqueue(workOrderId, action, eventType, actorType, fieldName, oldValue, newValue, actorId, dispatchId, correlationId); + + if (eventType == AuditEventType.Manual && + (action == AuditActionType.FieldChanged || + action == AuditActionType.StatusChanged || + action == AuditActionType.AssignmentChanged)) + { + await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId); + } + + await _auditData.PersistAsync(CancellationToken.None); + } + + private async Task StageAuditAsync( + int workOrderId, + AuditActionType action, + string fieldName, + string? oldValue, + string? newValue, + string? actorId, + int? dispatchId = null) + { + Enqueue(workOrderId, action, AuditEventType.Manual, AuditActorType.Dispatcher, + fieldName, oldValue, newValue, actorId, dispatchId); + + if (action == AuditActionType.FieldChanged || + action == AuditActionType.StatusChanged || + action == AuditActionType.AssignmentChanged) + { + await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId); + } + } + + private void Enqueue( + int workOrderId, + AuditActionType action, + AuditEventType eventType, + AuditActorType actorType, + string fieldName, + string? oldValue, + string? newValue, + string? actorId, + int? dispatchId = null, + string? correlationId = null) { _auditData.EnqueueAuditLog(new WorkOrderAuditLog { @@ -55,16 +123,6 @@ namespace SeaHaven.Services.Implementation CorrelationId = correlationId, CreatedAt = DateTime.UtcNow }); - - if (eventType == AuditEventType.Manual && - (action == AuditActionType.FieldChanged || - action == AuditActionType.StatusChanged || - action == AuditActionType.AssignmentChanged)) - { - await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId); - } - - await _auditData.PersistAsync(CancellationToken.None); } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs new file mode 100644 index 0000000..7902978 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs @@ -0,0 +1,55 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderBoardCancelService : IWorkOrderBoardCancelService + { + private readonly IWorkOrderBoardMutationDataService _mutationData; + private readonly IWorkOrderBoardService _boardService; + private readonly IWorkOrderAuditService _auditService; + + public WorkOrderBoardCancelService( + IWorkOrderBoardMutationDataService mutationData, + IWorkOrderBoardService boardService, + IWorkOrderAuditService auditService) + { + _mutationData = mutationData; + _boardService = boardService; + _auditService = auditService; + } + + public async Task CancelAsync(int workOrderId, string? actorId) + { + var workOrder = await _mutationData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); + + if (workOrder == null) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + if (workOrder.LifecycleStatus == LifecycleStatus.Canceled) + { + var existing = await _boardService.GetBoardRowAsync(workOrderId); + return existing ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + } + + if (workOrder.LifecycleStatus == LifecycleStatus.Completed) + throw new WorkOrderBoardValidationException("CancelNotAllowed", "Work order cannot be canceled in its current status."); + + var oldStatus = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status ?? ""; + workOrder.LifecycleStatus = LifecycleStatus.Canceled; + workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Canceled); + if (workOrder.LegacyStatus == null && workOrder.Status != null) + workOrder.LegacyStatus = workOrder.Status; + + await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId); + await _mutationData.SaveAsync(CancellationToken.None); + + var row = await _boardService.GetBoardRowAsync(workOrderId); + return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs new file mode 100644 index 0000000..b7ad1ee --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -0,0 +1,305 @@ +using System.Text.Json; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentValidation; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.Validation; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderBoardCreateService : IWorkOrderBoardCreateService + { + private readonly IWorkOrderBoardDataService _boardDataService; + private readonly IWorkOrderBoardMutationDataService _mutationData; + private readonly IWorkOrderBoardService _boardService; + private readonly IWorkOrderAuditService _auditService; + private readonly IWorkOrderBoardCreateValidation _validator; + + public WorkOrderBoardCreateService( + IWorkOrderBoardDataService boardDataService, + IWorkOrderBoardMutationDataService mutationData, + IWorkOrderBoardService boardService, + IWorkOrderAuditService auditService, + IWorkOrderBoardCreateValidation validator) + { + _boardDataService = boardDataService; + _mutationData = mutationData; + _boardService = boardService; + _auditService = auditService; + _validator = validator; + } + + public async Task CreateAsync(WorkOrderBoardCreateRequestDto request, string? actorId) + { + var validationResult = await _validator.ValidateAsync(request); + if (!validationResult.IsValid) + throw new ValidationException(validationResult.Errors); + + var woNumber = await ResolveWoNumberAsync(request.WoNumber); + var siteCode = request.SiteCode!.Trim(); + var primaryService = ResolvePrimaryService(request); + var extraServicesJson = SerializeExtraServices(request.ExtraServices); + var serviceNotes = TrimOrNull(request.ServiceNotes); + var pocName = TrimOrNull(request.PocName); + var pocPhone = TrimOrNull(request.PocPhone); + var pocNotes = TrimOrNull(request.PocNotes); + var techPhone = TrimOrNull(request.TechPhone); + var vendorNotes = TrimOrNull(request.VendorNotes); + var hasVendorDispatch = request.VendorId.HasValue + || request.ApptDate.HasValue + || !string.IsNullOrWhiteSpace(request.ApptTime); + + if (request.VendorId.HasValue && request.VendorId.Value > 0) + { + if (!await _mutationData.VendorExistsAsync(request.VendorId.Value, CancellationToken.None)) + throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist."); + } + + var workOrder = new WorkOrder + { + InternalWONumber = woNumber, + WorkerOrderNumber = woNumber, + WorkOrderType = request.WorkOrderType, + SiteCode = siteCode, + Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), + Trade = primaryService, + ExtraServices = extraServicesJson, + ServiceNotes = serviceNotes, + PocName = pocName, + PocPhone = pocPhone, + PocNotes = pocNotes, + LocationId = request.LocationId, + DueDate = request.DueDate?.Date, + AssignTo = string.IsNullOrWhiteSpace(request.AssignTo) ? null : request.AssignTo.Trim(), + TargetWeek = request.TargetWeek, + ScheduleWeekOnly = request.ScheduleWeekOnly, + DocStatus = request.DocStatus, + createdby = actorId, + CreatedDate = DateTime.UtcNow, + istemplate = false + }; + + if (!hasVendorDispatch) + { + workOrder.TechPhone = techPhone; + workOrder.VendorNotes = vendorNotes; + } + + WorkOrderBoardFieldMutations.SetInitialLifecycleStatus(workOrder); + + var changes = new List + { + WorkOrderBoardFieldMutations.BoardFieldChange.Changed("InternalWONumber", "", woNumber), + WorkOrderBoardFieldMutations.BoardFieldChange.Changed("WorkOrderType", "", request.WorkOrderType.ToString()), + WorkOrderBoardFieldMutations.BoardFieldChange.Changed("SiteCode", "", siteCode), + WorkOrderBoardFieldMutations.BoardFieldChange.Changed("LifecycleStatus", "", LifecycleStatus.Incomplete.ToString()) + }; + + if (workOrder.Description != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Description", "", workOrder.Description)); + if (workOrder.Trade != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Trade", "", workOrder.Trade)); + if (workOrder.ExtraServices != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ExtraServices", "", workOrder.ExtraServices)); + if (workOrder.ServiceNotes != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ServiceNotes", "", workOrder.ServiceNotes)); + if (workOrder.PocName != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocName", "", workOrder.PocName)); + if (workOrder.PocPhone != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocPhone", "", workOrder.PocPhone)); + if (workOrder.PocNotes != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocNotes", "", workOrder.PocNotes)); + if (workOrder.TechPhone != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TechPhone", "", workOrder.TechPhone)); + if (workOrder.VendorNotes != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorNotes", "", workOrder.VendorNotes)); + if (workOrder.LocationId.HasValue) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("LocationId", "", workOrder.LocationId.Value.ToString())); + if (workOrder.DueDate.HasValue) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("DueDate", "", workOrder.DueDate.Value.ToString("yyyy-MM-dd"))); + if (workOrder.TargetWeek.HasValue) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TargetWeek", "", workOrder.TargetWeek.Value.ToString("yyyy-MM-dd"))); + if (workOrder.ScheduleWeekOnly.HasValue) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ScheduleWeekOnly", "", workOrder.ScheduleWeekOnly.Value.ToString())); + if (workOrder.DocStatus.HasValue) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("DocStatus", "", workOrder.DocStatus.Value.ToString())); + + if (!string.IsNullOrWhiteSpace(request.AssignTo)) + changes.AddRange(WorkOrderBoardFieldMutations.ApplyAssignTo(workOrder, request.AssignTo)); + else if (!string.IsNullOrWhiteSpace(workOrder.AssignTo)) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.AssignmentChanged("AssignTo", "", workOrder.AssignTo)); + + if (request.ScheduledDate.HasValue) + changes.AddRange(WorkOrderBoardFieldMutations.ApplyScheduledDate(workOrder, request.ScheduledDate.Value.Date)); + + Dispatch? dispatch = null; + if (hasVendorDispatch) + { + if (!request.VendorId.HasValue || request.VendorId.Value <= 0) + throw new WorkOrderBoardValidationException("DispatchRequired", "vendorId is required when setting appointment fields."); + + dispatch = new Dispatch + { + VendorId = request.VendorId.Value, + Status = "Pending", + CreatedDate = DateTime.UtcNow, + ScheduledDate = request.ApptDate?.Date, + TechPhone = techPhone, + VendorNotes = vendorNotes + }; + _mutationData.TrackNewDispatch(dispatch); + workOrder.PrimaryDispatch = dispatch; + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorId", "", request.VendorId.Value.ToString())); + + if (request.ApptDate.HasValue) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ApptDate", "", request.ApptDate.Value.ToString("yyyy-MM-dd"))); + + if (!string.IsNullOrWhiteSpace(request.ApptTime)) + { + var apptChange = WorkOrderBoardFieldMutations.ApplyApptTime(workOrder, dispatch, request.ApptTime); + if (apptChange.HasChanged) + changes.Add(apptChange); + } + + if (techPhone != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TechPhone", "", techPhone)); + if (vendorNotes != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorNotes", "", vendorNotes)); + } + + // Two SaveChanges are required so generated WO/dispatch IDs can backfill FKs and + // audit/lock/contact rows. A transaction keeps the create atomic across both saves. + await _mutationData.ExecuteTransactionalAsync(async ct => + { + _mutationData.TrackNewWorkOrder(workOrder); + var firstOutcome = await _mutationData.SaveAsync(ct); + + if (dispatch != null && workOrder.PrimaryDispatchId == null) + { + workOrder.PrimaryDispatchId = dispatch.Id; + dispatch.WorkOrderId = workOrder.Id; + } + + + await _auditService.StageCreatedAsync(workOrder.Id, actorId, woNumber); + await StageChangesAsync(workOrder.Id, changes, actorId, dispatch); + + if (request.PocContactId.HasValue) + { + _mutationData.TrackWorkOrderContact(workOrder.Id, request.PocContactId.Value, + string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim()); + } + + await _mutationData.SaveAsync(ct); + }, CancellationToken.None); + + var row = await _boardService.GetBoardRowAsync(workOrder.Id); + return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded."); + } + + private static string? ResolvePrimaryService(WorkOrderBoardCreateRequestDto request) + { + if (!string.IsNullOrWhiteSpace(request.PrimaryService)) + return request.PrimaryService.Trim(); + if (!string.IsNullOrWhiteSpace(request.Trade)) + return request.Trade.Trim(); + return null; + } + + private static string? SerializeExtraServices(List? extras) + { + if (extras == null || extras.Count == 0) + return null; + + var normalized = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in extras) + { + if (string.IsNullOrWhiteSpace(item)) + continue; + var trimmed = item.Trim(); + if (trimmed.Length > 128) + throw new WorkOrderBoardValidationException( + "InvalidValue", + "Each extra service must be at most 128 characters."); + if (!seen.Add(trimmed)) + continue; + normalized.Add(trimmed); + } + + if (normalized.Count == 0) + return null; + + var json = JsonSerializer.Serialize(normalized); + if (json.Length > 2000) + throw new WorkOrderBoardValidationException( + "InvalidValue", + "extraServices must serialize to at most 2000 characters."); + + return json; + } + + private static string? TrimOrNull(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private async Task ResolveWoNumberAsync(string? requested) + { + if (!string.IsNullOrWhiteSpace(requested)) + { + if (!WorkOrderNumberNormalizer.TryNormalize(requested, out var normalized, out var error)) + throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number."); + + if (await _boardDataService.InternalWoNumberExistsAsync(normalized, 0)) + throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists."); + + return normalized; + } + + for (var attempt = 0; attempt < 20; attempt++) + { + var candidate = await GenerateNextSequentialWoNumberAsync(); + if (!await _boardDataService.InternalWoNumberExistsAsync(candidate, 0)) + return candidate; + } + + throw new WorkOrderBoardValidationException("WoNumberGenerationFailed", "Could not generate a unique WO number."); + } + + private async Task GenerateNextSequentialWoNumberAsync() + { + var maxId = await _mutationData.GetMaxWorkOrderIdAsync(CancellationToken.None); + var seed = Math.Max(maxId + 1, 1); + if (!WorkOrderNumberNormalizer.TryNormalize(seed.ToString(), out var normalized, out _)) + normalized = seed.ToString().PadLeft(11, '0'); + return normalized; + } + + private async Task StageChangesAsync( + int workOrderId, + IEnumerable changes, + string? actorId, + Dispatch? dispatch) + { + foreach (var change in changes.Where(c => c.HasChanged)) + { + var dispatchId = change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null); + switch (change.Action) + { + case AuditActionType.StatusChanged: + await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); + break; + case AuditActionType.AssignmentChanged: + await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); + break; + default: + await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue ?? "", change.NewValue, actorId, dispatchId); + break; + } + } + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs new file mode 100644 index 0000000..e381f23 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -0,0 +1,141 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderBoardService : IWorkOrderBoardService + { + private readonly IWorkOrderBoardDataService _boardDataService; + + public WorkOrderBoardService(IWorkOrderBoardDataService boardDataService) + { + _boardDataService = boardDataService; + } + + public async Task GetBoardAsync( + WorkOrderBoardQueryDto query, + string? currentUserId) + { + var weekStart = query.WeekStart; + var weekEnd = query.WeekEnd ?? weekStart.AddDays(4); + + var validationError = WorkOrderOperationalWeek.ValidateWeekWindow(weekStart, weekEnd); + if (validationError != null) + throw new ArgumentException(validationError); + + var dataQuery = new WorkOrderBoardQuery( + weekStart, + weekEnd, + query.Dispatchers, + query.MyWorkOrders, + query.Types, + query.Search, + currentUserId); + + var result = await _boardDataService.GetBoardRowsAsync(dataQuery); + var utcNow = DateTime.UtcNow; + + return new WorkOrderBoardResponseDto + { + WeekStart = weekStart, + WeekEnd = weekEnd, + Counts = new WorkOrderBoardCountsDto + { + Total = result.ScheduledTotalBeforeSearch, + Returned = result.ScheduledRows.Count + }, + Scheduled = result.ScheduledRows.Select(r => MapRawRow(r, utcNow)).ToList(), + Unscheduled = result.UnscheduledRows.Select(r => MapRawRow(r, utcNow)).ToList() + }; + } + + public async Task> GetDispatcherLookupsAsync() + { + var rows = await _boardDataService.GetDispatcherLookupsAsync(); + return rows.Select(r => new DispatcherLookupDto + { + Id = r.Id, + Name = FormatName(r.FirstName, r.LastName), + Initials = r.Initials, + Color = r.Color + }).ToList(); + } + + public async Task GetBoardRowAsync(int workOrderId) + { + var raw = await _boardDataService.GetBoardRowByIdAsync(workOrderId); + return raw == null ? null : MapRawRow(raw, DateTime.UtcNow); + } + + public static WorkOrderBoardRowDto MapRawRow(WorkOrderBoardRawRow row, DateTime utcNow) + { + // Appt column: date prefers dispatch appointment (vendor slot), then WO ScheduledDate. + // Time prefers WO ScheduledStart/End (dispatcher window), then dispatch datetime. + var apptStart = row.ScheduledStart ?? row.DispatchApptDate; + var apptEnd = row.ScheduledEnd; + + return new WorkOrderBoardRowDto + { + Id = row.Id, + WoNumber = row.InternalWONumber, + RescheduleCount = row.RescheduleCount, + CarriedOver = row.CarriedOver, + WorkOrderType = row.WorkOrderType, + IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow), + SiteCode = row.SiteCode, + LocationName = row.LocationName, + PocName = row.PocName, + PocPhone = row.PocPhone, + PocNotes = row.PocNotes, + LifecycleStatus = row.LifecycleStatus, + LifecycleStatusLabel = WorkOrderDerivedFields.GetLifecycleStatusLabel(row.LifecycleStatus), + LegacyStatus = row.LegacyStatus, + DispatcherId = row.AssignTo, + DispatcherName = FormatName(row.DispatcherFirstName, row.DispatcherLastName), + Initials = row.Initials, + Color = row.Color, + DueDate = row.DueDate, + ScheduledDate = row.ScheduledDate, + TargetWeek = row.TargetWeek, + ScheduleWeekOnly = row.ScheduleWeekOnly, + DayGroup = WorkOrderDerivedFields.GetDayGroup(row.ScheduledDate), + VendorId = row.VendorId, + VendorName = row.VendorName, + TechName = row.TechName, + TechPhone = row.TechPhone, + ApptDate = row.DispatchApptDate ?? row.ScheduledDate, + ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd), + Pm = row.Trade ?? row.Problem, + ServiceNotes = row.ServiceNotes, + ExtraServices = ParseExtraServices(row.ExtraServices), + DocStatus = row.DocStatus, + FlagColor = row.FlagColor, + PrimaryDispatchId = row.PrimaryDispatchId, + RowVersion = row.RowVersion, + DispatchRowVersion = row.DispatchRowVersion + }; + } + + private static string FormatName(string? firstName, string? lastName) + => $"{firstName ?? ""} {lastName ?? ""}".Trim(); + + private static List? ParseExtraServices(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + var list = System.Text.Json.JsonSerializer.Deserialize>(json); + return list is { Count: > 0 } ? list : null; + } + catch (System.Text.Json.JsonException) + { + return null; + } + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs new file mode 100644 index 0000000..7e73a1b --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -0,0 +1,657 @@ +using System.Text.Json; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Constants; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderBoardUpdateService : IWorkOrderBoardUpdateService + { + private readonly IWorkOrderBoardDataService _boardDataService; + private readonly IWorkOrderBoardMutationDataService _mutationData; + private readonly IWorkOrderAuditService _auditService; + + public WorkOrderBoardUpdateService( + IWorkOrderBoardDataService boardDataService, + IWorkOrderBoardMutationDataService mutationData, + IWorkOrderAuditService auditService) + { + _boardDataService = boardDataService; + _mutationData = mutationData; + _auditService = auditService; + } + + public async Task PatchFieldAsync( + int workOrderId, + WorkOrderBoardPatchRequestDto request, + string? actorId) + { + await _mutationData.ExecuteTransactionalAsync(async ct => + { + if (string.IsNullOrWhiteSpace(request.Field)) + throw new WorkOrderBoardValidationException("InvalidField", "Field is required."); + + var field = request.Field.Trim(); + var canonicalField = WorkOrderBoardFieldNames.Canonicalize(field); + if (canonicalField == null) + throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable."); + + var workOrderVersion = ParseRowVersion(request.WorkOrderVersion, "workOrderVersion"); + if (workOrderVersion == null) + throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required."); + + var dispatchVersion = ParseRowVersion(request.DispatchVersion, "dispatchVersion"); + + var workOrder = await _mutationData.GetTrackedWorkOrderAsync(workOrderId, ct); + + if (workOrder == null) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + if (!RowVersionsMatch(workOrder.RowVersion, workOrderVersion)) + { + var staleState = await LoadBoardRowAsync(workOrderId); + throw new WorkOrderBoardConcurrencyException(staleState); + } + + if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) + throw new WorkOrderBoardValidationException("CanceledReadOnly", "Work order is read-only in its current status."); + + 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."); + + _mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion); + + Dispatch? dispatch = null; + if (WorkOrderBoardFieldNames.IsDispatchField(canonicalField)) + { + var vendorIdHint = canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase) + ? ParseVendorIdHint(request.Value) + : null; + + var hadPrimaryDispatch = workOrder.PrimaryDispatchId.HasValue; + dispatch = await ResolveDispatchAsync(workOrder, request.PrimaryDispatchId, vendorIdHint, ct); + + if (dispatchVersion != null && !RowVersionsMatch(dispatch.RowVersion, dispatchVersion)) + { + var staleState = await LoadBoardRowAsync(workOrderId); + throw new WorkOrderBoardConcurrencyException(staleState); + } + + if (dispatchVersion != null) + _mutationData.SetExpectedDispatchVersion(dispatch, dispatchVersion); + else if (hadPrimaryDispatch && dispatch.RowVersion != null) + throw new WorkOrderBoardValidationException("DispatchVersionRequired", "dispatchVersion is required for dispatch fields."); + } + + var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField); + var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField); + changes = changes.Where(c => c.HasChanged).ToList(); + + if (changes.Count == 0 && !_mutationData.HasPendingChanges()) + return; + + if (changes.Count == 0) + { + await _mutationData.SaveAsync(ct); + return; + } + + foreach (var change in changes) + { + var dispatchId = change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null); + switch (change.Action) + { + case AuditActionType.StatusChanged: + await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); + break; + case AuditActionType.AssignmentChanged: + await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); + break; + default: + await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, dispatchId); + break; + } + } + + var outcome = await _mutationData.SaveAsync(ct); + if (outcome == BoardSaveOutcome.ConcurrencyConflict) + { + var currentState = await LoadBoardRowAsync(workOrderId); + throw new WorkOrderBoardConcurrencyException(currentState); + } + }, CancellationToken.None); + + var row = await LoadBoardRowAsync(workOrderId); + return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + } + + private async Task> ApplyFieldMutationAsync( + string field, + WorkOrder workOrder, + Dispatch? dispatch, + string? value, + string auditField) + { + return field switch + { + WorkOrderBoardFieldNames.WoNumber => new List { await ApplyWoNumber(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.WorkOrderType => new List { ApplyWorkOrderType(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.SiteCode => new List { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) }, + WorkOrderBoardFieldNames.LifecycleStatus => new List { ApplyLifecycleStatus(workOrder, value, auditField) }, + 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.VendorId => new List { await ApplyVendorIdAsync(dispatch!, value, auditField) }, + WorkOrderBoardFieldNames.ApptDate => new List { ApplyApptDate(dispatch!, value, auditField) }, + WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value), + WorkOrderBoardFieldNames.DocStatus => new List { ApplyDocStatus(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.Pm => new List { ApplyStringField(value, auditField, v => workOrder.Trade = v, () => workOrder.Trade) }, + WorkOrderBoardFieldNames.ServiceNotes => new List { ApplyServiceNotes(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.ExtraServices => new List { ApplyExtraServices(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.FlagColor => new List { ApplyFlagColor(workOrder, value, auditField) }, + _ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.") + }; + } + + private async Task ResolveDispatchAsync(WorkOrder workOrder, int? primaryDispatchId, int? vendorIdForCreate, CancellationToken cancellationToken) + { + if (primaryDispatchId.HasValue) + { + var dispatch = await _mutationData.GetTrackedDispatchAsync(primaryDispatchId.Value, workOrder.Id, cancellationToken); + + if (dispatch == null) + throw new WorkOrderBoardValidationException("InvalidDispatch", "Primary dispatch does not belong to this work order."); + + workOrder.PrimaryDispatchId = dispatch.Id; + return dispatch; + } + + if (workOrder.PrimaryDispatchId.HasValue) + { + var existing = await _mutationData.GetTrackedDispatchByIdAsync(workOrder.PrimaryDispatchId.Value, cancellationToken); + + if (existing != null) + return existing; + } + + if (!vendorIdForCreate.HasValue || vendorIdForCreate.Value <= 0) + throw new WorkOrderBoardValidationException("DispatchRequired", "A primary dispatch is required. Set vendorId first or provide primaryDispatchId."); + + await EnsureVendorExistsAsync(vendorIdForCreate.Value); + + var created = new Dispatch + { + WorkOrderId = workOrder.Id, + VendorId = vendorIdForCreate.Value, + Status = "Pending", + CreatedDate = DateTime.UtcNow + }; + _mutationData.TrackNewDispatch(created); + await _mutationData.SaveAsync(cancellationToken); + workOrder.PrimaryDispatchId = created.Id; + return created; + } + + private async Task EnsureVendorExistsAsync(int vendorId) + { + if (!await _mutationData.VendorExistsAsync(vendorId, CancellationToken.None)) + throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist."); + } + + private async Task ApplyWoNumber(WorkOrder workOrder, string? value, string auditField) + { + if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error)) + throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number."); + + if (await _boardDataService.InternalWoNumberExistsAsync(normalized, workOrder.Id)) + throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists."); + + var old = workOrder.InternalWONumber; + if (old == normalized) + return FieldChange.Unchanged(auditField); + + workOrder.InternalWONumber = normalized; + workOrder.WorkerOrderNumber = normalized; + return FieldChange.ForField(auditField, old, normalized); + } + + private static FieldChange ApplyWorkOrderType(WorkOrder workOrder, string? value, string auditField) + { + if (string.IsNullOrWhiteSpace(value)) + throw new WorkOrderBoardValidationException("InvalidValue", "workOrderType value is required."); + + 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)."); + + if (!WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed)) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}"); + + var old = workOrder.WorkOrderType?.ToString(); + if (workOrder.WorkOrderType == parsed) + return FieldChange.Unchanged(auditField); + + workOrder.WorkOrderType = parsed; + return FieldChange.ForField(auditField, old, parsed.ToString()); + } + + private static FieldChange ApplyStringField( + string? value, + string auditField, + Action setter, + Func getter) + { + var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + var old = getter(); + if (old == normalized) + return FieldChange.Unchanged(auditField); + + setter(normalized); + return FieldChange.ForField(auditField, old, normalized); + } + + private static FieldChange ApplyServiceNotes(WorkOrder workOrder, string? value, string auditField) + { + var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + if (normalized != null && normalized.Length > 4000) + throw new WorkOrderBoardValidationException("InvalidValue", "serviceNotes must be at most 4000 characters."); + + var old = workOrder.ServiceNotes; + if (old == normalized) + return FieldChange.Unchanged(auditField); + + workOrder.ServiceNotes = normalized; + return FieldChange.ForField(auditField, old, normalized); + } + + private static FieldChange ApplyExtraServices(WorkOrder workOrder, string? value, string auditField) + { + string? normalizedJson = null; + if (!string.IsNullOrWhiteSpace(value)) + { + List? parsed; + try + { + parsed = JsonSerializer.Deserialize>(value.Trim()); + } + catch (JsonException) + { + throw new WorkOrderBoardValidationException("InvalidValue", "extraServices must be a JSON string array."); + } + + var items = NormalizeExtraServiceList(parsed); + if (items.Count > 0) + { + if (string.IsNullOrWhiteSpace(workOrder.Trade)) + { + throw new WorkOrderBoardValidationException( + "InvalidValue", + "primaryService (pm/trade) is required when extraServices is provided."); + } + + items = items + .Where(s => !string.Equals(s, workOrder.Trade, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + normalizedJson = items.Count > 0 ? JsonSerializer.Serialize(items) : null; + if (normalizedJson != null && normalizedJson.Length > 2000) + { + throw new WorkOrderBoardValidationException( + "InvalidValue", + "extraServices must serialize to at most 2000 characters."); + } + } + } + + var old = workOrder.ExtraServices; + if (old == normalizedJson) + return FieldChange.Unchanged(auditField); + + workOrder.ExtraServices = normalizedJson; + return FieldChange.ForField(auditField, old, normalizedJson); + } + + private static List NormalizeExtraServiceList(List? extras) + { + var normalized = new List(); + if (extras == null || extras.Count == 0) + return normalized; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in extras) + { + if (string.IsNullOrWhiteSpace(item)) + continue; + var trimmed = item.Trim(); + if (trimmed.Length > 128) + throw new WorkOrderBoardValidationException("InvalidValue", "Each extra service must be at most 128 characters."); + if (!seen.Add(trimmed)) + continue; + normalized.Add(trimmed); + } + + return normalized; + } + + private static FieldChange ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField) + { + var parsed = LifecycleStatusMapper.ParseLifecycleStatus(value); + if (parsed == null) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid lifecycleStatus: {value}"); + + var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status; + if (workOrder.LifecycleStatus == parsed) + return FieldChange.Unchanged(auditField); + + workOrder.LifecycleStatus = parsed; + workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(parsed); + if (workOrder.LegacyStatus == null && workOrder.Status != null) + workOrder.LegacyStatus = workOrder.Status; + + return FieldChange.StatusChanged(auditField, old, parsed.ToString()); + } + + private static List ApplyAssignTo(WorkOrder workOrder, string? value, string auditField) + { + var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + var old = workOrder.AssignTo; + var changes = new List(); + + if (old != normalized) + { + workOrder.AssignTo = normalized; + changes.Add(FieldChange.AssignmentChanged(auditField, old, normalized)); + } + + changes.AddRange(ApplyAutoScheduleSideEffects(workOrder)); + return changes; + } + + private static FieldChange ApplyDateField( + string? value, + string auditField, + Action setter, + Func getter) + { + DateTime? parsed = null; + if (!string.IsNullOrWhiteSpace(value)) + { + if (!DateTime.TryParse(value, out var dt)) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid date for {auditField}: {value}"); + parsed = dt.Date; + } + + var old = FormatDate(getter()); + var newVal = FormatDate(parsed); + if (old == newVal) + return FieldChange.Unchanged(auditField); + + setter(parsed); + return FieldChange.ForField(auditField, old, newVal); + } + + private static List ApplyScheduledDate(WorkOrder workOrder, string? value, string auditField) + { + var previousDate = workOrder.ScheduledDate; + DateTime? parsed = null; + if (!string.IsNullOrWhiteSpace(value)) + { + if (!DateTime.TryParse(value, out var dt)) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid scheduledDate: {value}"); + parsed = dt.Date; + } + + var changes = new List(); + var oldFormatted = FormatDate(previousDate); + var newFormatted = FormatDate(parsed); + + if (oldFormatted != newFormatted) + { + workOrder.ScheduledDate = parsed; + changes.Add(FieldChange.ForField(auditField, oldFormatted, newFormatted)); + + if (!workOrder.OriginalDate.HasValue && parsed.HasValue) + { + workOrder.OriginalDate = DateOnly.FromDateTime(parsed.Value); + var originalWeek = WorkOrderBoardMutationRules.ComputeOriginalWeek(parsed); + if (originalWeek.HasValue) + workOrder.OriginalWeek = originalWeek; + } + + if (WorkOrderBoardMutationRules.IsReschedule(previousDate, parsed)) + { + var oldCount = workOrder.RescheduleCount.ToString(); + workOrder.RescheduleCount++; + changes.Add(FieldChange.ForField(WorkOrderBoardFieldNames.RescheduleCount, oldCount, workOrder.RescheduleCount.ToString())); + } + } + + changes.AddRange(ApplyAutoScheduleSideEffects(workOrder)); + return changes; + } + + private static FieldChange ApplyTargetWeek(WorkOrder workOrder, string? value, string auditField) + { + DateOnly? parsed = null; + if (!string.IsNullOrWhiteSpace(value)) + { + if (!DateOnly.TryParse(value, out var d)) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid targetWeek: {value}"); + parsed = d; + } + + var old = workOrder.TargetWeek?.ToString("yyyy-MM-dd"); + var newVal = parsed?.ToString("yyyy-MM-dd"); + if (old == newVal) + return FieldChange.Unchanged(auditField); + + workOrder.TargetWeek = parsed; + return FieldChange.ForField(auditField, old, newVal); + } + + private static FieldChange ApplyBoolField( + string? value, + string auditField, + Action setter, + Func getter) + { + if (string.IsNullOrWhiteSpace(value)) + throw new WorkOrderBoardValidationException("InvalidValue", $"{auditField} value is required."); + + if (!bool.TryParse(value, out var parsed)) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid boolean for {auditField}: {value}"); + + var old = getter()?.ToString(); + if (getter() == parsed) + return FieldChange.Unchanged(auditField); + + setter(parsed); + return FieldChange.ForField(auditField, old, parsed.ToString()); + } + + private async Task ApplyVendorIdAsync(Dispatch dispatch, string? value, string auditField) + { + var vendorId = ParseVendorIdHint(value); + if (!vendorId.HasValue) + throw new WorkOrderBoardValidationException("InvalidValue", "vendorId must be a positive integer."); + + await EnsureVendorExistsAsync(vendorId.Value); + + var old = dispatch.VendorId.ToString(); + if (dispatch.VendorId == vendorId.Value) + return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch)); + + dispatch.VendorId = vendorId.Value; + return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), ResolveDispatchIdForAudit(dispatch)); + } + + private static FieldChange ApplyApptDate(Dispatch dispatch, string? value, string auditField) + { + DateTime? parsed = null; + if (!string.IsNullOrWhiteSpace(value)) + { + if (!DateTime.TryParse(value, out var dt)) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid apptDate: {value}"); + parsed = dt.Date; + } + + var old = FormatDate(dispatch.ScheduledDate); + var newVal = FormatDate(parsed); + if (old == newVal) + return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch)); + + dispatch.ScheduledDate = parsed; + return FieldChange.ForField(auditField, old, newVal, ResolveDispatchIdForAudit(dispatch)); + } + + // apptTime is stored on WorkOrder (ScheduledStart/End). Lock those sync field names so + // SyncFieldMergePolicy honors the manual edit (not a synthetic "ApptTime" lock). + private static List ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value) + { + if (!WorkOrderBoardApptTimeParser.TryParse(value, out var start, out var end, out var error)) + throw new WorkOrderBoardValidationException("InvalidValue", error ?? "Invalid apptTime."); + + var apptDate = dispatch.ScheduledDate ?? workOrder.ScheduledDate; + var oldStart = FormatDateTime(workOrder.ScheduledStart); + var oldEnd = FormatDateTime(workOrder.ScheduledEnd); + var dispatchId = ResolveDispatchIdForAudit(dispatch); + + workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start); + workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end); + + var newStart = FormatDateTime(workOrder.ScheduledStart); + var newEnd = FormatDateTime(workOrder.ScheduledEnd); + var changes = new List(); + + if (oldStart != newStart) + changes.Add(FieldChange.ForField(WorkOrderFieldNames.ScheduledStart, oldStart, newStart, dispatchId)); + if (oldEnd != newEnd) + changes.Add(FieldChange.ForField(WorkOrderFieldNames.ScheduledEnd, oldEnd, newEnd, dispatchId)); + + if (changes.Count == 0) + changes.Add(FieldChange.Unchanged(WorkOrderFieldNames.ScheduledStart, dispatchId)); + + return changes; + } + + private static FieldChange ApplyDocStatus(WorkOrder workOrder, string? value, string auditField) + { + if (string.IsNullOrWhiteSpace(value)) + throw new WorkOrderBoardValidationException("InvalidValue", "docStatus value is required."); + + if (!Enum.TryParse(value, true, out var parsed)) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid docStatus: {value}"); + + var old = workOrder.DocStatus?.ToString(); + if (workOrder.DocStatus == parsed) + return FieldChange.Unchanged(auditField); + + workOrder.DocStatus = parsed; + return FieldChange.ForField(auditField, old, parsed.ToString()); + } + + private static FieldChange ApplyFlagColor(WorkOrder workOrder, string? value, string auditField) + { + if (!WorkOrderFlagColors.TryNormalize(value, out var canonical)) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid flagColor: {value}"); + + if (canonical != null && !WorkOrderFlagColors.IsAllowed(canonical)) + throw new WorkOrderBoardValidationException( + "InvalidValue", + $"flagColor must be one of the allowed palette values or empty to clear."); + + var old = workOrder.FlagColor; + if (string.Equals(old, canonical, StringComparison.OrdinalIgnoreCase)) + return FieldChange.Unchanged(auditField); + + workOrder.FlagColor = canonical; + return FieldChange.ForField(auditField, old, canonical); + } + + private static List ApplyAutoScheduleSideEffects(WorkOrder workOrder) + { + var changes = new List(); + if (!WorkOrderBoardMutationRules.ShouldAutoSchedule( + workOrder.LifecycleStatus, + workOrder.ScheduledDate, + workOrder.AssignTo, + workOrder.ScheduleWeekOnly)) + return changes; + + var old = workOrder.LifecycleStatus?.ToString(); + workOrder.LifecycleStatus = LifecycleStatus.Scheduled; + workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Scheduled); + changes.Add(FieldChange.StatusChanged("LifecycleStatus", old, LifecycleStatus.Scheduled.ToString())); + return changes; + } + + private static bool RowVersionsMatch(byte[]? current, byte[] expected) + => current != null && current.AsSpan().SequenceEqual(expected); + + private async Task LoadBoardRowAsync(int workOrderId) + { + var raw = await _boardDataService.GetBoardRowByIdAsync(workOrderId); + return raw == null ? null : WorkOrderBoardService.MapRawRow(raw, DateTime.UtcNow); + } + + private static byte[]? ParseRowVersion(string? base64, string fieldName) + { + if (string.IsNullOrWhiteSpace(base64)) + return null; + + try + { + return Convert.FromBase64String(base64); + } + catch (FormatException) + { + throw new WorkOrderBoardValidationException("InvalidRowVersion", $"Invalid {fieldName} format."); + } + } + + private static int? ParseVendorIdHint(string? value) + { + if (string.IsNullOrWhiteSpace(value) || !int.TryParse(value, out var vendorId) || vendorId <= 0) + return null; + return vendorId; + } + + private static int? ResolveDispatchIdForAudit(Dispatch dispatch) + => dispatch.Id > 0 ? dispatch.Id : null; + + private static string? FormatDate(DateTime? value) => value?.ToString("yyyy-MM-dd"); + private static string? FormatDateTime(DateTime? value) => value?.ToString("O"); + private static string? FormatApptRange(string? start, string? end) + => start == null && end == null ? null : $"{start}|{end}"; + + private sealed record FieldChange( + string FieldName, + string? OldValue, + string? NewValue, + AuditActionType Action, + int? DispatchId, + bool HasChanged) + { + public static FieldChange Unchanged(string fieldName, int? dispatchId = null) + => new(fieldName, null, null, AuditActionType.FieldChanged, dispatchId, false); + + public static FieldChange ForField(string fieldName, string? oldValue, string? newValue, int? dispatchId = null) + => new(fieldName, oldValue, newValue, AuditActionType.FieldChanged, dispatchId, true); + + public static FieldChange StatusChanged(string fieldName, string? oldValue, string? newValue) + => new(fieldName, oldValue, newValue, AuditActionType.StatusChanged, null, true); + + public static FieldChange AssignmentChanged(string fieldName, string? oldValue, string? newValue) + => new(fieldName, oldValue, newValue, AuditActionType.AssignmentChanged, null, true); + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderCommentService.cs b/SeaHaven.Services/Implementation/WorkOrderCommentService.cs new file mode 100644 index 0000000..d91599e --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderCommentService.cs @@ -0,0 +1,59 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderCommentService : IWorkOrderCommentService + { + private readonly IWorkOrderDetailDataService _detailData; + private readonly ICommentDataService _commentData; + + public WorkOrderCommentService( + IWorkOrderDetailDataService detailData, + ICommentDataService commentData) + { + _detailData = detailData; + _commentData = commentData; + } + + public async Task?> GetCommentsAsync(int workOrderId) + { + if (!await _detailData.ExistsAsync(workOrderId)) + return null; + + var comments = await _detailData.GetCommentsAsync(workOrderId); + return WorkOrderCommentProjection.ToDtos(comments); + } + + public async Task AddCommentAsync( + int workOrderId, + WorkOrderCommentCreateDto request, + string? actorId, + string? documentUrl = null) + { + if (!await _detailData.ExistsAsync(workOrderId)) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + if (string.IsNullOrWhiteSpace(request.Text)) + throw new WorkOrderBoardValidationException("InvalidValue", "Comment text is required."); + + var comment = new Comments + { + WorkerOrderId = workOrderId, + UserId = actorId, + Commenttext = request.Text.Trim(), + CommentType = "General", + RecordType = "WorkOrder", + Documents = documentUrl, + CreatedDate = DateTime.UtcNow + }; + + var saved = await _commentData.AddAsync(comment); + return WorkOrderCommentProjection.ToDto(saved); + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs new file mode 100644 index 0000000..2ec8f5b --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs @@ -0,0 +1,176 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderCompletionService : IWorkOrderCompletionService + { + private readonly IWorkOrderCompletionDataService _completionData; + private readonly ICompletionDocTemplateDataService _templateData; + private readonly IWorkOrderDetailDataService _detailData; + private readonly IWorkOrderAuditService _auditService; + + public WorkOrderCompletionService( + IWorkOrderCompletionDataService completionData, + ICompletionDocTemplateDataService templateData, + IWorkOrderDetailDataService detailData, + IWorkOrderAuditService auditService) + { + _completionData = completionData; + _templateData = templateData; + _detailData = detailData; + _auditService = auditService; + } + + public async Task> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType) + { + var rows = await _templateData.GetActiveAsync(serviceKey, workOrderType); + return rows.Select(WorkOrderDetailService.MapTemplate).ToList(); + } + + public async Task GetTemplateByIdAsync(int id) + { + var row = await _templateData.GetByIdAsync(id); + return row == null ? null : WorkOrderDetailService.MapTemplate(row); + } + + public async Task UploadCompletionDocAsync( + int workOrderId, + WorkOrderCompletionDocUploadDto request, + string fileUrl, + string? actorId) + { + var workOrder = await _completionData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); + + if (workOrder == null) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) + throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); + + var workOrderVersion = ParseRowVersion(request.WorkOrderVersion); + if (workOrderVersion == null) + throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required."); + + if (workOrder.RowVersion == null || !workOrder.RowVersion.AsSpan().SequenceEqual(workOrderVersion)) + throw new WorkOrderBoardValidationException("ConcurrencyConflict", "Work order was modified. Refresh and retry."); + + _completionData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion); + + var oldDocStatus = workOrder.DocStatus?.ToString(); + var oldAttachment = workOrder.SignOffAttachment; + + workOrder.SignOffAttachment = fileUrl; + if (!string.IsNullOrWhiteSpace(request.SignOffName)) + workOrder.SignOffName = request.SignOffName.Trim(); + if (!string.IsNullOrWhiteSpace(request.SignOffSignature)) + workOrder.SignOffSignature = request.SignOffSignature.Trim(); + + workOrder.DocStatus = DocStatus.Yes; + + await _auditService.StageFieldChangedAsync(workOrderId, "SignOffAttachment", oldAttachment, fileUrl, actorId); + if (oldDocStatus != DocStatus.Yes.ToString()) + await _auditService.StageFieldChangedAsync(workOrderId, "DocStatus", oldDocStatus, DocStatus.Yes.ToString(), actorId); + + await _completionData.SaveAsync(CancellationToken.None); + + var extended = await _detailData.GetExtendedFieldsAsync(workOrderId); + var template = await _templateData.ResolveForWorkOrderAsync(workOrder.Trade, workOrder.WorkOrderType); + var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId); + + return new WorkOrderCompletionDto + { + DocStatus = workOrder.DocStatus, + Template = template == null ? null : WorkOrderDetailService.MapTemplate(template), + SignOffName = extended?.SignOffName, + SignOffAttachment = extended?.SignOffAttachment, + SignOffSignature = extended?.SignOffSignature, + DispatchSignoffs = signoffs.Select(s => new DispatchSignoffDto + { + DispatchId = s.DispatchId, + SignoffType = s.SignoffType, + Name = s.Name, + SignedAt = s.SignedAt?.ToUniversalTime().ToString("o") + }).ToList() + }; + } + + public async Task CreateTemplateAsync(CompletionDocTemplateCreateDto request) + { + ValidateTemplateRequest(request); + + var entity = new CompletionDocTemplate + { + Name = request.Name.Trim(), + ServiceKey = request.ServiceKey.Trim(), + WorkOrderType = request.WorkOrderType, + TemplateUrl = request.TemplateUrl.Trim(), + IsActive = request.IsActive + }; + + var created = await _templateData.CreateAsync(entity); + return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow( + created.Id, created.Name, created.ServiceKey, created.WorkOrderType, created.TemplateUrl, created.IsActive)); + } + + public async Task UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request) + { + ValidateTemplateRequest(request); + + var entity = await _completionData.GetTrackedTemplateAsync(id, CancellationToken.None); + if (entity == null) + throw new WorkOrderBoardValidationException("NotFound", "Template not found."); + + entity.Name = request.Name.Trim(); + entity.ServiceKey = request.ServiceKey.Trim(); + entity.WorkOrderType = request.WorkOrderType; + entity.TemplateUrl = request.TemplateUrl.Trim(); + entity.IsActive = request.IsActive; + + await _templateData.UpdateAsync(entity); + return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow( + entity.Id, entity.Name, entity.ServiceKey, entity.WorkOrderType, entity.TemplateUrl, entity.IsActive)); + } + + public async Task DeleteTemplateAsync(int id) + { + if (!await _templateData.DeleteAsync(id)) + throw new WorkOrderBoardValidationException("NotFound", "Template not found."); + } + + private static void ValidateTemplateRequest(CompletionDocTemplateCreateDto request) + { + if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.ServiceKey)) + throw new WorkOrderBoardValidationException("InvalidValue", "Name and serviceKey are required."); + + if (string.IsNullOrWhiteSpace(request.TemplateUrl)) + throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl is required."); + + if (!Uri.TryCreate(request.TemplateUrl.Trim(), UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl must be an absolute http(s) URL."); + } + } + + private static byte[]? ParseRowVersion(string? base64) + { + if (string.IsNullOrWhiteSpace(base64)) + return null; + + try + { + return Convert.FromBase64String(base64); + } + catch (FormatException) + { + throw new WorkOrderBoardValidationException("InvalidRowVersion", "Invalid workOrderVersion format."); + } + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs new file mode 100644 index 0000000..849c5f6 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -0,0 +1,182 @@ +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderDetailService : IWorkOrderDetailService + { + private readonly IWorkOrderBoardService _boardService; + private readonly IWorkOrderDetailDataService _detailData; + private readonly ICompletionDocTemplateDataService _templateData; + private readonly IUserDataService _userDataService; + + public WorkOrderDetailService( + IWorkOrderBoardService boardService, + IWorkOrderDetailDataService detailData, + ICompletionDocTemplateDataService templateData, + IUserDataService userDataService) + { + _boardService = boardService; + _detailData = detailData; + _templateData = templateData; + _userDataService = userDataService; + } + + public async Task GetDetailAsync(int workOrderId) + { + if (!await _detailData.ExistsAsync(workOrderId)) + return null; + + var boardRow = await _boardService.GetBoardRowAsync(workOrderId); + if (boardRow == null) + return null; + + var extended = await _detailData.GetExtendedFieldsAsync(workOrderId); + var comments = await _detailData.GetCommentsAsync(workOrderId); + var audit = await _detailData.GetAuditLogsAsync(workOrderId, 50); + var attachments = await _detailData.GetAttachmentsAsync(workOrderId); + var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId); + var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId); + + var template = await _templateData.ResolveForWorkOrderAsync( + extended?.Trade ?? boardRow.Pm, + boardRow.WorkOrderType); + + var authorIds = WorkOrderCommentProjection.CollectAuthorUserIds(comments); + var authorNames = await _userDataService.GetDisplayNamesByIdsAsync(authorIds); + + return new WorkOrderDetailDto + { + Info = MapInfo(boardRow, extended), + Completion = MapCompletion(extended, boardRow.DocStatus, template, signoffs), + Comments = WorkOrderCommentProjection.ToDtos(comments, authorNames), + Audit = await MapAuditAsync(audit), + Media = workOrder == null + ? new List() + : WorkOrderMediaProjection.ProjectAll(workOrder, attachments) + }; + } + + public async Task?> GetAuditAsync(int workOrderId, int limit = 50) + { + if (!await _detailData.ExistsAsync(workOrderId)) + return null; + + var logs = await _detailData.GetAuditLogsAsync(workOrderId, limit); + return await MapAuditAsync(logs); + } + + private async Task> MapAuditAsync(IReadOnlyList logs) + { + var userIds = WorkOrderAuditProjection.CollectAssignToUserIds(logs); + var displayNames = await _userDataService.GetDisplayNamesByIdsAsync(userIds); + return WorkOrderAuditProjection.ToDtos(logs, displayNames); + } + + private static WorkOrderDetailInfoDto MapInfo( + WorkOrderBoardRowDto row, + WorkOrderDetailExtendedFields? extended) + { + return new WorkOrderDetailInfoDto + { + Id = row.Id, + WoNumber = row.WoNumber, + RescheduleCount = row.RescheduleCount, + CarriedOver = row.CarriedOver, + WorkOrderType = row.WorkOrderType, + IsPastDue = row.IsPastDue, + SiteCode = row.SiteCode, + LocationName = row.LocationName, + PocName = row.PocName, + PocPhone = row.PocPhone, + PocNotes = row.PocNotes, + LifecycleStatus = row.LifecycleStatus, + LifecycleStatusLabel = row.LifecycleStatusLabel, + LegacyStatus = row.LegacyStatus, + DispatcherId = row.DispatcherId, + DispatcherName = row.DispatcherName, + Initials = row.Initials, + Color = row.Color, + DueDate = row.DueDate, + ScheduledDate = row.ScheduledDate, + TargetWeek = row.TargetWeek, + ScheduleWeekOnly = row.ScheduleWeekOnly, + DayGroup = row.DayGroup, + VendorId = row.VendorId, + VendorName = row.VendorName, + TechName = row.TechName, + TechPhone = row.TechPhone, + ApptDate = row.ApptDate, + ApptTime = row.ApptTime, + Pm = row.Pm, + ServiceNotes = extended?.ServiceNotes ?? row.ServiceNotes, + ExtraServices = ParseExtraServices(extended?.ExtraServices) ?? row.ExtraServices, + DocStatus = row.DocStatus, + FlagColor = row.FlagColor, + PrimaryDispatchId = row.PrimaryDispatchId, + RowVersion = row.RowVersion, + DispatchRowVersion = row.DispatchRowVersion, + Description = extended?.Description, + Trade = extended?.Trade, + Problem = extended?.Problem, + SubTrade = extended?.SubTrade, + OriginalWeek = extended?.OriginalWeek, + OriginalDate = extended?.OriginalDate, + CompletedDate = extended?.CompletedDate, + VendorNotes = extended?.VendorNotes + }; + } + + private static List? ParseExtraServices(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + var list = System.Text.Json.JsonSerializer.Deserialize>(json); + return list is { Count: > 0 } ? list : null; + } + catch (System.Text.Json.JsonException) + { + return null; + } + } + + private static WorkOrderCompletionDto MapCompletion( + WorkOrderDetailExtendedFields? extended, + Data.SeaHavenIndustries.Enums.DocStatus? docStatus, + CompletionDocTemplateRow? template, + IReadOnlyList signoffs) + { + return new WorkOrderCompletionDto + { + DocStatus = docStatus, + Template = template == null ? null : MapTemplate(template), + SignOffName = extended?.SignOffName, + SignOffAttachment = extended?.SignOffAttachment, + SignOffSignature = extended?.SignOffSignature, + DispatchSignoffs = signoffs.Select(s => new DispatchSignoffDto + { + DispatchId = s.DispatchId, + SignoffType = s.SignoffType, + Name = s.Name, + SignedAt = s.SignedAt?.ToUniversalTime().ToString("o") + }).ToList() + }; + } + + internal static CompletionDocTemplateDto MapTemplate(CompletionDocTemplateRow template) + => new() + { + Id = template.Id, + Name = template.Name, + ServiceKey = template.ServiceKey, + WorkOrderType = template.WorkOrderType, + TemplateUrl = template.TemplateUrl, + IsActive = template.IsActive + }; + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs b/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs index 5bb006c..9825181 100644 --- a/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs @@ -16,9 +16,10 @@ namespace SeaHaven.Services.Implementation => _dataService.IsLockedAsync(workOrderId, fieldName, CancellationToken.None); public Task LockFieldAsync(int workOrderId, string fieldName, string? userId) - { - _dataService.EnqueueLock(workOrderId, fieldName, userId); - return Task.CompletedTask; - } + => _dataService.EnqueueLockAsync( + workOrderId, + fieldName, + userId, + CancellationToken.None); } } diff --git a/SeaHaven.Services/Implementation/WorkOrderIngestService.cs b/SeaHaven.Services/Implementation/WorkOrderIngestService.cs new file mode 100644 index 0000000..3a8bffb --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderIngestService.cs @@ -0,0 +1,200 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderIngestService : IWorkOrderIngestService + { + private readonly IWorkOrderIngestDataService _ingestData; + private readonly ISyncFieldMergePolicy _mergePolicy; + private readonly IWorkOrderFieldLockService _fieldLocks; + private readonly IWorkOrderAuditService _audit; + + public WorkOrderIngestService( + IWorkOrderIngestDataService ingestData, + ISyncFieldMergePolicy mergePolicy, + IWorkOrderFieldLockService fieldLocks, + IWorkOrderAuditService audit) + { + _ingestData = ingestData; + _mergePolicy = mergePolicy; + _fieldLocks = fieldLocks; + _audit = audit; + } + + public async Task UpsertBatchAsync( + IReadOnlyList items, + CancellationToken cancellationToken = default) + { + var result = new WorkOrderIngestBatchResultDto(); + if (items.Count == 0) + return result; + + await _ingestData.ExecuteTransactionalAsync(async cancellationToken => + { + var nextSeed = await AllocateNextWoSeedAsync(cancellationToken); + + foreach (var item in items) + { + if (string.IsNullOrWhiteSpace(item.ExternalWorkOrderId)) + { + result.Skipped++; + continue; + } + + var (upsert, updatedSeed) = await UpsertOneAsync(item, nextSeed, cancellationToken); + nextSeed = updatedSeed; + result.Results.Add(upsert); + if (upsert.Created) + result.Created++; + else + result.Updated++; + } + + await _ingestData.SaveAsync(cancellationToken); + }, cancellationToken); + + return result; + } + + private async Task<(WorkOrderIngestItemResultDto Result, int NextSeed)> UpsertOneAsync( + WorkOrderIngestPayloadDto item, + int nextSeed, + CancellationToken cancellationToken) + { + var existing = await _ingestData.GetTrackedByExternalIdAsync(item.ExternalWorkOrderId, cancellationToken); + + var locationId = await ResolveLocationIdAsync( + item.SiteCode, item.Building, item.Address, cancellationToken); + + if (existing == null) + { + var (woNumber, updatedSeed) = AllocateInternalWoNumber(nextSeed); + var wo = new WorkOrder + { + InternalWONumber = woNumber, + ExternalWorkOrderId = item.ExternalWorkOrderId, + WorkerOrderNumber = item.ExternalWorkOrderId, + WorkerOrderTitle = item.Description, + Description = item.Description, + Status = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus) ?? "Open", + Priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity), + Severity = item.Severity, + Customer = item.Customer, + SiteCode = item.SiteCode, + Building = item.Building, + LocationId = locationId, + DueDate = item.DueDate, + DateReported = item.DateReported, + ScheduledStart = item.ScheduledStart, + SourceEmailS3Key = item.SourceEmailS3Key, + CreatedDate = item.CreatedAt ?? DateTime.UtcNow, + istemplate = false + }; + _ingestData.TrackWorkOrder(wo); + await _ingestData.SaveAsync(cancellationToken); + + return (new WorkOrderIngestItemResultDto + { + ExternalWorkOrderId = item.ExternalWorkOrderId, + WorkOrderId = wo.Id, + Created = true + }, updatedSeed); + } + + var syncContext = new WorkOrderSyncContext + { + WorkOrder = existing, + FieldLocks = _fieldLocks, + Audit = _audit + }; + + if (item.Description != null) + { + await _mergePolicy.TryApplyAsync(syncContext, "Description", item.Description); + await _mergePolicy.TryApplyAsync(syncContext, "WorkerOrderTitle", item.Description); + } + + var mappedStatus = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus); + if (mappedStatus != null) + await _mergePolicy.TryApplyAsync(syncContext, "Status", mappedStatus); + + var priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity); + if (priority != null) + await _mergePolicy.TryApplyAsync(syncContext, "Priority", priority); + + if (item.Severity != null) + await _mergePolicy.TryApplyAsync(syncContext, "Severity", item.Severity); + + if (item.SiteCode != null) + await _mergePolicy.TryApplyAsync(syncContext, "SiteCode", item.SiteCode); + + if (item.Building != null) + await _mergePolicy.TryApplyAsync(syncContext, "Building", item.Building); + + if (locationId.HasValue) + await _mergePolicy.TryApplyAsync(syncContext, "LocationId", locationId.Value); + + if (item.DueDate.HasValue) + await _mergePolicy.TryApplyAsync(syncContext, "DueDate", item.DueDate.Value); + + if (item.Customer != null) + existing.Customer = item.Customer; + + // Sync-owned metadata (not in ShocOwnedFieldSet) + existing.DateReported = item.DateReported ?? existing.DateReported; + existing.ScheduledStart = item.ScheduledStart ?? existing.ScheduledStart; + existing.SourceEmailS3Key = item.SourceEmailS3Key ?? existing.SourceEmailS3Key; + + return (new WorkOrderIngestItemResultDto + { + ExternalWorkOrderId = item.ExternalWorkOrderId, + WorkOrderId = existing.Id, + Created = false + }, nextSeed); + } + + private async Task AllocateNextWoSeedAsync(CancellationToken cancellationToken) + { + var maxId = await _ingestData.GetMaxWorkOrderIdAsync(cancellationToken); + return Math.Max(maxId + 1, 1); + } + + private static (string Number, int NextSeed) AllocateInternalWoNumber(int seed) + { + if (!WorkOrderNumberNormalizer.TryNormalize(seed.ToString(), out var normalized, out _)) + normalized = seed.ToString().PadLeft(11, '0'); + return (normalized, seed + 1); + } + + private async Task ResolveLocationIdAsync( + string? siteCode, + string? building, + string? address, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(siteCode) && string.IsNullOrWhiteSpace(building)) + return null; + + var matchCode = siteCode ?? building; + var existing = await _ingestData.FindLocationAsync(matchCode, cancellationToken); + + if (existing != null) + return existing.Id; + + var location = new Locations + { + Name = matchCode, + Title = building, + Address1 = address, + Status = "Active" + }; + + await _ingestData.AddAndSaveLocationAsync(location, cancellationToken); + return location.Id; + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs new file mode 100644 index 0000000..81c2614 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -0,0 +1,132 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderMediaService : IWorkOrderMediaService + { + private readonly IWorkOrderMediaDataService _mediaData; + private readonly IWorkOrderDetailDataService _detailData; + + public WorkOrderMediaService(IWorkOrderMediaDataService mediaData, IWorkOrderDetailDataService detailData) + { + _mediaData = mediaData; + _detailData = detailData; + } + + public async Task?> GetMediaAsync(int workOrderId) + { + if (!await _detailData.ExistsAsync(workOrderId)) + return null; + + var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId); + if (workOrder == null) + return null; + + var attachments = await _detailData.GetAttachmentsAsync(workOrderId); + return WorkOrderMediaProjection.ProjectAll(workOrder, attachments); + } + + public async Task AddMediaAsync( + int workOrderId, + WorkOrderMediaCategory category, + string fileUrl, + string? actorId) + { + if (!await _detailData.ExistsAsync(workOrderId)) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); + if (workOrder == null) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) + throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); + + if (category == WorkOrderMediaCategory.Completion) + { + throw new WorkOrderBoardValidationException( + "UseCompletionDocEndpoint", + "Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc."); + } + + if (category == WorkOrderMediaCategory.Before) + { + workOrder.BeforPhotoAttachment = fileUrl; + await _mediaData.SaveAsync(CancellationToken.None); + return new WorkOrderMediaFileDto + { + Id = -1, + Category = category, + Url = fileUrl, + IsLegacy = true + }; + } + + if (category == WorkOrderMediaCategory.After) + { + workOrder.AfterPhotoAttachment = fileUrl; + await _mediaData.SaveAsync(CancellationToken.None); + return new WorkOrderMediaFileDto + { + Id = -2, + Category = category, + Url = fileUrl, + IsLegacy = true + }; + } + + var attachment = new WorkOrderAttachments + { + WorkorderId = workOrderId, + Attachments = fileUrl, + Category = category, + CreatedDate = DateTime.UtcNow, + createdby = actorId + }; + + _mediaData.TrackAttachment(attachment); + await _mediaData.SaveAsync(CancellationToken.None); + + return new WorkOrderMediaFileDto + { + Id = attachment.Id, + Category = category, + Url = fileUrl, + UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"), + IsLegacy = false + }; + } + + public async Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId) + { + if (mediaId <= 0) + throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be deleted via this endpoint."); + + if (!await _detailData.ExistsAsync(workOrderId)) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); + if (workOrder == null) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) + throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); + + var attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, CancellationToken.None); + + if (attachment == null) + throw new WorkOrderBoardValidationException("NotFound", "Media not found."); + + attachment.IsDeleted = true; + attachment.DeletionTime = DateTime.UtcNow; + attachment.DeleterUserId = actorId; + await _mediaData.SaveAsync(CancellationToken.None); + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs b/SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs new file mode 100644 index 0000000..0b20f96 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderOpsHealthService : IWorkOrderOpsHealthService + { + private readonly IWorkOrderOpsHealthDataService _healthData; + private readonly WorkOrderJobRunStateAccessor _runState; + private readonly WorkOrderOpsHealthOptions _options; + + public WorkOrderOpsHealthService( + IWorkOrderOpsHealthDataService healthData, + WorkOrderJobRunStateAccessor runState, + IOptions options) + { + _healthData = healthData; + _runState = runState; + _options = options.Value; + } + + public async Task GetHealthAsync(CancellationToken cancellationToken = default) + { + var since = DateTime.UtcNow.AddHours(-24); + + var syncRejected = await _healthData.CountSyncRejectedSinceAsync(since, cancellationToken); + var fieldLocks = await _healthData.CountFieldLocksAsync(cancellationToken); + + var state = _runState.State; + var ledgerWeekRolled = await _healthData.GetLastWeekRolledProcessedAtAsync(cancellationToken); + + return new WorkOrderOpsHealthDto + { + LastWeekRolledRunUtc = state.LastWeekRolledRunUtc ?? ledgerWeekRolled, + LastPastDueCacheRunUtc = state.LastPastDueCacheRunUtc, + LastWeekRolledError = state.LastWeekRolledError, + LastPastDueCacheError = state.LastPastDueCacheError, + SyncRejectedLast24h = syncRejected, + FieldLockCount = fieldLocks, + SyncEnabled = _options.SyncEnabled, + IngestEnabled = _options.IngestEnabled, + LegacyDeprecationEnabled = _options.LegacyDeprecationEnabled, + LegacySunsetDate = _options.LegacySunsetDate, + CheckedAtUtc = DateTime.UtcNow + }; + } + } + + /// Options bridged from API configuration for health snapshots. + public class WorkOrderOpsHealthOptions + { + public bool SyncEnabled { get; set; } = true; + public bool IngestEnabled { get; set; } = true; + public bool LegacyDeprecationEnabled { get; set; } + public string? LegacySunsetDate { get; set; } + } + + /// Shared in-memory job run timestamps (API hosted services + ops health). + public class WorkOrderJobRunStateAccessor + { + public WorkOrderJobRunState State { get; } = new(); + } + + public class WorkOrderJobRunState + { + public DateTime? LastWeekRolledRunUtc { get; set; } + public DateTime? LastPastDueCacheRunUtc { get; set; } + public string? LastWeekRolledError { get; set; } + public string? LastPastDueCacheError { get; set; } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs b/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs new file mode 100644 index 0000000..ec4eebf --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs @@ -0,0 +1,110 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderWeekRolledService : IWorkOrderWeekRolledService + { + private const int BatchSize = 500; + + private readonly IWorkOrderDomainJobDataService _dataService; + private readonly IWorkOrderAuditService _auditService; + private readonly ILogger _logger; + + public WorkOrderWeekRolledService( + IWorkOrderDomainJobDataService dataService, + IWorkOrderAuditService auditService, + ILogger logger) + { + _dataService = dataService; + _auditService = auditService; + _logger = logger; + } + + public async Task ProcessWeekRolledAsync( + DateOnly sourceWeekStart, + CancellationToken cancellationToken = default) + { + var stopwatch = Stopwatch.StartNew(); + var sourceWeekEnd = WorkOrderOperationalWeek.GetOperationalWeekEnd(sourceWeekStart); + var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(sourceWeekStart); + + var processed = 0; + var skipped = 0; + var failed = 0; + + _logger.LogInformation( + "WeekRolled job started. CorrelationId={CorrelationId}, SourceWeekStart={SourceWeekStart}, SourceWeekEnd={SourceWeekEnd}", + correlationId, sourceWeekStart, sourceWeekEnd); + + // Always fetch the first page of remaining candidates. Processed rows write ledger + // entries that exclude them from the next query; offset paging would skip unprocessed rows. + while (!cancellationToken.IsCancellationRequested) + { + var candidates = await _dataService.GetWeekRolledCandidatesAsync( + sourceWeekStart, sourceWeekEnd, BatchSize, cancellationToken); + + if (candidates.Count == 0) + break; + + foreach (var workOrderId in candidates) + { + if (cancellationToken.IsCancellationRequested) + break; + + try + { + var outcome = await _dataService.TryProcessWeekRolledAsync( + workOrderId, sourceWeekStart, correlationId, cancellationToken); + + if (outcome.Skipped) + { + skipped++; + continue; + } + + if (outcome.Processed) + { + await _auditService.LogWeekRolledAsync( + workOrderId, outcome.OldCarriedOver, outcome.NewCarriedOver, correlationId); + processed++; + } + } + catch (Exception ex) + { + failed++; + _logger.LogError(ex, + "WeekRolled failed for WorkOrderId={WorkOrderId}, CorrelationId={CorrelationId}", + workOrderId, correlationId); + } + } + + if (candidates.Count < BatchSize) + break; + } + + stopwatch.Stop(); + + var result = new WeekRolledJobResult + { + Processed = processed, + Skipped = skipped, + Failed = failed, + CorrelationId = correlationId, + SourceWeekStart = sourceWeekStart, + SourceWeekEnd = sourceWeekEnd, + DurationMs = stopwatch.ElapsedMilliseconds + }; + + _logger.LogInformation( + "WeekRolled job completed. CorrelationId={CorrelationId}, Processed={Processed}, Skipped={Skipped}, Failed={Failed}, DurationMs={DurationMs}", + correlationId, processed, skipped, failed, result.DurationMs); + + return result; + } + } +} diff --git a/SeaHaven.Services/Interfaces/ILocationService.cs b/SeaHaven.Services/Interfaces/ILocationService.cs index 95db466..21d9120 100644 --- a/SeaHaven.Services/Interfaces/ILocationService.cs +++ b/SeaHaven.Services/Interfaces/ILocationService.cs @@ -9,6 +9,7 @@ namespace SeaHaven.Services.Interfaces Task> GetAllLocationsAsync(); Task> GetLocationsByAccountIdAsync(int accountId); Task> GetLocationsPagedAsync(int page, int pageSize, string? search = null); + Task> GetSiteOptionsAsync(string? search = null); Task CreateLocationAsync(CreateLocationDTO dto, string userId); Task UpdateLocationAsync(int id, UpdateLocationDTO dto, string userId); Task DeleteLocationAsync(int id, string userId); diff --git a/SeaHaven.Services/Interfaces/IPastDueCacheService.cs b/SeaHaven.Services/Interfaces/IPastDueCacheService.cs new file mode 100644 index 0000000..e87f498 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IPastDueCacheService.cs @@ -0,0 +1,9 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IPastDueCacheService + { + Task RefreshAsync(CancellationToken cancellationToken = default); + } +} diff --git a/SeaHaven.Services/Interfaces/IServicePorts.cs b/SeaHaven.Services/Interfaces/IServicePorts.cs index 8050cc5..b159f3a 100644 --- a/SeaHaven.Services/Interfaces/IServicePorts.cs +++ b/SeaHaven.Services/Interfaces/IServicePorts.cs @@ -7,6 +7,18 @@ namespace SeaHaven.Services.Interfaces Task SaveFileAsync(IFormFile file); } + /// + /// Narrow vendor completion-document storage port. Implemented in the API layer so the + /// service layer can persist/read uploaded vendor documents without owning filesystem paths. + /// Path resolution is shared with the scan worker to avoid duplicate rules. + /// + public interface IVendorDocumentStoragePort + { + Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken); + Stream OpenRead(int vendorId, int dispatchId, string storedFileName); + void Delete(int vendorId, int dispatchId, string storedFileName); + } + public interface IDispatchEmailPort { Task SendDispatchEmailAsync(string emailTo, string subject, string htmlBody, string? replyTo); diff --git a/SeaHaven.Services/Interfaces/IVendorOperationsService.cs b/SeaHaven.Services/Interfaces/IVendorOperationsService.cs new file mode 100644 index 0000000..ef08df9 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IVendorOperationsService.cs @@ -0,0 +1,18 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IVendorOperationsService + { + Task GetNotificationsAsync(CancellationToken cancellationToken); + Task> GetAvailabilityAsync(DateTime? start, DateTime? end, int? locationId, string? trade, CancellationToken cancellationToken); + Task> UpdateAvailabilityAsync(int vendorId, string? status, string actor, CancellationToken cancellationToken); + Task> GetSitePreferencesAsync(int locationId, CancellationToken cancellationToken); + Task>> ReplaceSitePreferencesAsync(int locationId, IReadOnlyList body, string actor, CancellationToken cancellationToken); + Task> ReviewDocumentAsync(int documentId, DocumentReviewRequest body, string actor, CancellationToken cancellationToken); + Task> UpdateCommercialStatusAsync(int dispatchId, CommercialStatusRequest body, string actor, CancellationToken cancellationToken); + Task> AssignVendorAsync(int workOrderId, VendorAssignmentRequest body, string actor, CancellationToken cancellationToken); + Task> GetInsightRowsAsync(DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId, CancellationToken cancellationToken); + DateTime UtcNow { get; } + } +} diff --git a/SeaHaven.Services/Interfaces/IVendorPortalService.cs b/SeaHaven.Services/Interfaces/IVendorPortalService.cs index 1ba36c9..b60edfd 100644 --- a/SeaHaven.Services/Interfaces/IVendorPortalService.cs +++ b/SeaHaven.Services/Interfaces/IVendorPortalService.cs @@ -15,5 +15,7 @@ namespace SeaHaven.Services.Interfaces Task AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken); Task RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken); Task CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken); + Task UploadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, Microsoft.AspNetCore.Http.IFormFile file, CancellationToken cancellationToken); + Task DownloadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs new file mode 100644 index 0000000..8da8835 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs @@ -0,0 +1,11 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderAdvancedSearchService + { + Task> SearchAsync( + WorkOrderAdvancedSearchQueryDto query, + string? currentUserId); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs index bb83d5a..3970036 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs @@ -19,5 +19,11 @@ namespace SeaHaven.Services.Interfaces Task LogStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId); Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId); Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue); + Task LogWeekRolledAsync(int workOrderId, int oldCarriedOver, int newCarriedOver, string correlationId); + + Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null); + Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId); + Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId); + Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber); } } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderBoardCancelService.cs b/SeaHaven.Services/Interfaces/IWorkOrderBoardCancelService.cs new file mode 100644 index 0000000..d0b647b --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderBoardCancelService.cs @@ -0,0 +1,9 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderBoardCancelService + { + Task CancelAsync(int workOrderId, string? actorId); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderBoardCreateService.cs b/SeaHaven.Services/Interfaces/IWorkOrderBoardCreateService.cs new file mode 100644 index 0000000..9818053 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderBoardCreateService.cs @@ -0,0 +1,9 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderBoardCreateService + { + Task CreateAsync(WorkOrderBoardCreateRequestDto request, string? actorId); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs b/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs new file mode 100644 index 0000000..2a964cf --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs @@ -0,0 +1,11 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderBoardService + { + Task GetBoardAsync(WorkOrderBoardQueryDto query, string? currentUserId); + Task> GetDispatcherLookupsAsync(); + Task GetBoardRowAsync(int workOrderId); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderBoardUpdateService.cs b/SeaHaven.Services/Interfaces/IWorkOrderBoardUpdateService.cs new file mode 100644 index 0000000..7631d35 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderBoardUpdateService.cs @@ -0,0 +1,9 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderBoardUpdateService + { + Task PatchFieldAsync(int workOrderId, WorkOrderBoardPatchRequestDto request, string? actorId); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs b/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs new file mode 100644 index 0000000..9b1e2a2 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs @@ -0,0 +1,11 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderCommentService + { + Task?> GetCommentsAsync(int workOrderId); + Task AddCommentAsync(int workOrderId, WorkOrderCommentCreateDto request, string? actorId, string? documentUrl = null); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs b/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs new file mode 100644 index 0000000..333e906 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs @@ -0,0 +1,19 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderCompletionService + { + Task> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType); + Task GetTemplateByIdAsync(int id); + Task UploadCompletionDocAsync( + int workOrderId, + WorkOrderCompletionDocUploadDto request, + string fileUrl, + string? actorId); + Task CreateTemplateAsync(CompletionDocTemplateCreateDto request); + Task UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request); + Task DeleteTemplateAsync(int id); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs b/SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs new file mode 100644 index 0000000..c59f968 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs @@ -0,0 +1,10 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderDetailService + { + Task GetDetailAsync(int workOrderId); + Task?> GetAuditAsync(int workOrderId, int limit = 50); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs new file mode 100644 index 0000000..015cc83 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs @@ -0,0 +1,12 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderMediaService + { + Task?> GetMediaAsync(int workOrderId); + Task AddMediaAsync(int workOrderId, WorkOrderMediaCategory category, string fileUrl, string? actorId); + Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderPhase7Services.cs b/SeaHaven.Services/Interfaces/IWorkOrderPhase7Services.cs new file mode 100644 index 0000000..0242a38 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderPhase7Services.cs @@ -0,0 +1,16 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderIngestService + { + Task UpsertBatchAsync( + IReadOnlyList items, + CancellationToken cancellationToken = default); + } + + public interface IWorkOrderOpsHealthService + { + Task GetHealthAsync(CancellationToken cancellationToken = default); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs b/SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs new file mode 100644 index 0000000..b8c1a6a --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs @@ -0,0 +1,11 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderWeekRolledService + { + Task ProcessWeekRolledAsync( + DateOnly sourceWeekStart, + CancellationToken cancellationToken = default); + } +} diff --git a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs new file mode 100644 index 0000000..8d0564d --- /dev/null +++ b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs @@ -0,0 +1,94 @@ +using FluentValidation; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Validation +{ + public interface IWorkOrderBoardCreateValidation : IValidator + { + } + + public class WorkOrderBoardCreateValidation : AbstractValidator, IWorkOrderBoardCreateValidation + { + public WorkOrderBoardCreateValidation() + { + RuleFor(x => x.WorkOrderType) + .IsInEnum().WithMessage("workOrderType is required and must be a valid value."); + + RuleFor(x => x.SiteCode) + .NotEmpty().WithMessage("siteCode is required.") + .MaximumLength(32); + + RuleFor(x => x.TargetWeek) + .NotNull().WithMessage("targetWeek is required when scheduleWeekOnly is true.") + .When(x => x.ScheduleWeekOnly == true); + + RuleFor(x => x.Description) + .MaximumLength(2000) + .When(x => !string.IsNullOrEmpty(x.Description)); + + RuleFor(x => x.Trade) + .MaximumLength(128) + .When(x => !string.IsNullOrEmpty(x.Trade)); + + RuleFor(x => x.PrimaryService) + .MaximumLength(128) + .When(x => !string.IsNullOrEmpty(x.PrimaryService)); + + RuleFor(x => x.ServiceNotes) + .MaximumLength(4000) + .When(x => !string.IsNullOrEmpty(x.ServiceNotes)); + + RuleFor(x => x.PocName) + .MaximumLength(200) + .When(x => !string.IsNullOrEmpty(x.PocName)); + + RuleFor(x => x.PocPhone) + .MaximumLength(50) + .When(x => !string.IsNullOrEmpty(x.PocPhone)); + + RuleFor(x => x.PocNotes) + .MaximumLength(2000) + .When(x => !string.IsNullOrEmpty(x.PocNotes)); + + RuleFor(x => x.TechPhone) + .MaximumLength(50) + .When(x => !string.IsNullOrEmpty(x.TechPhone)); + + RuleFor(x => x.VendorNotes) + .MaximumLength(2000) + .When(x => !string.IsNullOrEmpty(x.VendorNotes)); + + RuleFor(x => x.LocationId) + .GreaterThan(0) + .When(x => x.LocationId.HasValue); + + RuleFor(x => x.VendorId) + .GreaterThan(0) + .When(x => x.VendorId.HasValue); + + RuleFor(x => x.PocContactId) + .GreaterThan(0) + .When(x => x.PocContactId.HasValue); + + RuleFor(x => x) + .Must(HasPrimaryWhenExtras) + .WithMessage("primaryService (or trade) is required when extraServices is provided.") + .WithName("extraServices"); + + RuleForEach(x => x.ExtraServices) + .MaximumLength(128) + .When(x => x.ExtraServices != null); + } + + private static bool HasPrimaryWhenExtras(WorkOrderBoardCreateRequestDto request) + { + var hasExtras = request.ExtraServices != null + && request.ExtraServices.Any(s => !string.IsNullOrWhiteSpace(s)); + if (!hasExtras) + return true; + + return !string.IsNullOrWhiteSpace(request.PrimaryService) + || !string.IsNullOrWhiteSpace(request.Trade); + } + } +} diff --git a/SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs b/SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs new file mode 100644 index 0000000..15acce5 --- /dev/null +++ b/SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs @@ -0,0 +1,180 @@ +using System.Reflection; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Migrations; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace SeaHavenIndustries.Tests; + +public class Phase4SearchIndexesSqlServerTests +{ + private const string LocalDbMaster = + @"Server=(localdb)\MSSQLLocalDB;Database=master;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=3"; + + [Fact] + public void WorkOrder_IndexedStringColumns_HaveBoundedMaxLengths() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(LocalDbMaster) + .Options; + + using var context = new ApplicationDbContext(options); + var entity = context.Model.FindEntityType(typeof(WorkOrder)); + Assert.NotNull(entity); + + Assert.Equal(32, entity.FindProperty(nameof(WorkOrder.SiteCode))!.GetMaxLength()); + Assert.Equal(11, entity.FindProperty(nameof(WorkOrder.InternalWONumber))!.GetMaxLength()); + } + + [Fact] + public async Task Phase4_SearchIndexes_ApplyOnSqlServer_WhenLocalDbAvailable() + { + if (!await IsLocalDbAvailableAsync()) + return; + + var dbName = $"Phase4SearchIndexes_{Guid.NewGuid():N}"; + var connectionString = + $@"Server=(localdb)\MSSQLLocalDB;Database={dbName};Trusted_Connection=True;TrustServerCertificate=True"; + + try + { + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(); + + await using (var createTable = connection.CreateCommand()) + { + createTable.CommandText = + """ + CREATE TABLE workOrders ( + Id int NOT NULL IDENTITY PRIMARY KEY, + SiteCode nvarchar(max) NULL, + InternalWONumber nvarchar(max) NULL, + LifecycleStatus int NULL, + ScheduledDate datetime2 NULL, + istemplate bit NULL + ); + """; + await createTable.ExecuteNonQueryAsync(); + } + + var options = new DbContextOptionsBuilder() + .UseSqlServer(connection) + .Options; + + await using var context = new ApplicationDbContext(options); + await ApplyMigrationUpAsync(context, new Phase4_SearchIndexes()); + + await using (var assertCmd = connection.CreateCommand()) + { + assertCmd.CommandText = + """ + SELECT c.name, t.name AS type_name, c.max_length + FROM sys.columns c + INNER JOIN sys.types t ON c.user_type_id = t.user_type_id + WHERE c.object_id = OBJECT_ID(N'workOrders') + AND c.name IN (N'SiteCode', N'InternalWONumber') + ORDER BY c.name; + """; + + await using var reader = await assertCmd.ExecuteReaderAsync(); + var columns = new Dictionary(StringComparer.OrdinalIgnoreCase); + while (await reader.ReadAsync()) + { + columns[reader.GetString(0)] = (reader.GetString(1), reader.GetInt16(2)); + } + + Assert.Equal("nvarchar", columns["InternalWONumber"].Type); + Assert.Equal(22, columns["InternalWONumber"].MaxLength); // nvarchar(11) => 22 bytes + Assert.Equal("nvarchar", columns["SiteCode"].Type); + Assert.Equal(64, columns["SiteCode"].MaxLength); // nvarchar(32) => 64 bytes + } + + await using (var indexCmd = connection.CreateCommand()) + { + indexCmd.CommandText = + """ + SELECT name + FROM sys.indexes + WHERE object_id = OBJECT_ID(N'workOrders') + AND name IN ( + N'IX_workOrders_SiteCode', + N'IX_workOrders_InternalWONumber', + N'IX_workOrders_LifecycleStatus_ScheduledDate' + ); + """; + + await using var reader = await indexCmd.ExecuteReaderAsync(); + var indexes = new List(); + while (await reader.ReadAsync()) + indexes.Add(reader.GetString(0)); + + Assert.Contains("IX_workOrders_SiteCode", indexes); + Assert.Contains("IX_workOrders_InternalWONumber", indexes); + Assert.Contains("IX_workOrders_LifecycleStatus_ScheduledDate", indexes); + } + } + finally + { + await DropDatabaseAsync(dbName); + } + } + + private static async Task ApplyMigrationUpAsync(ApplicationDbContext context, Migration migration) + { + var builder = new MigrationBuilder(context.Database.ProviderName!); + var up = typeof(Migration).GetMethod("Up", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Migration.Up not found."); + up.Invoke(migration, [builder]); + + var sqlGenerator = context.GetService(); + var commands = sqlGenerator.Generate(builder.Operations, model: null); + foreach (var command in commands) + await context.Database.ExecuteSqlRawAsync(command.CommandText); + } + + private static async Task IsLocalDbAvailableAsync() + { + if (!OperatingSystem.IsWindows()) + return false; + + try + { + await using var connection = new SqlConnection(LocalDbMaster); + await connection.OpenAsync(); + return true; + } + catch (SqlException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + private static async Task DropDatabaseAsync(string dbName) + { + try + { + await using var connection = new SqlConnection(LocalDbMaster); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = + $""" + IF DB_ID(N'{dbName}') IS NOT NULL + BEGIN + ALTER DATABASE [{dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE [{dbName}]; + END + """; + await command.ExecuteNonQueryAsync(); + } + catch + { + // Best-effort cleanup for ephemeral LocalDB databases. + } + } +} diff --git a/SeaHavenIndustries.Tests/SeaHavenIndustries.Tests.csproj b/SeaHavenIndustries.Tests/SeaHavenIndustries.Tests.csproj index 5eb8d5d..e5dff3a 100644 --- a/SeaHavenIndustries.Tests/SeaHavenIndustries.Tests.csproj +++ b/SeaHavenIndustries.Tests/SeaHavenIndustries.Tests.csproj @@ -10,6 +10,7 @@ + @@ -20,8 +21,10 @@ + + diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs new file mode 100644 index 0000000..efad8f4 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -0,0 +1,152 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardCancelServiceTests +{ + private static (ApplicationDbContext Context, WorkOrderBoardCancelService Cancel, WorkOrderBoardUpdateService Update) CreateSut() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit); + var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit); + return (context, cancel, update); + } + + private static string ToVersion(WorkOrder workOrder) + => Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); + + [Fact] + public async Task Cancel_SetsLifecycleStatusCanceled() + { + var (context, cancel, _) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + Status = "Incomplete", + SiteCode = "BK5", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await cancel.CancelAsync(1, "actor-1"); + + Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus); + Assert.Equal("Canceled", result.LifecycleStatusLabel); + } + + [Fact] + public async Task Cancel_IsIdempotentWhenAlreadyCanceled() + { + var (context, cancel, _) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Canceled, + Status = "Canceled", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var before = await context.WorkOrderAuditLogs.CountAsync(); + var result = await cancel.CancelAsync(1, "actor-1"); + var after = await context.WorkOrderAuditLogs.CountAsync(); + + Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus); + Assert.Equal(before, after); + } + + [Fact] + public async Task Cancel_BlocksWhenCompleted() + { + var (context, cancel, _) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Completed, + Status = "Completed" + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => cancel.CancelAsync(1, "actor-1")); + Assert.Equal("CancelNotAllowed", ex.Code); + } + + [Fact] + public async Task Cancel_BlocksWhenClosedLabel() + { + var (context, cancel, _) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Completed, + Status = "Closed" + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => cancel.CancelAsync(1, "actor-1")); + Assert.Equal("CancelNotAllowed", ex.Code); + } + + [Fact] + public async Task Cancel_WritesStatusChangedAudit() + { + var (context, cancel, _) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + Status = "Scheduled", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + await cancel.CancelAsync(1, "actor-1"); + + var log = await context.WorkOrderAuditLogs.SingleAsync(l => l.Action == "StatusChanged"); + Assert.Equal("Canceled", log.NewValue); + } + + [Fact] + public async Task Patch_AfterCancel_IsBlocked() + { + var (context, cancel, update) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + Status = "Incomplete", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + await cancel.CancelAsync(1, "actor-1"); + + var ex = await Assert.ThrowsAsync(() => + update.PatchFieldAsync(1, new SeaHaven.Services.DTOs.WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "NEW", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("CanceledReadOnly", ex.Code); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs new file mode 100644 index 0000000..16f8e9e --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs @@ -0,0 +1,185 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardConcurrencyTests +{ + private static ApplicationDbContext CreateContext(string dbName) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrderBoardUpdateService CreateService(ApplicationDbContext context) + { + var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + return new WorkOrderBoardUpdateService(boardData, mutationData, audit); + } + + [Fact] + public async Task ParallelVendorAndStatusEdits_OnDifferentAggregates_Succeed() + { + var dbName = Guid.NewGuid().ToString(); + await using var seedContext = CreateContext(dbName); + seedContext.Vendors.Add(new Vendor { Id = 3, CompanyName = "Parallel Vendor" }); + var dispatch = new Dispatch + { + Id = 10, + VendorId = 3, + WorkOrderId = 1, + RowVersion = new byte[] { 9, 0, 0, 0, 0, 0, 0, 9 } + }; + seedContext.Dispatches.Add(dispatch); + seedContext.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 8, 0, 0, 0, 0, 0, 0, 8 } + }); + await seedContext.SaveChangesAsync(); + + await using var woContext = CreateContext(dbName); + await using var dispatchContext = CreateContext(dbName); + var woService = CreateService(woContext); + var dispatchService = CreateService(dispatchContext); + + var wo = await woContext.workOrders.FindAsync(1); + var disp = await dispatchContext.Dispatches.FindAsync(10); + + var statusTask = woService.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.LifecycleStatus, + Value = "Scheduled", + WorkOrderVersion = Convert.ToBase64String(wo!.RowVersion!) + }, "dispatcher-a"); + + var vendorTask = dispatchService.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.VendorId, + Value = "3", + WorkOrderVersion = Convert.ToBase64String(wo.RowVersion!), + DispatchVersion = Convert.ToBase64String(disp!.RowVersion!), + PrimaryDispatchId = 10 + }, "dispatcher-b"); + + await Task.WhenAll(statusTask, vendorTask); + + Assert.Equal(LifecycleStatus.Scheduled, statusTask.Result.LifecycleStatus); + Assert.Equal(3, vendorTask.Result.VendorId); + } + + [Fact] + public async Task StaleWorkOrderVersion_ThrowsConcurrencyWithCurrentState() + { + var dbName = Guid.NewGuid().ToString(); + await using var context1 = CreateContext(dbName); + context1.workOrders.Add(new WorkOrder + { + Id = 1, + SiteCode = "A", + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context1.SaveChangesAsync(); + + await using var context2 = CreateContext(dbName); + var wo2 = await context2.workOrders.FirstAsync(); + wo2.SiteCode = "B"; + await context2.SaveChangesAsync(); + + await using var patchContext = CreateContext(dbName); + var service = CreateService(patchContext); + var staleVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "C", + WorkOrderVersion = staleVersion + }, "actor-1")); + + Assert.NotNull(ex.CurrentState); + Assert.Equal("B", ex.CurrentState!.SiteCode); + } + + [Fact] + public async Task RescheduleAuditCount_MatchesChangedFields() + { + var dbName = Guid.NewGuid().ToString(); + await using var context = CreateContext(dbName); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 20), + RescheduleCount = 0, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + var service = CreateService(context); + var wo = await context.workOrders.FindAsync(1); + + await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ScheduledDate, + Value = "2026-06-25", + WorkOrderVersion = Convert.ToBase64String(wo!.RowVersion!) + }, "actor-1"); + + var auditCount = await context.WorkOrderAuditLogs.CountAsync(); + Assert.Equal(2, auditCount); + } + + [Fact] + public async Task SecondEditWithStaleVersion_FailsAfterFirstEdit() + { + var dbName = Guid.NewGuid().ToString(); + await using var seed = CreateContext(dbName); + seed.workOrders.Add(new WorkOrder + { + Id = 1, + SiteCode = "A", + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 4, 0, 0, 0, 0, 0, 0, 4 } + }); + await seed.SaveChangesAsync(); + + var originalVersion = Convert.ToBase64String(new byte[] { 4, 0, 0, 0, 0, 0, 0, 4 }); + + await using (var ctx1 = CreateContext(dbName)) + { + var svc1 = CreateService(ctx1); + await svc1.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "B", + WorkOrderVersion = originalVersion + }, "user-1"); + } + + await using var ctx2 = CreateContext(dbName); + var svc2 = CreateService(ctx2); + await Assert.ThrowsAsync(() => + svc2.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "C", + WorkOrderVersion = originalVersion + }, "user-2")); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs new file mode 100644 index 0000000..bd098de --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs @@ -0,0 +1,152 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardCreateRelationalTests +{ + [Fact] + public async Task Create_AuditAndFieldLocks_UseGeneratedWorkOrderId() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new SqliteBoardTestDbContext(options); + await context.Database.EnsureCreatedAsync(); + + const string actorId = "actor-1"; + context.Users.Add(new ApplicationUser + { + Id = actorId, + UserName = "actor-1", + NormalizedUserName = "ACTOR-1", + Email = "actor@test.local", + NormalizedEmail = "ACTOR@TEST.LOCAL" + }); + await context.SaveChangesAsync(); + + var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var service = new WorkOrderBoardCreateService( + boardData, + mutationData, + boardService, + audit, + new WorkOrderBoardCreateValidation()); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + Description = "Relational create" + }, actorId); + + Assert.True(result.Id > 0); + + var logs = await context.WorkOrderAuditLogs.AsNoTracking().ToListAsync(); + var locks = await context.WorkOrderFieldLocks.AsNoTracking().ToListAsync(); + + Assert.NotEmpty(logs); + Assert.NotEmpty(locks); + Assert.Contains(logs, l => l.Action == "Create"); + Assert.All(logs, l => Assert.Equal(result.Id, l.WorkOrderId)); + Assert.All(locks, l => Assert.Equal(result.Id, l.WorkOrderId)); + Assert.DoesNotContain(logs, l => l.WorkOrderId == 0); + Assert.DoesNotContain(locks, l => l.WorkOrderId == 0); + } + + [Fact] + public async Task Create_WhenContactPersistenceFails_RollsBackWorkOrder() + { + await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new SqliteBoardTestDbContext(options); + await context.Database.EnsureCreatedAsync(); + + const string actorId = "actor-1"; + context.Users.Add(new ApplicationUser + { + Id = actorId, + UserName = "actor-1", + NormalizedUserName = "ACTOR-1", + Email = "actor@test.local", + NormalizedEmail = "ACTOR@TEST.LOCAL" + }); + await context.SaveChangesAsync(); + + var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var service = new WorkOrderBoardCreateService( + boardData, + mutationData, + boardService, + audit, + new WorkOrderBoardCreateValidation()); + + await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + Description = "Should roll back", + PocContactId = 999_999 + }, actorId)); + + // Fresh context on the same connection so we observe committed state only. + await using var verify = new SqliteBoardTestDbContext(options); + Assert.Empty(await verify.workOrders.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.WorkOrderFieldLocks.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.WorkOrderContacts.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.Dispatches.AsNoTracking().ToListAsync()); + } + + private sealed class SqliteBoardTestDbContext : ApplicationDbContext + { + public SqliteBoardTestDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + // SQL Server filtered index syntax is invalid on SQLite. + foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes())) + { + if (index.GetFilter() != null) + index.SetFilter(null); + } + + // SQLite has no rowversion type; treat as plain nullable blobs. + foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) }) + { + var property = builder.Entity(entityType).Property("RowVersion").Metadata; + property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never; + property.IsConcurrencyToken = false; + } + } + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs new file mode 100644 index 0000000..9ac5114 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -0,0 +1,353 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardCreateServiceTests +{ + private static (ApplicationDbContext Context, WorkOrderBoardCreateService Service) CreateSut() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var validator = new WorkOrderBoardCreateValidation(); + var service = new WorkOrderBoardCreateService(boardData, mutationData, boardService, audit, validator); + return (context, service); + } + + [Fact] + public async Task Create_Minimal_ReturnsIncomplete() + { + var (_, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5" + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus); + Assert.Equal("BK5", result.SiteCode); + Assert.Equal(WorkOrderType.PM, result.WorkOrderType); + Assert.False(string.IsNullOrWhiteSpace(result.WoNumber)); + Assert.Equal(11, result.WoNumber!.Length); + } + + [Fact] + public async Task Create_WithManualWoNumber_NormalizesTo11Digits() + { + var (_, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WoNumber = "12345", + WorkOrderType = WorkOrderType.PO, + SiteCode = "DAL" + }, "actor-1"); + + Assert.Equal("00000012345", result.WoNumber); + } + + [Fact] + public async Task Create_DuplicateWoNumber_Throws() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder { InternalWONumber = "00000099999", WorkerOrderNumber = "00000099999" }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WoNumber = "99999", + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5" + }, "actor-1")); + + Assert.Equal("DuplicateWoNumber", ex.Code); + } + + [Fact] + public async Task Create_AutoSchedulesWhenDateAndAssignee() + { + var (_, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + AssignTo = "dispatcher-1", + ScheduledDate = new DateTime(2026, 6, 25) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus); + Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate); + } + + [Fact] + public async Task Create_WeekOnlyScheduling_SetsTargetWeek() + { + var (_, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + ScheduleWeekOnly = true, + TargetWeek = new DateOnly(2026, 6, 22) + }, "actor-1"); + + Assert.True(result.ScheduleWeekOnly); + Assert.Equal(new DateOnly(2026, 6, 22), result.TargetWeek); + } + + [Fact] + public async Task Create_WeekOnlyWithoutTargetWeek_ThrowsValidation() + { + var (_, service) = CreateSut(); + + await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + ScheduleWeekOnly = true + }, "actor-1")); + } + + [Fact] + public async Task Create_WithVendor_CreatesPrimaryDispatch() + { + var (context, service) = CreateSut(); + context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Acme HVAC" }); + await context.SaveChangesAsync(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + VendorId = 5, + ApptDate = new DateTime(2026, 6, 26) + }, "actor-1"); + + Assert.Equal(5, result.VendorId); + Assert.NotNull(result.PrimaryDispatchId); + } + + [Fact] + public async Task Create_CreatesFieldLocksForPopulatedFields() + { + var (context, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + Description = "Test WO" + }, "actor-1"); + + var locks = await context.WorkOrderFieldLocks.ToListAsync(); + Assert.Contains(locks, l => l.FieldName == "SiteCode"); + Assert.Contains(locks, l => l.FieldName == "WorkOrderType"); + Assert.Contains(locks, l => l.FieldName == "Description"); + Assert.All(locks, l => Assert.Equal(result.Id, l.WorkOrderId)); + } + + [Fact] + public async Task Create_WritesCreateAuditEvent() + { + var (context, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5" + }, "actor-1"); + + var logs = await context.WorkOrderAuditLogs.ToListAsync(); + Assert.Contains(logs, l => l.Action == "Create"); + Assert.Contains(logs, l => l.Action == "FieldChanged" && l.FieldName == "SiteCode"); + Assert.All(logs, l => Assert.Equal(result.Id, l.WorkOrderId)); + Assert.True(result.Id > 0); + } + + [Fact] + public async Task Create_StructuredServiceNotes_PersistsAndReturnsPm() + { + var (context, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + PrimaryService = "HVAC PM", + ExtraServices = new List { "Filter change", "Coil clean", "Filter change" }, + ServiceNotes = "Unit on roof" + }, "actor-1"); + + Assert.Equal("HVAC PM", result.Pm); + Assert.Equal("Unit on roof", result.ServiceNotes); + Assert.Equal(new[] { "Filter change", "Coil clean" }, result.ExtraServices); + + var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id); + Assert.Equal("HVAC PM", wo.Trade); + Assert.Equal("Unit on roof", wo.ServiceNotes); + Assert.Contains("Filter change", wo.ExtraServices); + Assert.Contains("Coil clean", wo.ExtraServices); + Assert.Equal(1, System.Text.Json.JsonSerializer.Deserialize>(wo.ExtraServices!)! + .Count(s => s == "Filter change")); + } + + [Fact] + public async Task Create_PrimaryServiceTakesPrecedenceOverTrade() + { + var (_, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.Reactive, + SiteCode = "CHI", + Trade = "Legacy Trade", + PrimaryService = "Plumbing" + }, "actor-1"); + + Assert.Equal("Plumbing", result.Pm); + } + + [Fact] + public async Task Create_ExtraServicesWithoutPrimary_ThrowsValidation() + { + var (_, service) = CreateSut(); + + await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + ExtraServices = new List { "Filter change" } + }, "actor-1")); + } + + [Fact] + public async Task Create_PocFreeText_WithoutContact_AppearsOnBoardRow() + { + var (context, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.Reactive, + SiteCode = "BK5", + PocName = "Jane Site Lead", + PocPhone = "+1 555-0100", + PocNotes = "Call 30 min before" + }, "actor-1"); + + Assert.Equal("Jane Site Lead", result.PocName); + Assert.Equal("+1 555-0100", result.PocPhone); + Assert.Equal("Call 30 min before", result.PocNotes); + + var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id); + Assert.Equal("Jane Site Lead", wo.PocName); + Assert.Empty(context.WorkOrderContacts); + } + + [Fact] + public async Task Create_WithoutVendor_PersistsTechPhoneAndVendorNotesOnWorkOrder() + { + var (context, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + TechPhone = "+1 555-0199", + VendorNotes = "Gate code 4421" + }, "actor-1"); + + Assert.Equal("+1 555-0199", result.TechPhone); + + var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id); + Assert.Equal("+1 555-0199", wo.TechPhone); + Assert.Equal("Gate code 4421", wo.VendorNotes); + Assert.Null(wo.PrimaryDispatchId); + } + + [Fact] + public async Task Create_WithVendor_PersistsTechPhoneAndVendorNotesOnDispatch() + { + var (context, service) = CreateSut(); + context.Vendors.Add(new Vendor { Id = 55, CompanyName = "Acme", Phone = "+1 555-0000" }); + await context.SaveChangesAsync(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + VendorId = 55, + ApptDate = new DateTime(2026, 7, 18), + TechPhone = "+1 555-0199", + VendorNotes = "Park behind dock" + }, "actor-1"); + + Assert.Equal("+1 555-0199", result.TechPhone); + Assert.Equal(55, result.VendorId); + + var wo = await context.workOrders.Include(w => w.PrimaryDispatch).SingleAsync(w => w.Id == result.Id); + Assert.Null(wo.TechPhone); + Assert.Null(wo.VendorNotes); + Assert.NotNull(wo.PrimaryDispatch); + Assert.Equal("+1 555-0199", wo.PrimaryDispatch!.TechPhone); + Assert.Equal("Park behind dock", wo.PrimaryDispatch.VendorNotes); + } + + [Fact] + public async Task Create_LegacyTradeAndDescription_StillWorks() + { + var (context, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + Trade = "HVAC", + Description = "pmNote\nPOC: Jane · +1 555\nVendor notes: gate" + }, "actor-1"); + + Assert.Equal("HVAC", result.Pm); + Assert.Null(result.PocName); + + var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id); + Assert.Equal("HVAC", wo.Trade); + Assert.Contains("Vendor notes:", wo.Description); + Assert.Null(wo.PocName); + Assert.Null(wo.ExtraServices); + Assert.Null(wo.ServiceNotes); + } + + [Fact] + public async Task Create_UnknownVendorId_Throws() + { + var (_, service) = CreateSut(); + + var ex = await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + VendorId = 999 + }, "actor-1")); + + Assert.Equal("VendorNotFound", ex.Code); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs new file mode 100644 index 0000000..cf89a8c --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs @@ -0,0 +1,103 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.Validation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardCreateSyncLockTests +{ + [Fact] + public async Task AfterShocCreate_SyncRejectsLockedSiteCode() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + + var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var createService = new WorkOrderBoardCreateService( + boardData, + mutationData, + boardService, + audit, + new WorkOrderBoardCreateValidation()); + var policy = new SyncFieldMergePolicy(fieldLocks); + + await createService.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5" + }, "dispatcher-1"); + + var wo = await context.workOrders.SingleAsync(); + var syncContext = new WorkOrderSyncContext + { + WorkOrder = wo, + FieldLocks = fieldLocks, + Audit = audit + }; + + var applied = await policy.TryApplyAsync(syncContext, "SiteCode", "SYNC-SITE"); + + Assert.False(applied); + Assert.Equal("BK5", wo.SiteCode); + + var rejected = await context.WorkOrderAuditLogs + .SingleAsync(l => l.Action == "SyncRejected" && l.FieldName == "SiteCode"); + Assert.Equal("SYNC-SITE", rejected.NewValue); + } + + [Fact] + public async Task AfterShocCreate_SyncCanUpdateUnlockedField_NotInShocSet() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + + var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var createService = new WorkOrderBoardCreateService( + boardData, + mutationData, + boardService, + audit, + new WorkOrderBoardCreateValidation()); + var policy = new SyncFieldMergePolicy(fieldLocks); + + await createService.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5" + }, "dispatcher-1"); + + var wo = await context.workOrders.SingleAsync(); + wo.Customer = "Original"; + await context.SaveChangesAsync(); + + var syncContext = new WorkOrderSyncContext + { + WorkOrder = wo, + FieldLocks = fieldLocks, + Audit = audit + }; + + // Customer is not in SyncFieldMergePolicy apply switch — use Description which wasn't set on create + var applied = await policy.TryApplyAsync(syncContext, "Description", "From sync"); + + Assert.True(applied); + Assert.Equal("From sync", wo.Description); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs new file mode 100644 index 0000000..933aa80 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs @@ -0,0 +1,65 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.Helpers; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardMutationRulesTests +{ + [Theory] + [InlineData(LifecycleStatus.Canceled, true)] + [InlineData(LifecycleStatus.Completed, true)] + [InlineData(LifecycleStatus.Scheduled, false)] + [InlineData(LifecycleStatus.Incomplete, false)] + public void IsReadOnly_ReflectsTerminalStatuses(LifecycleStatus status, bool expected) + { + Assert.Equal(expected, WorkOrderBoardMutationRules.IsReadOnly(status)); + } + + [Fact] + public void ShouldBlockStatusChangeWhenPastDue_BlocksOnlyLifecycleStatus() + { + Assert.True(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue( + WorkOrderBoardFieldNames.LifecycleStatus, isPastDue: true)); + Assert.False(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue( + WorkOrderBoardFieldNames.ScheduledDate, isPastDue: true)); + } + + [Fact] + public void ShouldAutoSchedule_WhenIncompleteWithDateAndAssignee() + { + Assert.True(WorkOrderBoardMutationRules.ShouldAutoSchedule( + LifecycleStatus.Incomplete, + new DateTime(2026, 6, 25), + "dispatcher-1")); + } + + [Fact] + public void ShouldAutoSchedule_FalseWhenAlreadyScheduled() + { + Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule( + LifecycleStatus.Scheduled, + new DateTime(2026, 6, 25), + "dispatcher-1")); + } + + [Fact] + public void ShouldAutoSchedule_FalseWhenScheduleWeekOnly() + { + Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule( + LifecycleStatus.Incomplete, + new DateTime(2026, 6, 25), + "dispatcher-1", + scheduleWeekOnly: true)); + } + + [Fact] + public void IsReschedule_WhenPreviousDateExistsAndChanges() + { + Assert.True(WorkOrderBoardMutationRules.IsReschedule( + new DateTime(2026, 6, 20), + new DateTime(2026, 6, 25))); + Assert.False(WorkOrderBoardMutationRules.IsReschedule( + null, + new DateTime(2026, 6, 25))); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs new file mode 100644 index 0000000..290b63d --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -0,0 +1,240 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardSearchFilterTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Theory] + [InlineData("BK5", 1)] + [InlineData("10000000001", 1)] + [InlineData("HVAC Tech", 1)] + [InlineData("Acme HVAC", 1)] + [InlineData("sched", 1)] + public async Task Apply_MatchesExpectedField(string search, int expectedId) + { + await using var context = CreateContext(); + await SeedSearchFixtures(context); + + var ids = await WorkOrderBoardSearchFilter + .Apply(context.workOrders.AsQueryable(), search) + .Select(w => w.Id) + .ToListAsync(); + + Assert.Contains(expectedId, ids); + } + + [Fact] + public void NormalizeSearch_IgnoresSingleCharacter() + { + Assert.Null(WorkOrderBoardSearchFilter.NormalizeSearch("a")); + Assert.Null(WorkOrderBoardSearchFilter.NormalizeSearch(" ")); + Assert.Equal("ab", WorkOrderBoardSearchFilter.NormalizeSearch(" ab ")); + } + + [Fact] + public async Task Apply_LifecycleLabel_MatchesScheduled() + { + await using var context = CreateContext(); + await SeedSearchFixtures(context); + + var ids = await WorkOrderBoardSearchFilter + .Apply(context.workOrders.AsQueryable(), "scheduled") + .Select(w => w.Id) + .ToListAsync(); + + Assert.Single(ids); + Assert.Equal(1, ids[0]); + } + + private static async Task SeedSearchFixtures(ApplicationDbContext context) + { + context.Vendors.Add(new Vendor + { + Id = 1, + CompanyName = "Acme HVAC", + ContactName = "HVAC Tech", + Phone = "555-0100" + }); + context.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1 }); + context.workOrders.Add(new WorkOrder + { + Id = 1, + SiteCode = "BK5", + InternalWONumber = "10000000001", + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10 + }); + await context.SaveChangesAsync(); + } +} + +public class WorkOrderBoardSearchCountsTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Fact] + public async Task GetBoardRows_SearchReducesScheduledAndKeepsTotal() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + SiteCode = "BK5", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 2, + SiteCode = "BK6", + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 3, + SiteCode = "BK5", + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete + }); + + await context.SaveChangesAsync(); + + var service = new WorkOrderBoardDataService(context); + var result = await service.GetBoardRowsAsync(new SeaHaven.DataServices.Interfaces.WorkOrderBoardQuery( + weekStart, + weekStart.AddDays(4), + null, + false, + null, + "BK5", + null)); + + Assert.Equal(2, result.ScheduledTotalBeforeSearch); + Assert.Single(result.ScheduledRows); + Assert.Equal("BK5", result.ScheduledRows[0].SiteCode); + Assert.Single(result.UnscheduledRows); + Assert.Equal("BK5", result.UnscheduledRows[0].SiteCode); + } +} + +public class WorkOrderSearchDateRangeResolverTests +{ + [Fact] + public void Resolve_ThisWeek_UsesIsoMonday() + { + var reference = new DateOnly(2026, 6, 24); + var range = SeaHaven.Services.Helpers.WorkOrderSearchDateRangeResolver.Resolve( + SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek, + null, + null, + reference); + + Assert.Equal(new DateOnly(2026, 6, 22), range.From); + Assert.Equal(new DateOnly(2026, 6, 28), range.To); + } + + [Fact] + public void Resolve_Custom_RequiresDates() + { + var ex = Assert.Throws(() => + SeaHaven.Services.Helpers.WorkOrderSearchDateRangeResolver.Resolve( + SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + null, + null)); + + Assert.Contains("dateFrom", ex.Message); + } +} + +public class WorkOrderAdvancedSearchServiceTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Fact] + public async Task SearchAsync_FiltersBySiteAndPaginates() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + SiteCode = "BK5", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000001" + }, + new WorkOrder + { + Id = 2, + SiteCode = "BK6", + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000002" + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = weekStart, + DateTo = weekStart.AddDays(6), + Sites = new List { "BK5" }, + Page = 1, + PageSize = 50 + }, null); + + Assert.Equal(1, result.TotalCount); + Assert.Single(result.Items); + Assert.Equal("BK5", result.Items.First().SiteCode); + } + + [Fact] + public async Task SearchAsync_ClampsPageSizeToMax() + { + await using var context = CreateContext(); + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek, + PageSize = 500 + }, null); + + Assert.Equal(100, result.PageSize); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs new file mode 100644 index 0000000..8b1b867 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -0,0 +1,210 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardDataServiceTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Fact] + public async Task GetBoardRows_ReturnsScheduledInWeekAndUnscheduled() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC", ContactName = "Tech", Phone = "555-0100" }); + context.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0) + }); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + SiteCode = "BK5" + }, + new WorkOrder + { + Id = 2, + InternalWONumber = "10000000002", + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete, + SiteCode = "BK6" + }, + new WorkOrder + { + Id = 3, + InternalWONumber = "10000000003", + ScheduledDate = new DateTime(2026, 7, 1), + LifecycleStatus = LifecycleStatus.Scheduled + }); + + await context.SaveChangesAsync(); + + var service = new WorkOrderBoardDataService(context); + var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery( + weekStart, + weekStart.AddDays(4), + null, + false, + null, + null, + null)); + + Assert.Single(result.ScheduledRows); + Assert.Equal(1, result.ScheduledRows[0].Id); + Assert.Equal("Acme HVAC", result.ScheduledRows[0].VendorName); + Assert.Single(result.UnscheduledRows); + Assert.Equal(2, result.UnscheduledRows[0].Id); + Assert.Equal(1, result.ScheduledTotalBeforeSearch); + } + + [Fact] + public async Task GetBoardRows_FiltersByDispatcher() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + AssignTo = "user-a", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 2, + AssignTo = "user-b", + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled + }); + + await context.SaveChangesAsync(); + + var service = new WorkOrderBoardDataService(context); + var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery( + weekStart, + weekStart.AddDays(4), + new[] { "user-a" }, + false, + null, + null, + null)); + + Assert.Single(result.ScheduledRows); + Assert.Equal("user-a", result.ScheduledRows[0].AssignTo); + } + + [Fact] + public async Task GetBoardRows_MyWorkOrders_FiltersCurrentUser() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + AssignTo = "me", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 2, + AssignTo = "other", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled + }); + + await context.SaveChangesAsync(); + + var service = new WorkOrderBoardDataService(context); + var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery( + weekStart, + weekStart.AddDays(4), + null, + true, + null, + null, + "me")); + + Assert.Single(result.ScheduledRows); + Assert.Equal(1, result.ScheduledRows[0].Id); + } +} + +public class WorkOrderBoardServiceTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Fact] + public async Task GetBoardAsync_ComputesCountsAndPastDue() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc), + LifecycleStatus = LifecycleStatus.Scheduled + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(dataService); + + var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto + { + WeekStart = weekStart + }, null); + + Assert.Equal(1, response.Counts.Total); + Assert.Single(response.Scheduled); + Assert.True(response.Scheduled[0].IsPastDue); + Assert.Equal("wednesday", response.Scheduled[0].DayGroup); + } + + [Fact] + public async Task GetBoardAsync_InvalidWeekWindow_Throws() + { + await using var context = CreateContext(); + var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(context)); + + await Assert.ThrowsAsync(() => boardService.GetBoardAsync(new WorkOrderBoardQueryDto + { + WeekStart = new DateOnly(2026, 6, 22), + WeekEnd = new DateOnly(2026, 6, 15) + }, null)); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs new file mode 100644 index 0000000..883aeae --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -0,0 +1,801 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Constants; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardUpdateServiceTests +{ + private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var service = new WorkOrderBoardUpdateService(boardData, mutationData, audit); + return (context, service); + } + + private static string ToVersion(WorkOrder workOrder) + => Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); + + private static string ToVersion(Dispatch dispatch) + => Convert.ToBase64String(dispatch.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }); + + [Fact] + public async Task PatchField_AutoSchedulesIncompleteToScheduled() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + AssignTo = "user-a", + 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(LifecycleStatus.Scheduled, result.LifecycleStatus); + Assert.Equal(2, await context.WorkOrderAuditLogs.CountAsync()); + } + + [Fact] + public async Task PatchField_IncrementsRescheduleCount() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 20), + RescheduleCount = 1, + 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(2, result.RescheduleCount); + } + + [Fact] + public async Task PatchField_BlocksStatusChangeWhenPastDue() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = DateTime.UtcNow.Date.AddDays(-3), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.LifecycleStatus, + Value = "Complete", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("PastDueStatusBlocked", ex.Code); + } + + [Fact] + public async Task PatchField_RejectsOverdueAsWorkOrderType() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.Reactive, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.WorkOrderType, + Value = "Overdue", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("InvalidValue", ex.Code); + Assert.Contains("isPastDue", ex.Message, StringComparison.Ordinal); + Assert.Contains("scheduledDate", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task PatchField_BlocksEditWhenCanceled() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Canceled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "BK5", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("CanceledReadOnly", ex.Code); + } + + [Fact] + public async Task PatchField_VendorCreatesPrimaryDispatch() + { + var (context, service) = CreateSut(); + context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Vendor Co" }); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.VendorId, + Value = "5", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.NotNull(result.PrimaryDispatchId); + Assert.Equal(5, result.VendorId); + Assert.Equal(1, await context.Dispatches.CountAsync()); + } + + [Fact] + public async Task PatchField_CreatesFieldLockAndAuditPerField() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + SiteCode = "OLD", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "BK5", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Single(await context.WorkOrderAuditLogs.Where(a => a.Action == AuditActionType.FieldChanged.ToString()).ToListAsync()); + Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.FieldName == "SiteCode")); + } + + [Fact] + public async Task PatchField_NormalizesWoNumber() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + InternalWONumber = "00000009999", + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.WoNumber, + Value = "12345", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal("00000012345", result.WoNumber); + } + + [Fact] + public async Task PatchField_RejectsDuplicateWoNumber() + { + var (context, service) = CreateSut(); + context.workOrders.AddRange( + new WorkOrder { Id = 1, InternalWONumber = "00000011111", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }, + new WorkOrder { Id = 2, InternalWONumber = "00000022222", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } }); + await context.SaveChangesAsync(); + + var wo = await context.workOrders.FindAsync(1); + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.WoNumber, + Value = "22222", + WorkOrderVersion = ToVersion(wo!) + }, "actor-1")); + + Assert.Equal("DuplicateWoNumber", ex.Code); + } + + [Fact] + public async Task PatchField_UpdatesPmTrade() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + Trade = "HVAC", + 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.Pm, + Value = "Plumbing", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal("Plumbing", result.Pm); + Assert.Equal("Plumbing", (await context.workOrders.FindAsync(1))!.Trade); + } + + [Fact] + public async Task PatchField_UpdatesServiceNotesAndReturnsOnBoardRow() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + Trade = "Backflow Preventers", + 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.ServiceNotes, + Value = "service notes", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal("service notes", result.ServiceNotes); + Assert.Equal("service notes", (await context.workOrders.FindAsync(1))!.ServiceNotes); + } + + [Fact] + public async Task PatchField_UpdatesExtraServices_WhenPrimaryExists() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + Trade = "Backflow Preventers", + 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.ExtraServices, + Value = "[\"Filter change\",\"Coil clean\"]", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(new[] { "Filter change", "Coil clean" }, result.ExtraServices); + Assert.Contains("Filter change", (await context.workOrders.FindAsync(1))!.ExtraServices); + } + + [Fact] + public async Task PatchField_ExtraServicesWithoutPrimary_Throws() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ExtraServices, + Value = "[\"Filter change\"]", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("InvalidValue", ex.Code); + } + + [Fact] + public async Task PatchField_SetsFlagColor_CanonicalUppercase() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.FlagColor, + Value = "#ef4444", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal("#EF4444", result.FlagColor); + Assert.Equal("#EF4444", (await context.workOrders.FindAsync(1))!.FlagColor); + } + + [Fact] + public async Task PatchField_ClearsFlagColor_WithNullOrEmpty() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + FlagColor = "#EF4444", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var cleared = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.FlagColor, + Value = "", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Null(cleared.FlagColor); + Assert.Null((await context.workOrders.FindAsync(1))!.FlagColor); + } + + [Fact] + public async Task PatchField_ClearsFlagColor_WithNullValue() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + FlagColor = "#3B82F6", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var cleared = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.FlagColor, + Value = null, + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Null(cleared.FlagColor); + Assert.Null((await context.workOrders.FindAsync(1))!.FlagColor); + } + + [Fact] + public async Task PatchField_FlagColorOutsidePalette_Throws() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.FlagColor, + Value = "#000000", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("InvalidValue", ex.Code); + } + + [Fact] + public async Task PatchField_FlagColorMalformed_Throws() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.FlagColor, + Value = "not-a-color", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("InvalidValue", ex.Code); + } + + [Fact] + public async Task PatchField_FlagColor_DoesNotRequireDispatchVersion() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.Dispatches.Add(new Dispatch + { + Id = 10, + WorkOrderId = 1, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.FlagColor, + Value = "#8B5CF6", + WorkOrderVersion = ToVersion(wo) + // no DispatchVersion + }, "actor-1"); + + Assert.Equal("#8B5CF6", result.FlagColor); + } + + [Fact] + public async Task PatchField_SecondEditSameField_DoesNotDuplicateFieldLock() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + SiteCode = "OLD", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "BK5", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + var updated = await context.workOrders.FindAsync(1); + var secondResult = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "BK6", + WorkOrderVersion = ToVersion(updated!) + }, "actor-1"); + + Assert.Equal("BK6", secondResult.SiteCode); + Assert.Single(await context.WorkOrderFieldLocks.Where(l => l.FieldName == "SiteCode").ToListAsync()); + Assert.Equal(2, await context.WorkOrderAuditLogs.Where(a => a.FieldName == "SiteCode").CountAsync()); + } + + [Fact] + public async Task PatchField_NotFound_ThrowsNotFoundCode() + { + var (_, service) = CreateSut(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(999, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "BK5", + WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }) + }, "actor-1")); + + Assert.Equal("NotFound", ex.Code); + } + + [Fact] + public async Task PatchField_WeekOnly_DoesNotAutoScheduleOnAssignTo() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + ScheduledDate = new DateTime(2026, 6, 25), + ScheduleWeekOnly = true, + TargetWeek = new DateOnly(2026, 6, 22), + 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.AssignTo, + Value = "user-a", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus); + Assert.Equal("user-a", result.DispatcherId); + } + + [Fact] + public async Task PatchField_ApptTime_UpdatesScheduledStart() + { + var (context, service) = CreateSut(); + var dispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + ScheduledDate = new DateTime(2026, 6, 25), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + ScheduledDate = new DateTime(2026, 6, 25), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" }); + context.Dispatches.Add(dispatch); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ApptTime, + Value = "09:00-10:00", + WorkOrderVersion = ToVersion(wo), + DispatchVersion = ToVersion(dispatch), + PrimaryDispatchId = 10 + }, "actor-1"); + + Assert.Contains("09:00", result.ApptTime); + var reloaded = await context.workOrders.FindAsync(1); + Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), reloaded!.ScheduledStart); + Assert.Equal(new DateTime(2026, 6, 25, 10, 0, 0), reloaded.ScheduledEnd); + } + + [Fact] + public async Task PatchField_ApptTime_DashPrefixedToken_TreatedAsSingleStart() + { + var (context, service) = CreateSut(); + var dispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + ScheduledDate = new DateTime(2026, 6, 25), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + ScheduledDate = new DateTime(2026, 6, 25), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" }); + context.Dispatches.Add(dispatch); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ApptTime, + Value = "-30", + WorkOrderVersion = ToVersion(wo), + DispatchVersion = ToVersion(dispatch), + PrimaryDispatchId = 10 + }, "actor-1"); + + var reloaded = await context.workOrders.FindAsync(1); + Assert.NotNull(reloaded!.ScheduledStart); + Assert.Equal(new DateTime(2026, 6, 25), reloaded.ScheduledEnd); + Assert.Equal(new DateTime(2026, 6, 25).Add(TimeSpan.FromDays(30)), reloaded.ScheduledStart); + } + + [Fact] + public async Task PatchField_ApptTime_LocksScheduledStartAndEnd_ForSync() + { + var (context, service) = CreateSut(); + var dispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + ScheduledDate = new DateTime(2026, 6, 25), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + ScheduledDate = new DateTime(2026, 6, 25), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" }); + context.Dispatches.Add(dispatch); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ApptTime, + Value = "09:00-10:00", + WorkOrderVersion = ToVersion(wo), + DispatchVersion = ToVersion(dispatch), + PrimaryDispatchId = 10 + }, "actor-1"); + + var lockNames = await context.WorkOrderFieldLocks + .Where(l => l.WorkOrderId == 1) + .Select(l => l.FieldName) + .ToListAsync(); + Assert.Contains(WorkOrderFieldNames.ScheduledStart, lockNames); + Assert.Contains(WorkOrderFieldNames.ScheduledEnd, lockNames); + Assert.DoesNotContain("ApptTime", lockNames); + + var locks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), locks); + var policy = new SyncFieldMergePolicy(locks); + var reloaded = await context.workOrders.SingleAsync(w => w.Id == 1); + var syncContext = new WorkOrderSyncContext + { + WorkOrder = reloaded, + FieldLocks = locks, + Audit = audit + }; + + var appliedStart = await policy.TryApplyAsync( + syncContext, WorkOrderFieldNames.ScheduledStart, "2026-06-25T08:00:00"); + var appliedEnd = await policy.TryApplyAsync( + syncContext, WorkOrderFieldNames.ScheduledEnd, "2026-06-25T11:00:00"); + + Assert.False(appliedStart); + Assert.False(appliedEnd); + Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), reloaded.ScheduledStart); + Assert.Equal(new DateTime(2026, 6, 25, 10, 0, 0), reloaded.ScheduledEnd); + } + + [Fact] + public async Task PatchField_DispatchFieldWithoutVersion_ThrowsDispatchVersionRequired() + { + var (context, service) = CreateSut(); + var dispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" }); + context.Dispatches.Add(dispatch); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ApptDate, + Value = "2026-06-26", + WorkOrderVersion = ToVersion(wo), + PrimaryDispatchId = 10 + }, "actor-1")); + + Assert.Equal("DispatchVersionRequired", ex.Code); + } + + [Fact] + public async Task PatchField_VendorIdNotFound_Throws() + { + var (context, service) = CreateSut(); + var dispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" }); + context.Dispatches.Add(dispatch); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.VendorId, + Value = "999", + WorkOrderVersion = ToVersion(wo), + DispatchVersion = ToVersion(dispatch), + PrimaryDispatchId = 10 + }, "actor-1")); + + Assert.Equal("VendorNotFound", ex.Code); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs new file mode 100644 index 0000000..1d7d2f6 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -0,0 +1,409 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderDocStatusPatchTests +{ + private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var service = new WorkOrderBoardUpdateService(boardData, mutationData, audit); + return (context, service); + } + + private static string ToVersion(WorkOrder workOrder) + => Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); + + [Theory] + [InlineData("Yes", DocStatus.Yes)] + [InlineData("No", DocStatus.No)] + [InlineData("NotNeeded", DocStatus.NotNeeded)] + public async Task PatchField_DocStatus_SetsValue(string value, DocStatus expected) + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.DocStatus, + Value = value, + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(expected, result.DocStatus); + var audit = await context.WorkOrderAuditLogs.SingleAsync(); + Assert.Equal("DocStatus", audit.FieldName); + } + + [Fact] + public async Task PatchField_DocStatus_ReadOnlyWorkOrder_Throws() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Canceled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.DocStatus, + Value = "Yes", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("CanceledReadOnly", ex.Code); + } +} + +public class WorkOrderDetailServiceTests +{ + private static (ApplicationDbContext Context, WorkOrderDetailService Service) CreateSut() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var detailData = new WorkOrderDetailDataService(context); + var templateData = new CompletionDocTemplateDataService(context); + var userData = new UserDataService(context); + var service = new WorkOrderDetailService(boardService, detailData, templateData, userData); + return (context, service); + } + + [Fact] + public async Task GetDetail_ReturnsDerivedFieldsAndFeAuditMapping() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + InternalWONumber = "00000000001", + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = DateTime.UtcNow.AddDays(-2), + Trade = "HVAC PM", + WorkOrderType = WorkOrderType.PM, + DocStatus = DocStatus.No, + Description = "Leak", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + context.CompletionDocTemplates.Add(new CompletionDocTemplate + { + Name = "HVAC PM Completion", + ServiceKey = "HVAC PM", + WorkOrderType = WorkOrderType.PM, + TemplateUrl = "https://example.com/hvac.pdf", + IsActive = true + }); + context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + { + WorkOrderId = 1, + EventType = "System", + Action = "WeekRolled", + FieldName = "CarriedOver", + OldValue = "0", + NewValue = "1", + CreatedAt = DateTime.UtcNow + }); + context.Comments.Add(new Comments + { + WorkerOrderId = 1, + UserId = "user-1", + Commenttext = "Called vendor", + CreatedDate = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc) + }); + await context.SaveChangesAsync(); + + var detail = await service.GetDetailAsync(1); + + Assert.NotNull(detail); + Assert.True(detail!.Info.IsPastDue); + Assert.Equal("HVAC PM", detail.Info.Trade); + Assert.Equal("Leak", detail.Info.Description); + Assert.NotNull(detail.Completion.Template); + Assert.Equal("HVAC PM Completion", detail.Completion.Template!.Name); + Assert.Single(detail.Comments); + Assert.Equal("user-1", detail.Comments[0].AuthorId); + Assert.Equal("Called vendor", detail.Comments[0].Text); + Assert.Single(detail.Audit); + Assert.Equal("system", detail.Audit[0].Type); + Assert.Equal("WeekRolled", detail.Audit[0].Action); + } +} + +public class WorkOrderCompletionServiceTests +{ + private static (ApplicationDbContext Context, WorkOrderCompletionService Service) CreateSut() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + var templateData = new CompletionDocTemplateDataService(context); + var completionData = new WorkOrderCompletionDataService(context); + var detailData = new WorkOrderDetailDataService(context); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var service = new WorkOrderCompletionService(completionData, templateData, detailData, audit); + return (context, service); + } + + [Fact] + public async Task UploadCompletionDoc_SetsDocStatusYesAndSignOff() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + Trade = "HVAC PM", + DocStatus = DocStatus.No, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.UploadCompletionDocAsync( + 1, + new WorkOrderCompletionDocUploadDto + { + SignOffName = "Jane Dispatcher", + WorkOrderVersion = Convert.ToBase64String(wo.RowVersion!) + }, + "https://example.com/signed.pdf", + "actor-1"); + + Assert.Equal(DocStatus.Yes, result.DocStatus); + Assert.Equal("https://example.com/signed.pdf", result.SignOffAttachment); + Assert.Equal("Jane Dispatcher", result.SignOffName); + + var updated = await context.workOrders.FindAsync(1); + Assert.Equal(DocStatus.Yes, updated!.DocStatus); + Assert.Equal(2, await context.WorkOrderAuditLogs.CountAsync()); + } + + [Fact] + public async Task UploadCompletionDoc_SecondUpload_DoesNotDuplicateFieldLocks() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + DocStatus = DocStatus.No, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var version = Convert.ToBase64String(wo.RowVersion!); + await service.UploadCompletionDocAsync( + 1, + new WorkOrderCompletionDocUploadDto { WorkOrderVersion = version }, + "https://example.com/first.pdf", + "actor-1"); + + // Simulate a new request scope: clear local tracker then reload + context.ChangeTracker.Clear(); + var reloaded = await context.workOrders.SingleAsync(w => w.Id == 1); + // InMemory does not bump rowversion; keep same bytes for the second call + reloaded.RowVersion ??= new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }; + + await service.UploadCompletionDocAsync( + 1, + new WorkOrderCompletionDocUploadDto + { + WorkOrderVersion = Convert.ToBase64String(reloaded.RowVersion!) + }, + "https://example.com/second.pdf", + "actor-1"); + + var locks = await context.WorkOrderFieldLocks.ToListAsync(); + Assert.Equal(1, locks.Count(l => l.FieldName == "SignOffAttachment")); + Assert.Equal(1, locks.Count(l => l.FieldName == "DocStatus")); + Assert.Equal("https://example.com/second.pdf", reloaded.SignOffAttachment); + } + + [Fact] + public async Task UploadCompletionDoc_MissingVersion_Throws() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.UploadCompletionDocAsync( + 1, + new WorkOrderCompletionDocUploadDto(), + "https://example.com/signed.pdf", + "actor-1")); + + Assert.Equal("WorkOrderVersionRequired", ex.Code); + } + + [Fact] + public async Task UploadCompletionDoc_ReadOnly_Throws() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Completed + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.UploadCompletionDocAsync( + 1, + new WorkOrderCompletionDocUploadDto + { + WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }) + }, + "https://example.com/signed.pdf", + "actor-1")); + + Assert.Equal("ReadOnly", ex.Code); + } +} + +public class WorkOrderCommentServiceTests +{ + [Fact] + public async Task AddComment_ReturnsFeSchema() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled }); + await context.SaveChangesAsync(); + + var detailData = new WorkOrderDetailDataService(context); + var commentData = new CommentDataService(context); + var service = new WorkOrderCommentService(detailData, commentData); + + var result = await service.AddCommentAsync(1, new WorkOrderCommentCreateDto { Text = "Note" }, "user-abc"); + + Assert.Equal("user-abc", result.AuthorId); + Assert.Equal("Note", result.Text); + Assert.False(string.IsNullOrWhiteSpace(result.Time)); + } +} + +public class WorkOrderMediaServiceTests +{ + [Fact] + public async Task GetMedia_IncludesLegacyBeforeAfterAndExtra() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + context.workOrders.Add(new WorkOrder + { + Id = 1, + BeforPhotoAttachment = "https://example.com/before.jpg", + AfterPhotoAttachment = "https://example.com/after.jpg" + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/extra.pdf", + Category = WorkOrderMediaCategory.Extra + }); + await context.SaveChangesAsync(); + + var detailData = new WorkOrderDetailDataService(context); + var mediaData = new WorkOrderMediaDataService(context); + var service = new WorkOrderMediaService(mediaData, detailData); + var media = await service.GetMediaAsync(1); + + Assert.NotNull(media); + Assert.Equal(3, media!.Count); + Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.Before); + Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.After); + Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.Extra); + } + + [Fact] + public async Task DeleteMedia_ReadOnlyWorkOrder_Throws() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Completed + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + Id = 10, + WorkorderId = 1, + Attachments = "https://example.com/extra.pdf", + Category = WorkOrderMediaCategory.Extra + }); + await context.SaveChangesAsync(); + + var service = new WorkOrderMediaService(new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context)); + var ex = await Assert.ThrowsAsync(() => + service.DeleteMediaAsync(1, 10, "actor-1")); + + Assert.Equal("ReadOnly", ex.Code); + } + + [Fact] + public async Task AddMedia_CompletionCategory_ThrowsUseCompletionDocEndpoint() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled + }); + await context.SaveChangesAsync(); + + var service = new WorkOrderMediaService(new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context)); + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync(1, WorkOrderMediaCategory.Completion, "https://example.com/doc.pdf", "actor-1")); + + Assert.Equal("UseCompletionDocEndpoint", ex.Code); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs new file mode 100644 index 0000000..8d6acfa --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs @@ -0,0 +1,242 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.Validation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderPhase7CoexistenceTests +{ + [Fact] + public void IngestFieldMapper_MapsDynamoStatus() + { + Assert.Equal("Open", WorkOrderIngestFieldMapper.MapStatus("new")); + Assert.Equal("In Progress", WorkOrderIngestFieldMapper.MapStatus("in_progress")); + Assert.Equal("Done", WorkOrderIngestFieldMapper.MapStatus("completed")); + } + + [Fact] + public async Task IngestService_CreatesThenUpdates_IdempotentByExternalId() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); + + var payload = new WorkOrderIngestPayloadDto + { + ExternalWorkOrderId = "ext-100", + Description = "Initial", + WoStatus = "new", + Severity = "2", + SiteCode = "BK1" + }; + + var create = await ingest.UpsertBatchAsync(new[] { payload }); + Assert.Equal(1, create.Created); + Assert.Equal(0, create.Updated); + + payload.Description = "Updated via ingest"; + var update = await ingest.UpsertBatchAsync(new[] { payload }); + Assert.Equal(0, update.Created); + Assert.Equal(1, update.Updated); + + var wo = await context.workOrders.SingleAsync(); + Assert.Equal("Updated via ingest", wo.Description); + Assert.Equal("ext-100", wo.ExternalWorkOrderId); + } + + [Fact] + public async Task IngestRespectsFieldLock_AfterShocBoardCreate() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + + var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var createService = new WorkOrderBoardCreateService( + boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation()); + var policy = new SyncFieldMergePolicy(fieldLocks); + var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), policy, fieldLocks, audit); + + await createService.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5" + }, "dispatcher-1"); + + var wo = await context.workOrders.SingleAsync(); + wo.ExternalWorkOrderId = "ext-shoc-1"; + await context.SaveChangesAsync(); + + await ingest.UpsertBatchAsync(new[] + { + new WorkOrderIngestPayloadDto + { + ExternalWorkOrderId = "ext-shoc-1", + SiteCode = "SYNC-SITE" + } + }); + + Assert.Equal("BK5", wo.SiteCode); + Assert.True(await context.WorkOrderAuditLogs.AnyAsync(l => l.Action == "SyncRejected")); + } + + [Fact] + public async Task IngestRespectsLocationIdFieldLock() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + + context.Locations.Add(new Locations { Id = 1, Name = "BK1", Status = "Active" }); + context.Locations.Add(new Locations { Id = 2, Name = "BK2", Status = "Active" }); + context.workOrders.Add(new WorkOrder + { + Id = 10, + ExternalWorkOrderId = "ext-loc-1", + LocationId = 1, + SiteCode = "BK1", + istemplate = false + }); + context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock + { + WorkOrderId = 10, + FieldName = "LocationId", + LockedAt = DateTime.UtcNow, + LockedByUserId = "dispatcher-1" + }); + await context.SaveChangesAsync(); + + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); + + await ingest.UpsertBatchAsync(new[] + { + new WorkOrderIngestPayloadDto + { + ExternalWorkOrderId = "ext-loc-1", + SiteCode = "BK2", + Building = "BK2" + } + }); + + var wo = await context.workOrders.SingleAsync(w => w.Id == 10); + Assert.Equal(1, wo.LocationId); + Assert.True(await context.WorkOrderAuditLogs.AnyAsync(l => + l.Action == "SyncRejected" && l.FieldName == "LocationId")); + } + + [Fact] + public async Task IngestBatch_SkipsItemsWithoutExternalId() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); + + var result = await ingest.UpsertBatchAsync(new[] + { + new WorkOrderIngestPayloadDto { ExternalWorkOrderId = " ", Description = "bad" }, + new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "ext-ok", Description = "good" } + }); + + Assert.Equal(1, result.Created); + Assert.Equal(1, result.Skipped); + Assert.Equal(1, result.Results.Count); + Assert.Equal("ext-ok", result.Results[0].ExternalWorkOrderId); + } + + [Fact] + public async Task OpsHealth_ReturnsCoexistenceMetrics() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + + context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock + { + WorkOrderId = 1, + FieldName = "SiteCode", + LockedAt = DateTime.UtcNow, + LockedByUserId = "u1" + }); + context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + { + WorkOrderId = 1, + Action = "SyncRejected", + FieldName = "Description", + CreatedAt = DateTime.UtcNow.AddHours(-1) + }); + await context.SaveChangesAsync(); + + var runState = new WorkOrderJobRunStateAccessor + { + State = { LastWeekRolledRunUtc = DateTime.UtcNow.AddDays(-1) } + }; + + var healthOptions = Options.Create(new WorkOrderOpsHealthOptions + { + SyncEnabled = true, + IngestEnabled = true, + LegacyDeprecationEnabled = true, + LegacySunsetDate = "2026-12-31" + }); + + var service = new WorkOrderOpsHealthService(new WorkOrderOpsHealthDataService(context), runState, healthOptions); + var health = await service.GetHealthAsync(); + + Assert.Equal(1, health.FieldLockCount); + Assert.Equal(1, health.SyncRejectedLast24h); + Assert.True(health.SyncEnabled); + Assert.True(health.IngestEnabled); + Assert.Equal("2026-12-31", health.LegacySunsetDate); + } + + [Fact] + public async Task BoardAndLegacyEndpoints_CanCoexist_InSameDatabase() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + + context.workOrders.Add(new WorkOrder + { + WorkerOrderTitle = "Legacy row", + Status = "Open", + InternalWONumber = "10000001", + istemplate = false + }); + await context.SaveChangesAsync(); + + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var count = await context.workOrders.CountAsync(); + + Assert.Equal(1, count); + Assert.NotNull(boardService); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs new file mode 100644 index 0000000..e98ee23 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs @@ -0,0 +1,347 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging.Abstractions; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderWeekRolledTests +{ + private static readonly DateOnly SourceWeekStart = new(2026, 6, 16); + private static readonly DateOnly SourceWeekEnd = new(2026, 6, 20); + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrderWeekRolledService CreateService(ApplicationDbContext context) + { + var dataService = new WorkOrderDomainJobDataService(context); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context))); + return new WorkOrderWeekRolledService( + dataService, + audit, + NullLogger.Instance); + } + + [Fact] + public async Task WeekRolled_IncrementsCarriedOver_ForEligibleWo() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Scheduled, + CarriedOver = 0 + }); + await context.SaveChangesAsync(); + + var service = CreateService(context); + var result = await service.ProcessWeekRolledAsync(SourceWeekStart); + + Assert.Equal(1, result.Processed); + Assert.Equal(0, result.Skipped); + Assert.Equal(0, result.Failed); + + var wo = await context.workOrders.FindAsync(1); + Assert.Equal(1, wo!.CarriedOver); + } + + [Fact] + public async Task WeekRolled_SkipsTerminalStatuses() + { + await using var context = CreateContext(); + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Completed + }, + new WorkOrder + { + Id = 2, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Canceled + }); + await context.SaveChangesAsync(); + + var service = CreateService(context); + var result = await service.ProcessWeekRolledAsync(SourceWeekStart); + + Assert.Equal(0, result.Processed); + Assert.Equal(0, result.Failed); + } + + [Fact] + public async Task WeekRolled_Idempotent_SecondRunSkips() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Scheduled, + CarriedOver = 0 + }); + await context.SaveChangesAsync(); + + var service = CreateService(context); + var first = await service.ProcessWeekRolledAsync(SourceWeekStart); + var second = await service.ProcessWeekRolledAsync(SourceWeekStart); + + Assert.Equal(1, first.Processed); + Assert.Equal(0, second.Processed); + Assert.Equal(0, second.Failed); + + var wo = await context.workOrders.FindAsync(1); + Assert.Equal(1, wo!.CarriedOver); + } + + [Fact] + public async Task WeekRolled_WritesAuditEvent() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.InProgress, + CarriedOver = 2 + }); + await context.SaveChangesAsync(); + + var service = CreateService(context); + await service.ProcessWeekRolledAsync(SourceWeekStart); + + var logs = await context.WorkOrderAuditLogs.ToListAsync(); + Assert.Single(logs); + Assert.Equal("WeekRolled", logs[0].Action); + Assert.Equal("System", logs[0].EventType); + Assert.Equal("System", logs[0].ActorType); + Assert.Equal("CarriedOver", logs[0].FieldName); + Assert.Equal("2", logs[0].OldValue); + Assert.Equal("3", logs[0].NewValue); + Assert.Equal("week:2026-06-16", logs[0].CorrelationId); + + var locks = await context.WorkOrderFieldLocks.ToListAsync(); + Assert.Empty(locks); + } + + [Fact] + public async Task WeekRolled_ConcurrentWithReschedule_DoesNotDuplicate() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Scheduled, + CarriedOver = 0 + }); + await context.SaveChangesAsync(); + + var dataService = new WorkOrderDomainJobDataService(context); + var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(SourceWeekStart); + + var first = await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId); + var second = await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId); + + Assert.True(first.Processed); + Assert.True(second.Skipped); + + var wo = await context.workOrders.FindAsync(1); + Assert.Equal(1, wo!.CarriedOver); + } + + [Fact] + public async Task WeekRolled_FailureReRun_IsSafeAfterPartialLedger() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Scheduled, + CarriedOver = 0 + }); + await context.SaveChangesAsync(); + + var dataService = new WorkOrderDomainJobDataService(context); + var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(SourceWeekStart); + + await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId); + + var service = CreateService(context); + var rerun = await service.ProcessWeekRolledAsync(SourceWeekStart); + + Assert.Equal(0, rerun.Processed); + Assert.Equal(0, rerun.Failed); + + var wo = await context.workOrders.FindAsync(1); + Assert.Equal(1, wo!.CarriedOver); + } + + [Fact] + public async Task WeekRolled_ProcessesAllEligible_WhenMoreThanBatchSize() + { + // WorkOrderWeekRolledService.BatchSize is 500; more than one page must all be carried over. + const int eligibleCount = 501; + + await using var context = CreateContext(); + for (var i = 1; i <= eligibleCount; i++) + { + context.workOrders.Add(new WorkOrder + { + Id = i, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Scheduled, + CarriedOver = 0 + }); + } + await context.SaveChangesAsync(); + + var service = CreateService(context); + var result = await service.ProcessWeekRolledAsync(SourceWeekStart); + + Assert.Equal(eligibleCount, result.Processed); + Assert.Equal(0, result.Skipped); + Assert.Equal(0, result.Failed); + + var carriedOverCount = await context.workOrders.CountAsync(w => w.CarriedOver == 1); + Assert.Equal(eligibleCount, carriedOverCount); + + var ledgerCount = await context.WorkOrderWeekRolledLedgers.CountAsync(); + Assert.Equal(eligibleCount, ledgerCount); + + var auditCount = await context.WorkOrderAuditLogs.CountAsync(l => l.Action == "WeekRolled"); + Assert.Equal(eligibleCount, auditCount); + } + + [Fact] + public async Task PastDueCache_SyncsOperationalFlags() + { + await using var context = CreateContext(); + var pastDate = DateTime.UtcNow.Date.AddDays(-3); + var futureDate = DateTime.UtcNow.Date.AddDays(3); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + ScheduledDate = pastDate, + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 2, + ScheduledDate = pastDate, + LifecycleStatus = LifecycleStatus.Completed, + OperationalFlags = OperationalFlags.PastDue + }, + new WorkOrder + { + Id = 3, + ScheduledDate = futureDate, + LifecycleStatus = LifecycleStatus.Scheduled, + OperationalFlags = OperationalFlags.PastDue + }); + await context.SaveChangesAsync(); + + var dataService = new WorkOrderDomainJobDataService(context); + var result = await dataService.RefreshPastDueFlagsAsync(DateTime.UtcNow); + + Assert.Equal(1, result.SetCount); + Assert.Equal(2, result.ClearedCount); + + var wo1 = await context.workOrders.FindAsync(1); + var wo2 = await context.workOrders.FindAsync(2); + var wo3 = await context.workOrders.FindAsync(3); + + Assert.True(wo1!.OperationalFlags.HasFlag(OperationalFlags.PastDue)); + Assert.False(wo2!.OperationalFlags.HasFlag(OperationalFlags.PastDue)); + Assert.False(wo3!.OperationalFlags.HasFlag(OperationalFlags.PastDue)); + } + + [Fact] + public async Task Board_ReflectsCarriedOver_AfterWeekRolled() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Scheduled, + CarriedOver = 0 + }); + await context.SaveChangesAsync(); + + var service = CreateService(context); + await service.ProcessWeekRolledAsync(SourceWeekStart); + + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var row = await boardService.GetBoardRowAsync(1); + + Assert.NotNull(row); + Assert.Equal(1, row!.CarriedOver); + } + + [Fact] + public async Task Board_IsPastDue_UnaffectedByCache() + { + await using var context = CreateContext(); + var pastDate = DateTime.UtcNow.Date.AddDays(-2); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = pastDate, + LifecycleStatus = LifecycleStatus.Scheduled, + OperationalFlags = OperationalFlags.None + }); + await context.SaveChangesAsync(); + + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var row = await boardService.GetBoardRowAsync(1); + + Assert.NotNull(row); + Assert.True(row!.IsPastDue); + Assert.False(WorkOrderDerivedFields.IsPastDue(pastDate, LifecycleStatus.Scheduled) == false); + } + + [Fact] + public async Task Board_IsPastDue_IgnoresStalePastDueFlag() + { + await using var context = CreateContext(); + var futureDate = DateTime.UtcNow.Date.AddDays(5); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = futureDate, + LifecycleStatus = LifecycleStatus.Scheduled, + OperationalFlags = OperationalFlags.PastDue + }); + await context.SaveChangesAsync(); + + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var row = await boardService.GetBoardRowAsync(1); + + Assert.NotNull(row); + Assert.False(row!.IsPastDue); + } +} diff --git a/SeaHavenIndustries/Data/Services/WorkorderService.cs b/SeaHavenIndustries/Data/Services/WorkorderService.cs index f616377..2e18cf2 100644 --- a/SeaHavenIndustries/Data/Services/WorkorderService.cs +++ b/SeaHavenIndustries/Data/Services/WorkorderService.cs @@ -22,14 +22,22 @@ namespace SeaHavenIndustries.Data.Services private UserManager _userManager; private UploadFileHp _uploadFileHelper; private readonly SendMessage _sendemail; + private readonly BlazorWorkOrderSunsetGuard _sunsetGuard; - public WorkorderService(SendMessage sendemail, ApplicationDbContext context, IHttpContextAccessor httpContextAccessor, UserManager userManager, UploadFileHp uploadFileHelper) + public WorkorderService( + SendMessage sendemail, + ApplicationDbContext context, + IHttpContextAccessor httpContextAccessor, + UserManager userManager, + UploadFileHp uploadFileHelper, + BlazorWorkOrderSunsetGuard sunsetGuard) { _context = context; _httpContextAccessor = httpContextAccessor; _userManager = userManager; _uploadFileHelper = uploadFileHelper; _sendemail = sendemail; + _sunsetGuard = sunsetGuard; } public async Task> Getworkorder() { @@ -178,6 +186,7 @@ namespace SeaHavenIndustries.Data.Services } public async Task Addworkorder(WorkOrder model, List files) { + _sunsetGuard.ThrowIfMutationsBlocked(); try { _context.ChangeTracker.Clear(); @@ -425,6 +434,7 @@ namespace SeaHavenIndustries.Data.Services } public async Task Deleteworkorder(int id) { + _sunsetGuard.ThrowIfMutationsBlocked(); _context.ChangeTracker.Clear(); try diff --git a/SeaHavenIndustries/Helper/BlazorWorkOrderSunsetGuard.cs b/SeaHavenIndustries/Helper/BlazorWorkOrderSunsetGuard.cs new file mode 100644 index 0000000..5c7bbf8 --- /dev/null +++ b/SeaHavenIndustries/Helper/BlazorWorkOrderSunsetGuard.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Options; +using SeaHavenIndustries.Options; + +namespace SeaHavenIndustries.Helper +{ + public class BlazorWorkOrderSunsetGuard + { + private readonly BlazorWorkOrderSunsetOptions _options; + + public BlazorWorkOrderSunsetGuard(IOptions options) + { + _options = options.Value; + } + + public bool IsSunsetEnabled => _options.Enabled; + + public string ShocBaseUrl => _options.ShocBaseUrl.TrimEnd('/'); + + public void ThrowIfMutationsBlocked() + { + if (_options.Enabled) + throw new InvalidOperationException( + "Work Orders module has moved to SHOC. Editing via Blazor is disabled."); + } + } +} diff --git a/SeaHavenIndustries/Options/BlazorWorkOrderSunsetOptions.cs b/SeaHavenIndustries/Options/BlazorWorkOrderSunsetOptions.cs new file mode 100644 index 0000000..e368d9f --- /dev/null +++ b/SeaHavenIndustries/Options/BlazorWorkOrderSunsetOptions.cs @@ -0,0 +1,11 @@ +namespace SeaHavenIndustries.Options +{ + public class BlazorWorkOrderSunsetOptions + { + public const string SectionName = "BlazorWorkOrderSunset"; + + public bool Enabled { get; set; } + + public string ShocBaseUrl { get; set; } = "https://shoc.seahaven.com"; + } +} diff --git a/SeaHavenIndustries/Program.cs b/SeaHavenIndustries/Program.cs index 5450abf..48b76c3 100644 --- a/SeaHavenIndustries/Program.cs +++ b/SeaHavenIndustries/Program.cs @@ -77,6 +77,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.Configure( + builder.Configuration.GetSection(SeaHavenIndustries.Options.BlazorWorkOrderSunsetOptions.SectionName)); +builder.Services.AddSingleton(); builder.Services.AddSession(options => { diff --git a/SeaHavenIndustries/appsettings.json b/SeaHavenIndustries/appsettings.json index 78d143e..fa26bef 100644 --- a/SeaHavenIndustries/appsettings.json +++ b/SeaHavenIndustries/appsettings.json @@ -1,10 +1,5 @@ { "ConnectionStrings": { - //"DefaultConnection": "Server=.;Database=SeahavenIndustries;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true", - //"DefaultConnection": "Server=.\\SqlExpress;Database=SeahavenIndustries;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true", - - // Provide the real value via environment variable ConnectionStrings__DefaultConnection, - // user-secrets, or appsettings.Development.json (which is gitignored). Do NOT commit credentials. "DefaultConnection": "${CONNECTION_STRING}", "excelconnection": "Provider=Microsoft.ACE.OLEDB.12.0;Data Source={0};Extended Properties='Excel 8.0;HDR=YES'" }, @@ -15,12 +10,14 @@ } }, "SendGrid": { - // Provide the real value via environment variable SendGrid__ApiKey or user-secrets. "ApiKey": "${SENDGRID_API_KEY}" }, "GoogleMaps": { - // Provide the real value via environment variable GoogleMaps__ApiKey or user-secrets. "ApiKey": "${GOOGLE_MAPS_API_KEY}" }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "BlazorWorkOrderSunset": { + "Enabled": false, + "ShocBaseUrl": "https://shoc.seahaven.com" + } } diff --git a/docs/work-orders/phase-1/README.md b/docs/work-orders/phase-1/README.md new file mode 100644 index 0000000..660bfb3 --- /dev/null +++ b/docs/work-orders/phase-1/README.md @@ -0,0 +1,159 @@ +# Fase 1 — Weekly Board (leitura) + +**Programa:** Work Orders Board +**Objetivo:** Endpoint window-based para carregar a visão semanal do SHOC (Mon–Fri + Unscheduled) com projeção das 14 colunas operacionais. + +--- + +## Endpoints + +### `GET /api/workorders/board` + +Retorna WOs agendadas na semana + seção **Unscheduled** em uma única chamada. + +**Autenticação:** Bearer JWT (`[Authorize]`) + +**Query params:** + +| Param | Obrigatório | Descrição | +|-------|-------------|-----------| +| `weekStart` | Sim (`BindRequired`) | Segunda-feira da semana (`YYYY-MM-DD`). Omitir retorna **400**. | +| `weekEnd` | Não | Default: `weekStart + 4 dias` (sexta). Janela pode ter até 7 dias. | +| `dispatchers` | Não | Lista de GUIDs; use `__unassigned__` para não atribuídos. **Ignorado** quando `myWorkOrders=true`. | +| `myWorkOrders` | Não | `true` → filtra `AssignTo == usuário logado` e tem **precedência** sobre `dispatchers` (não é AND). | +| `types` | Não | Valores enum `WorkOrderType` (`PM`, `PO`, `Emergency`, etc.) | +| `search` | Não | Busca contextual (site, WO#, location, dispatcher, trade, vendor, status) | + +**Exemplo:** + +```bash +curl -H "Authorization: Bearer " \ + "https://localhost:5001/api/workorders/board?weekStart=2026-06-22&myWorkOrders=true" +``` + +**Response:** + +```json +{ + "weekStart": "2026-06-22", + "weekEnd": "2026-06-26", + "counts": { "returned": 42, "total": 58 }, + "unscheduled": [ /* WorkOrderBoardRowDto[] */ ], + "scheduled": [ /* WorkOrderBoardRowDto[] — agrupar por dayGroup no FE */ ] +} +``` + +- `counts.total` — WOs agendadas na semana (filtros dispatcher/tipo, **sem** search) +- `counts.returned` — WOs agendadas após aplicar search +- `dayGroup` — `monday`…`friday` para dias úteis; **`null` no sábado/domingo** (a janela pode incluir fim de semana; o FE deve tratar `dayGroup` nulo) +- Referência de dia / past-due: **UTC** nesta fase (timezone de negócio fica para fase posterior) + +### `GET /api/workorders/lookups/dispatchers` + +Lista opções para o filtro/dropdown de assignee do board. + +**Escopo atual (placeholder):** todos os users com `IsDeleted != true` — ainda **sem** filtro por role. Quando roles de assignee estiverem definidos, este lookup será restrito. + +```json +[ + { "id": "guid", "name": "Jane Doe", "initials": "JD", "color": "#4A90D9" } +] +``` + +--- + +## Matriz coluna → campo API + +| Coluna board | Campos response | +|--------------|-----------------| +| WO# | `woNumber`, `rescheduleCount`, `carriedOver` | +| Type | `workOrderType`, `isPastDue` | +| Site | `siteCode`, `locationName`, `pocName`, `pocPhone`, `pocNotes` | +| Status | `lifecycleStatus`, `lifecycleStatusLabel`, `legacyStatus` | +| Assignee | `dispatcherId`, `dispatcherName`, `initials`, `color` | +| Due | `dueDate` | +| Scheduled | `scheduledDate`, `targetWeek`, `scheduleWeekOnly`, `dayGroup` | +| Vendor | `vendorId`, `vendorName`, `techName`, `techPhone` | +| Appt | `apptDate`, `apptTime` | +| Past Due | `isPastDue` (derivado on-read) | +| Carried | `carriedOver` | +| Reschedule | `rescheduleCount` | +| Doc | `docStatus` | +| Actions | FE only | + +--- + +## Regras de derivação + +### isPastDue + +``` +ScheduledDate < UTC hoje +AND LifecycleStatus NOT IN (Complete, Canceled, Closed) +``` + +Implementação: `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs` + +### Vendor / Appt + +Projeção read-only via `WorkOrder.PrimaryDispatchId` → `Dispatch` → `Vendor` ([ADR](../phase-0/adr-vendor-source-of-truth.md)). + +### Janela semanal + +WO entra em **scheduled** se: + +- `ScheduledDate` entre `weekStart` e `weekEnd`, **ou** +- `ScheduleWeekOnly == true` e `TargetWeek == weekStart` + +**Unscheduled:** `ScheduledDate == null` e status não terminal (`LifecycleStatusSets.Terminal`). + +--- + +## Código entregue + +| Camada | Arquivo | +|--------|---------| +| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` | +| Derived fields | `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs` | +| Data | `SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs` | +| Service | `SeaHaven.Services/Implementation/WorkOrderBoardService.cs` | +| API | `Api.SeaHavenIndustries/Controllers/WorkOrderController.cs` (`board`, `lookups/dispatchers`) | +| Migration | `Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs` | +| Testes | `SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs`, `WorkOrderBoardServiceTests.cs` | + +--- + +## Gates de aceite + +### Desenvolvimento (concluído) + +- [x] Endpoint `GET board` — semana + Unscheduled +- [x] Projeção 12/14 colunas de dados +- [x] `isPastDue` derivado on-read +- [x] Vendor via dispatch primário +- [x] Filtros dispatcher / My WOs / tipo / search +- [x] Contador X of Y +- [x] Índices Tier S +- [x] 14 testes unitários board (+ 12 Fase 0 = 26 total) + +### Staging / produção (pendente) + +- [ ] Sign-off CTO/PO (herda gates Fase 0) +- [ ] Dry-run migration Phase0 + Phase1 em staging +- [ ] Tier volume confirmado em staging/prod +- [ ] Smoke checklist Blazor / Sync / Portal +- [ ] Gate Vendor Portal regression (endpoints dispatch inalterados) +- [ ] Latência aceitável com volume real (Tier S: < 25k WOs) +- [ ] Feature flag SHOC board em staging + +--- + +## Coexistência + +Endpoints legados (`GetWorkOrderList`, `GetWorkorderById`, etc.) **não foram alterados**. O board usa contrato novo em rotas separadas. + +--- + +## Próximo passo + +**Fase 2** — Inline edit + concorrência (dual RowVersion, audit por campo, PATCH granular). diff --git a/scripts/load-test/README.md b/scripts/load-test/README.md new file mode 100644 index 0000000..c068043 --- /dev/null +++ b/scripts/load-test/README.md @@ -0,0 +1,40 @@ +# Load Test — Work Order Search (Tier S) + +Harness k6 para validar SLOs da Fase 4 em ambiente local ou staging. + +## Pré-requisitos + +- [k6](https://k6.io/docs/get-started/installation/) instalado +- API rodando com JWT válido +- (Opcional) Seed sintético: `scripts/seed-work-orders-search.ps1 -Count 15000` + +## Variáveis de ambiente + +| Variável | Descrição | Default | +|----------|-----------|---------| +| `BASE_URL` | URL base da API | `https://localhost:5001` | +| `JWT` | Bearer token | _(obrigatório)_ | +| `WEEK_START` | Segunda-feira ISO para board | `2026-06-22` | + +## Execução + +```powershell +$env:JWT = "" +$env:BASE_URL = "https://localhost:5001" +k6 run scripts/load-test/work-order-search.k6.js +``` + +## SLOs Tier S (p95) + +| Métrica | Limite | +|---------|--------| +| `board_no_search` | < 800ms | +| `board_with_search` | < 1000ms | +| `advanced_preset` | < 1200ms | +| `advanced_multi_filter` | < 1500ms | + +## Staging (pós Volume Discovery) + +1. Executar `scripts/volume-discovery.ps1` +2. Confirmar tier em `docs/work-orders/phase-4/search-tier-decision.md` +3. Repetir k6 com volume real; ajustar thresholds se tier = M diff --git a/scripts/load-test/work-order-search.k6.js b/scripts/load-test/work-order-search.k6.js new file mode 100644 index 0000000..ff69d67 --- /dev/null +++ b/scripts/load-test/work-order-search.k6.js @@ -0,0 +1,60 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend } from 'k6/metrics'; + +const boardNoSearch = new Trend('board_no_search', true); +const boardWithSearch = new Trend('board_with_search', true); +const advancedPreset = new Trend('advanced_preset', true); +const advancedMultiFilter = new Trend('advanced_multi_filter', true); + +const BASE_URL = __ENV.BASE_URL || 'https://localhost:5001'; +const JWT = __ENV.JWT || ''; +const WEEK_START = __ENV.WEEK_START || '2026-06-22'; + +export const options = { + vus: 10, + duration: '30s', + thresholds: { + board_no_search: ['p(95)<800'], + board_with_search: ['p(95)<1000'], + advanced_preset: ['p(95)<1200'], + advanced_multi_filter: ['p(95)<1500'], + }, +}; + +const headers = { + Authorization: `Bearer ${JWT}`, + Accept: 'application/json', +}; + +export default function () { + const boardRes = http.get( + `${BASE_URL}/api/workorders/board?weekStart=${WEEK_START}`, + { headers } + ); + boardNoSearch.add(boardRes.timings.duration); + check(boardRes, { 'board 200': (r) => r.status === 200 }); + + const searchRes = http.get( + `${BASE_URL}/api/workorders/board?weekStart=${WEEK_START}&search=BK5`, + { headers } + ); + boardWithSearch.add(searchRes.timings.duration); + check(searchRes, { 'board search 200': (r) => r.status === 200 }); + + const advRes = http.get( + `${BASE_URL}/api/workorders/board/search?datePreset=thisMonth&page=1&pageSize=50`, + { headers } + ); + advancedPreset.add(advRes.timings.duration); + check(advRes, { 'advanced preset 200': (r) => r.status === 200 }); + + const multiRes = http.get( + `${BASE_URL}/api/workorders/board/search?datePreset=thisMonth&sites=BK5&types=PM&search=HVAC&page=1`, + { headers } + ); + advancedMultiFilter.add(multiRes.timings.duration); + check(multiRes, { 'advanced multi 200': (r) => r.status === 200 }); + + sleep(0.5); +}