Merge remote-tracking branch 'origin/codex/backend-layering-refactor' into codex/backend-governance-quality-system

This commit is contained in:
Alexandre Brandizzi 2026-07-24 16:37:28 -03:00
commit f9f2bd9d3f
187 changed files with 27038 additions and 459 deletions

View file

@ -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 # 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; # and source files. Copy the values into one of the supported configuration sources;
# do NOT commit real values. # do NOT commit real values.
# #
# .NET resolves configuration in this order (later wins): # .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" # 2. User Secrets (local dev): dotnet user-secrets set "Key:Sub" "value"
# 3. Environment variables (use "__" as the section separator) # 3. Environment variables (use "__" as the section separator)
# #
@ -32,3 +32,16 @@ GoogleMaps__ApiKey=AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# AWS_ACCESS_KEY_ID= # AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY= # AWS_SECRET_ACCESS_KEY=
# AWS_REGION=us-east-2 # 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=<shared-secret-for-X-Ingest-Key-min-32-chars>
# --- 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

View file

@ -25,7 +25,7 @@ public class AuthenticationServiceTests
var (manager, s, h) = IdentityTestHelpers.CreateUserManager(); var (manager, s, h) = IdentityTestHelpers.CreateUserManager();
store = s; store = s;
hasher = h; 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() private static JwtOptions JwtOptions => new()

View file

@ -42,7 +42,7 @@ public class DispatchBatchingBehaviorTests
Mock.Of<ICommentDataService>(), Mock.Of<ICommentDataService>(),
emailPort.Object, emailPort.Object,
tokenPort.Object, tokenPort.Object,
Options.Create(new FrontendOptions())); Microsoft.Extensions.Options.Options.Create(new FrontendOptions()));
} }
private static Mock<IWorkOrderDataService> WorkOrderDataReturning(params WorkOrder[] workOrders) => private static Mock<IWorkOrderDataService> WorkOrderDataReturning(params WorkOrder[] workOrders) =>

View file

@ -17,7 +17,7 @@ internal static class IdentityTestHelpers
var manager = new UserManager<ApplicationUser>( var manager = new UserManager<ApplicationUser>(
store.Object, store.Object,
Options.Create(new IdentityOptions()), Microsoft.Extensions.Options.Options.Create(new IdentityOptions()),
hasher.Object, hasher.Object,
new List<IUserValidator<ApplicationUser>>(), new List<IUserValidator<ApplicationUser>>(),
new List<IPasswordValidator<ApplicationUser>>(), new List<IPasswordValidator<ApplicationUser>>(),

View file

@ -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<ApplicationDbContext>()
.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<ICreateLocationValidation>(),
Mock.Of<IUpdateLocationValidation>());
var controller = new LocationController(service, Mock.Of<ILogger<LocationController>>())
{
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<OkObjectResult>().Subject;
var data = ok.Value.Should().BeAssignableTo<IReadOnlyList<SiteOptionDTO>>().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<OkObjectResult>().Subject;
var data = ok.Value.Should().BeAssignableTo<IReadOnlyList<SiteOptionDTO>>().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<OkObjectResult>().Subject;
var data = ok.Value.Should().BeAssignableTo<IReadOnlyList<SiteOptionDTO>>().Subject;
data.Should().HaveCount(2);
data.Select(s => s.Code).Should().BeEquivalentTo("BK5", "DFW8");
}
}

View file

@ -210,7 +210,7 @@ public class WorkOrderDispatchServiceBehaviorTests
Mock.Of<ICommentDataService>(), Mock.Of<ICommentDataService>(),
Mock.Of<IDispatchEmailPort>(), Mock.Of<IDispatchEmailPort>(),
Mock.Of<IVendorTokenPort>(), Mock.Of<IVendorTokenPort>(),
Options.Create(new FrontendOptions())); Microsoft.Extensions.Options.Options.Create(new FrontendOptions()));
} }
} }

View file

@ -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<ApplicationDbContext>()
.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<object>().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<object>();
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<object>().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<SitePreferenceRequest>
{
new() { VendorId = inactive.Id }
},
CancellationToken.None);
result.Should().BeOfType<BadRequestObjectResult>();
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<BadRequestObjectResult>();
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<object>().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<OkObjectResult>();
context.Dispatches.Should().ContainSingle();
(await context.Dispatches.SingleAsync()).VendorId.Should().Be(replacementVendor.Id);
}
}

View file

@ -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<ApplicationDbContext>()
.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<IWebHostEnvironment>();
storageEnvironment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot);
var storage = new VendorDocumentStorageAdapter(storageEnvironment.Object);
var service = new VendorPortalService(
tokenService,
new DispatchDataService(context),
Mock.Of<IUpliftDataService>(),
Mock.Of<ICommentDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<IEmailSender>(),
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<ILogger<VendorPortalController>>());
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<BadRequestObjectResult>();
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<OkObjectResult>();
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<ObjectResult>().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<OkObjectResult>();
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<OkObjectResult>();
context.VendorCompletionDocuments.Should().HaveCount(1);
}
public void Dispose()
{
if (Directory.Exists(_contentRoot))
{
Directory.Delete(_contentRoot, recursive: true);
}
}
}

View file

@ -21,7 +21,7 @@ public class VendorServiceTests
data.Object, data.Object,
Mock.Of<IVendorPortalTokenService>(), Mock.Of<IVendorPortalTokenService>(),
Mock.Of<IZipCodeDistance>(), Mock.Of<IZipCodeDistance>(),
Options.Create(new FrontendOptions()), Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
new CreateVendorValidation(), new CreateVendorValidation(),
new UpdateVendorValidation(), new UpdateVendorValidation(),
new WorkOrderVendorUpdateValidation()); new WorkOrderVendorUpdateValidation());

View file

@ -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<IWorkOrderBoardService>(),
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
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<IWorkOrderAdvancedSearchDataService>());
var controller = NewController(advancedSearch);
var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom
});
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var body = badRequest.Value.Should().BeOfType<Response>().Subject;
body.Status.Should().Be("Error");
body.Message.Should().Contain("dateFrom");
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public class WorkOrderRouteContractTests
{
private readonly ITestOutputHelper _output;
private static readonly HashSet<Type> WorkOrderFamilyControllerTypes = new()
{
typeof(WorkOrderController),
typeof(WorkOrderBoardController),
typeof(WorkOrderDetailController),
typeof(WorkOrderCompletionController),
typeof(WorkOrderMediaController),
typeof(WorkOrderDispatchController),
};
/// <summary>
/// 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).
/// </summary>
private static readonly HashSet<string> 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<ActionDescriptor> BuildActionDescriptors()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddMvcCore()
.AddApplicationPart(typeof(WorkOrderController).Assembly);
using var provider = services.BuildServiceProvider();
var actionProvider = provider.GetRequiredService<IActionDescriptorCollectionProvider>();
return actionProvider.ActionDescriptors.Items;
}
private static IReadOnlyList<(string Verb, string FullTemplate, string Controller, string Action)> FullRoutesFor(
Func<ActionDescriptor, bool> 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<HttpMethodActionConstraint>()
.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<string> endpoints)
{
_output.WriteLine("WorkOrder-family public endpoints (verb + action-relative route):");
foreach (var endpoint in endpoints.OrderBy(x => x, StringComparer.Ordinal))
_output.WriteLine(" " + endpoint);
}
}

View file

@ -25,6 +25,13 @@ namespace Api.SeaHavenIndustries.Controllers
_logger = logger; _logger = logger;
} }
[HttpGet("sites")]
public async Task<IActionResult> GetSites(string? search = null)
{
var data = await _locationService.GetSiteOptionsAsync(search);
return Ok(data);
}
[HttpGet("GetLocationList")] [HttpGet("GetLocationList")]
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
{ {

View file

@ -1,24 +1,42 @@
using Api.SeaHavenIndustries.Options;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers namespace Api.SeaHavenIndustries.Controllers
{ {
[Authorize] [Authorize(Roles = "Admin")]
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
public class SyncController : Controller public class SyncController : Controller
{ {
private readonly ISyncService _syncService; private readonly ISyncService _syncService;
private readonly SyncOptions _syncOptions;
public SyncController(ISyncService syncService) public SyncController(ISyncService syncService, IOptions<SyncOptions> syncOptions)
{ {
_syncService = syncService; _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")] [HttpPost("WorkOrders")]
public async Task<IActionResult> SyncWorkOrders(CancellationToken cancellationToken) public async Task<IActionResult> SyncWorkOrders(CancellationToken cancellationToken)
{ {
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.SyncWorkOrdersAsync(cancellationToken); var result = await _syncService.SyncWorkOrdersAsync(cancellationToken);
return Ok(result); return Ok(result);
} }
@ -26,6 +44,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("Comments")] [HttpPost("Comments")]
public async Task<IActionResult> SyncComments(CancellationToken cancellationToken) public async Task<IActionResult> SyncComments(CancellationToken cancellationToken)
{ {
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.SyncCommentsAsync(cancellationToken); var result = await _syncService.SyncCommentsAsync(cancellationToken);
return Ok(result); return Ok(result);
} }
@ -33,6 +54,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("BackfillInternalWONumbers")] [HttpPost("BackfillInternalWONumbers")]
public async Task<IActionResult> BackfillInternalWONumbers(CancellationToken cancellationToken) public async Task<IActionResult> BackfillInternalWONumbers(CancellationToken cancellationToken)
{ {
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.BackfillInternalWONumbersAsync(cancellationToken); var result = await _syncService.BackfillInternalWONumbersAsync(cancellationToken);
return Ok(result); return Ok(result);
} }
@ -40,6 +64,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("BackfillDispatchNumbers")] [HttpPost("BackfillDispatchNumbers")]
public async Task<IActionResult> BackfillDispatchNumbers(CancellationToken cancellationToken) public async Task<IActionResult> BackfillDispatchNumbers(CancellationToken cancellationToken)
{ {
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.BackfillDispatchNumbersAsync(cancellationToken); var result = await _syncService.BackfillDispatchNumbersAsync(cancellationToken);
return Ok(result); return Ok(result);
} }
@ -47,6 +74,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("VendorReplies")] [HttpPost("VendorReplies")]
public async Task<IActionResult> SyncVendorReplies(CancellationToken cancellationToken) public async Task<IActionResult> SyncVendorReplies(CancellationToken cancellationToken)
{ {
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.SyncVendorRepliesAsync(cancellationToken); var result = await _syncService.SyncVendorRepliesAsync(cancellationToken);
return Ok(result); return Ok(result);
} }
@ -54,6 +84,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("BackfillCommentTypes")] [HttpPost("BackfillCommentTypes")]
public async Task<IActionResult> BackfillCommentTypes(CancellationToken cancellationToken) public async Task<IActionResult> BackfillCommentTypes(CancellationToken cancellationToken)
{ {
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.BackfillCommentTypesAsync(cancellationToken); var result = await _syncService.BackfillCommentTypesAsync(cancellationToken);
return Ok(result); return Ok(result);
} }
@ -61,6 +94,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("All")] [HttpPost("All")]
public async Task<IActionResult> SyncAll(CancellationToken cancellationToken) public async Task<IActionResult> SyncAll(CancellationToken cancellationToken)
{ {
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.SyncAllAsync(cancellationToken); var result = await _syncService.SyncAllAsync(cancellationToken);
return Ok(result); return Ok(result);
} }

View file

@ -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<T>(VendorOperationOutcome<T> 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<IActionResult> GetNotifications(CancellationToken cancellationToken)
{
var result = await _service.GetNotificationsAsync(cancellationToken);
return Ok(new { result.GeneratedAt, result.Items });
}
[HttpGet("availability")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> GetSitePreferences(int locationId, CancellationToken cancellationToken)
{
var preferences = await _service.GetSitePreferencesAsync(locationId, cancellationToken);
return Ok(preferences);
}
[HttpPut("sites/{locationId:int}/preferred-vendors")]
public async Task<IActionResult> ReplaceSitePreferences(int locationId, [FromBody] List<SitePreferenceRequest> body, CancellationToken cancellationToken)
{
var outcome = await _service.ReplaceSitePreferencesAsync(locationId, body ?? new List<SitePreferenceRequest>(), Actor(), cancellationToken);
return Map(outcome);
}
[HttpPut("documents/{documentId:int}/review")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<string>
{
"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<string> 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<int> { 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());
}
}
}

View file

@ -1,5 +1,6 @@
using Api.SeaHavenIndustries.Helper; using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs; 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<IActionResult> 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<IActionResult> 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<VendorPortalSession?> ResolveSessionAsync(CancellationToken cancellationToken) private async Task<VendorPortalSession?> ResolveSessionAsync(CancellationToken cancellationToken)
{ {
if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null; if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null;

View file

@ -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;
}
/// <summary>
/// Weekly board payload.
/// When <paramref name="myWorkOrders"/> is true, the authenticated user filter wins and
/// <paramref name="dispatchers"/> is ignored (not AND-combined).
/// Weekend WOs in a Mon–Sun window may have <c>dayGroup = null</c>; FE should handle that.
/// </summary>
[HttpGet("board")]
public async Task<IActionResult> GetBoard(
[FromQuery, BindRequired] DateOnly weekStart,
[FromQuery] DateOnly? weekEnd = null,
[FromQuery] List<string>? dispatchers = null,
[FromQuery] bool myWorkOrders = false,
[FromQuery] List<WorkOrderType>? 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<IActionResult> 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 });
}
}
/// <summary>
/// Dispatcher dropdown options. Currently returns all non-deleted users
/// (role-filtered assignee list deferred until roles settle).
/// </summary>
[HttpGet("lookups/dispatchers")]
public async Task<IActionResult> GetDispatcherLookups()
{
var result = await _workOrderBoardService.GetDispatcherLookupsAsync();
return Ok(result);
}
[HttpPatch("{id:int}/board")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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
});
}
}
}
}

View file

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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 });
}
}
}
}

View file

@ -5,9 +5,9 @@ using FluentValidation;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
using SeaHaven.DataServices.Models;
using System.Security.Claims; using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers namespace Api.SeaHavenIndustries.Controllers
@ -19,16 +19,13 @@ namespace Api.SeaHavenIndustries.Controllers
public class WorkOrderController : Controller public class WorkOrderController : Controller
{ {
private readonly IWorkOrderService _workOrderService; private readonly IWorkOrderService _workOrderService;
private readonly IWorkOrderDispatchService _dispatchService;
private readonly ILogger<WorkOrderController> _logger; private readonly ILogger<WorkOrderController> _logger;
public WorkOrderController( public WorkOrderController(
IWorkOrderService workOrderService, IWorkOrderService workOrderService,
IWorkOrderDispatchService dispatchService,
ILogger<WorkOrderController> logger) ILogger<WorkOrderController> logger)
{ {
_workOrderService = workOrderService; _workOrderService = workOrderService;
_dispatchService = dispatchService;
_logger = logger; _logger = logger;
} }
@ -134,6 +131,7 @@ namespace Api.SeaHavenIndustries.Controllers
return Ok(viewModel); return Ok(viewModel);
} }
[Authorize(Roles = "Admin")]
[HttpDelete] [HttpDelete]
[Route("DeleteWorkorder")] [Route("DeleteWorkorder")]
public async Task<IActionResult> Deleteworkorder([FromBody] EditWorkorder_DTO model) public async Task<IActionResult> Deleteworkorder([FromBody] EditWorkorder_DTO model)
@ -344,188 +342,5 @@ namespace Api.SeaHavenIndustries.Controllers
var data = await _workOrderService.GetWorkordersAsync(); var data = await _workOrderService.GetWorkordersAsync();
return Ok(data); return Ok(data);
} }
[HttpPost]
[Route("DispatchToVendor")]
public async Task<IActionResult> 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<IActionResult> GetDispatches(int workOrderId)
{
var data = await _dispatchService.GetDispatchesAsync(workOrderId);
return Ok(data);
}
[HttpGet("GetDispatch/{id}")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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);
}
} }
} }

View file

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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 });
}
}
}
}

View file

@ -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<WorkOrderDispatchController> _logger;
public WorkOrderDispatchController(
IWorkOrderDispatchService dispatchService,
ILogger<WorkOrderDispatchController> logger)
{
_dispatchService = dispatchService;
_logger = logger;
}
[HttpPost]
[Route("DispatchToVendor")]
public async Task<IActionResult> 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<IActionResult> GetDispatches(int workOrderId)
{
var data = await _dispatchService.GetDispatchesAsync(workOrderId);
return Ok(data);
}
[HttpGet("GetDispatch/{id}")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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);
}
}
}

View file

@ -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;
}
/// <summary>
/// Idempotent upsert by ExternalWorkOrderId (Lambda cutover target).
/// Auth: X-Ingest-Key header.
/// </summary>
[HttpPost("ingest")]
[IngestApiKey]
[ProducesResponseType(typeof(WorkOrderIngestBatchResultDto), StatusCodes.Status200OK)]
public async Task<ActionResult<WorkOrderIngestBatchResultDto>> 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<WorkOrderIngestPayloadDto> NormalizeItems(WorkOrderIngestBatchRequestDto? batch)
{
if (batch?.Items is { Count: > 0 })
return batch.Items;
return new List<WorkOrderIngestPayloadDto>();
}
}
}

View file

@ -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
{
/// <summary>Ops-only endpoints for manual domain job reprocessing.</summary>
[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;
}
/// <summary>
/// Reprocess WeekRolled for a specific operational week (Monday start).
/// Idempotent via WorkOrderWeekRolledLedger.
/// </summary>
[HttpPost("week-rolled")]
[ProducesResponseType(typeof(WeekRolledJobResult), StatusCodes.Status200OK)]
public async Task<ActionResult<WeekRolledJobResult>> 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);
}
/// <summary>
/// Refresh optional OperationalFlags.PastDue cache (board isPastDue remains on-read).
/// </summary>
[HttpPost("past-due-cache")]
[ProducesResponseType(typeof(PastDueCacheJobResult), StatusCodes.Status200OK)]
public async Task<ActionResult<PastDueCacheJobResult>> RunPastDueCache(CancellationToken cancellationToken)
{
var result = await _pastDueCacheService.RefreshAsync(cancellationToken);
return Ok(result);
}
}
}

View file

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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 });
}
}
}
}

View file

@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
/// <summary>Ops health and coexistence metrics (Fase 7).</summary>
[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<ActionResult<WorkOrderOpsHealthDto>> GetHealth(CancellationToken cancellationToken)
{
var health = await _healthService.GetHealthAsync(cancellationToken);
return Ok(health);
}
}
}

View file

@ -6,6 +6,8 @@ namespace Api.SeaHavenIndustries.DTOs
public List<int> VendorIds { get; set; } = new(); public List<int> VendorIds { get; set; } = new();
public decimal? NTEAmount { get; set; } public decimal? NTEAmount { get; set; }
public DateTime? ScheduledDate { get; set; } public DateTime? ScheduledDate { get; set; }
public DateTime? EstimatedArrivalAt { get; set; }
public bool EtaManualOverride { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
public int? TaskListTemplateId { get; set; } public int? TaskListTemplateId { get; set; }
public List<string>? CustomChecklistItems { get; set; } public List<string>? CustomChecklistItems { get; set; }

View file

@ -6,6 +6,8 @@ namespace Api.SeaHavenIndustries.DTOs
public string? Status { get; set; } public string? Status { get; set; }
public decimal? NTEAmount { get; set; } public decimal? NTEAmount { get; set; }
public DateTime? ScheduledDate { get; set; } public DateTime? ScheduledDate { get; set; }
public DateTime? EstimatedArrivalAt { get; set; }
public bool EtaManualOverride { get; set; }
public DateTime? CompletedDate { get; set; } public DateTime? CompletedDate { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
} }

View file

@ -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;
}
}
}

View file

@ -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
{
/// <summary>Validates X-Ingest-Key for POST /api/workorders/ingest.</summary>
public class IngestApiKeyFilter : IAsyncActionFilter
{
private readonly WorkOrderIngestOptions _options;
public IngestApiKeyFilter(IOptions<WorkOrderIngestOptions> 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))
{
}
}
}

View file

@ -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<DocumentScanResult> ScanAsync(string path, CancellationToken cancellationToken);
}
public class ClamAvDocumentScanner : IDocumentScanner
{
private readonly IConfiguration _configuration;
public ClamAvDocumentScanner(IConfiguration configuration)
{
_configuration = configuration;
}
public async Task<DocumentScanResult> ScanAsync(string path, CancellationToken cancellationToken)
{
var host = _configuration["ClamAV:Host"];
var port = _configuration.GetValue<int?>("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;
}
}
}
}

View file

@ -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<VendorDocumentScanWorker> _logger;
public VendorDocumentScanWorker(
IServiceScopeFactory scopeFactory,
IWebHostEnvironment environment,
ILogger<VendorDocumentScanWorker> 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<ApplicationDbContext>();
var scanner = scope.ServiceProvider.GetRequiredService<IDocumentScanner>();
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);
}
}

View file

@ -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<PastDueCacheHostedService> _logger;
private readonly PastDueCacheJobOptions _options;
private readonly WorkOrderJobRunState _runState;
private DateOnly? _lastRunDate;
public PastDueCacheHostedService(
IServiceScopeFactory scopeFactory,
IOptions<WorkOrderJobsOptions> options,
WorkOrderJobRunState runState,
ILogger<PastDueCacheHostedService> 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<bool> RunJobAsync(CancellationToken stoppingToken)
{
try
{
using var scope = _scopeFactory.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<IPastDueCacheService>();
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;
}
}
}

View file

@ -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<WorkOrderWeekRolledHostedService> _logger;
private readonly WeekRolledJobOptions _options;
private readonly WorkOrderJobRunState _runState;
private DateOnly? _lastRunDate;
public WorkOrderWeekRolledHostedService(
IServiceScopeFactory scopeFactory,
IOptions<WorkOrderJobsOptions> options,
WorkOrderJobRunState runState,
ILogger<WorkOrderWeekRolledHostedService> 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<bool> RunJobAsync(CancellationToken stoppingToken)
{
try
{
var sourceWeekStart = WorkOrderOperationalWeek.GetPreviousOperationalWeekStart(DateTime.UtcNow);
using var scope = _scopeFactory.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<IWorkOrderWeekRolledService>();
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<DayOfWeek>(_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;
}
}
}

View file

@ -72,4 +72,37 @@ namespace Api.SeaHavenIndustries.Infrastructure
return token.Token; 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);
}
}
}
} }

View file

@ -0,0 +1,57 @@
using Api.SeaHavenIndustries.Options;
using Microsoft.Extensions.Options;
namespace Api.SeaHavenIndustries.Middleware
{
/// <summary>Adds Sunset/Deprecation headers to legacy WorkOrder REST routes.</summary>
public class LegacyDeprecationMiddleware
{
private static readonly HashSet<string> LegacyPathSegments = new(StringComparer.OrdinalIgnoreCase)
{
"GetWorkOrderList",
"AddWorkorder",
"EditWorkorder",
"ChangeStatus",
"ChangeAssignment",
"DeleteWorkorder",
"GetWorkorderById"
};
private readonly RequestDelegate _next;
private readonly LegacyEndpointOptions _options;
public LegacyDeprecationMiddleware(RequestDelegate next, IOptions<LegacyEndpointOptions> 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"] = "</api/workorders/board>; rel=\"successor-version\"";
return Task.CompletedTask;
});
}
}
await _next(context);
}
}
}

View file

@ -0,0 +1,12 @@
namespace Api.SeaHavenIndustries.Options
{
public class LegacyEndpointOptions
{
public const string SectionName = "LegacyEndpoints";
public bool DeprecationEnabled { get; set; }
/// <summary>ISO date returned in Sunset header (e.g. 2026-12-31).</summary>
public string? SunsetDate { get; set; }
}
}

View file

@ -0,0 +1,10 @@
namespace Api.SeaHavenIndustries.Options
{
public class SyncOptions
{
public const string SectionName = "Sync";
/// <summary>When false, SyncController endpoints return 503 (post-Lambda cutover). Default false to avoid dual-run with ingest.</summary>
public bool Enabled { get; set; }
}
}

View file

@ -0,0 +1,13 @@
namespace Api.SeaHavenIndustries.Options
{
public class WorkOrderIngestOptions
{
public const string SectionName = "WorkOrderIngest";
/// <summary>When false, ingest endpoints return 503. Default false until a real ApiKey is provisioned.</summary>
public bool Enabled { get; set; }
/// <summary>Shared secret for X-Ingest-Key header (Lambda / service accounts).</summary>
public string? ApiKey { get; set; }
}
}

View file

@ -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";
}
}

View file

@ -1,4 +1,7 @@
using Api.SeaHavenIndustries.Helper; using Api.SeaHavenIndustries.Helper;
using Api.SeaHavenIndustries.HostedServices;
using Api.SeaHavenIndustries.Middleware;
using Api.SeaHavenIndustries.Options;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@ -9,6 +12,7 @@ using Microsoft.OpenApi.Models;
using System.Text; using System.Text;
using SeaHaven.DataServices.DependencyInjection; using SeaHaven.DataServices.DependencyInjection;
using SeaHaven.Services.DependencyInjection; using SeaHaven.Services.DependencyInjection;
using SeaHaven.Services.Implementation;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
ConfigurationManager configuration = builder.Configuration; ConfigurationManager configuration = builder.Configuration;
@ -25,6 +29,7 @@ builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
builder.Services.AddControllers(options => builder.Services.AddControllers(options =>
{ {
options.Filters.Add<Api.SeaHavenIndustries.Filters.ConcurrencyExceptionFilter>(); options.Filters.Add<Api.SeaHavenIndustries.Filters.ConcurrencyExceptionFilter>();
options.Filters.Add<Api.SeaHavenIndustries.Filters.ArgumentExceptionFilter>();
}).AddJsonOptions(options => }).AddJsonOptions(options =>
{ {
options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value
@ -40,6 +45,9 @@ option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().All
builder.Services.AddScoped<SendMessage>(); builder.Services.AddScoped<SendMessage>();
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.SendGridEmailSender>(); builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.SendGridEmailSender>();
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IEmailSender, Api.SeaHavenIndustries.Helper.SendGridEmailSender>(); builder.Services.AddScoped<SeaHaven.Services.Interfaces.IEmailSender, Api.SeaHavenIndustries.Helper.SendGridEmailSender>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<IDocumentScanner, ClamAvDocumentScanner>();
builder.Services.AddHostedService<VendorDocumentScanWorker>();
builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp => builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp =>
{ {
var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance(); var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance();
@ -57,6 +65,39 @@ builder.Services.AddScoped<SeaHaven.Services.Interfaces.ISyncExternalSource, Api
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IFileStoragePort, Api.SeaHavenIndustries.Infrastructure.FileStorageAdapter>(); builder.Services.AddScoped<SeaHaven.Services.Interfaces.IFileStoragePort, Api.SeaHavenIndustries.Infrastructure.FileStorageAdapter>();
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IDispatchEmailPort, Api.SeaHavenIndustries.Infrastructure.DispatchEmailAdapter>(); builder.Services.AddScoped<SeaHaven.Services.Interfaces.IDispatchEmailPort, Api.SeaHavenIndustries.Infrastructure.DispatchEmailAdapter>();
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IVendorTokenPort, Api.SeaHavenIndustries.Infrastructure.VendorTokenAdapter>(); builder.Services.AddScoped<SeaHaven.Services.Interfaces.IVendorTokenPort, Api.SeaHavenIndustries.Infrastructure.VendorTokenAdapter>();
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IVendorDocumentStoragePort, Api.SeaHavenIndustries.Infrastructure.VendorDocumentStorageAdapter>();
builder.Services.Configure<WorkOrderJobsOptions>(
builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
builder.Services.AddOptions<WorkOrderIngestOptions>()
.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<SyncOptions>(
builder.Configuration.GetSection(SyncOptions.SectionName));
builder.Services.Configure<LegacyEndpointOptions>(
builder.Configuration.GetSection(LegacyEndpointOptions.SectionName));
builder.Services.AddSingleton<WorkOrderJobRunStateAccessor>();
builder.Services.AddSingleton(sp => sp.GetRequiredService<WorkOrderJobRunStateAccessor>().State);
builder.Services.AddScoped<Api.SeaHavenIndustries.Filters.IngestApiKeyFilter>();
builder.Services.AddHostedService<WorkOrderWeekRolledHostedService>();
builder.Services.AddHostedService<PastDueCacheHostedService>();
builder.Services.AddOptions<SeaHaven.Services.Implementation.WorkOrderOpsHealthOptions>()
.Configure<Microsoft.Extensions.Options.IOptions<SyncOptions>,
Microsoft.Extensions.Options.IOptions<WorkOrderIngestOptions>,
Microsoft.Extensions.Options.IOptions<LegacyEndpointOptions>>(
(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 => builder.Services.AddAuthentication(options =>
{ {
@ -125,6 +166,7 @@ if (!app.Environment.IsDevelopment())
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseStaticFiles(); app.UseStaticFiles();
app.UseCors(); app.UseCors();
app.UseMiddleware<LegacyDeprecationMiddleware>();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();

View file

@ -14,6 +14,12 @@
// Provide the real value via environment variable SendGrid__ApiKey or the instance secret store. // Provide the real value via environment variable SendGrid__ApiKey or the instance secret store.
"ApiKey": "${SENDGRID_API_KEY}" "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": "*", "AllowedHosts": "*",
"JWT": { "JWT": {
"ValidAudience": "http://console.seahavenind.com", "ValidAudience": "http://console.seahavenind.com",

View file

@ -1,4 +1,4 @@
{ {
"ConnectionStrings": { "ConnectionStrings": {
//"DefaultConnection": "Server=DESKTOP-64LC14O\SQLEXPRESS;Database=SeahavenIndustries;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true", //"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. // Provide the real value via environment variable SendGrid__ApiKey or user-secrets.
"ApiKey": "${SENDGRID_API_KEY}" "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": "*", "AllowedHosts": "*",
"JWT": { "JWT": {
"ValidAudience": "http://localhost:4200", "ValidAudience": "http://localhost:4200",
"ValidIssuer": "http://localhost:7195", "ValidIssuer": "http://localhost:7195",
// Provide the real value via environment variable JWT__Secret or user-secrets. // Provide the real value via environment variable JWT__Secret or user-secrets.
"Secret": "${JWT_SECRET}" "Secret": "${JWT_SECRET}"
} },
} "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"
}

View file

@ -38,10 +38,51 @@ namespace Data.SeaHavenIndustries
.HasIndex(l => new { l.WorkOrderId, l.FieldName }) .HasIndex(l => new { l.WorkOrderId, l.FieldName })
.IsUnique(); .IsUnique();
builder.Entity<WorkOrderWeekRolledLedger>()
.HasIndex(l => new { l.WorkOrderId, l.SourceWeekStart })
.IsUnique();
builder.Entity<CompletionDocTemplate>()
.HasIndex(t => new { t.ServiceKey, t.IsActive });
builder.Entity<WorkOrder>() builder.Entity<WorkOrder>()
.Property(w => w.RowVersion) .Property(w => w.RowVersion)
.IsRowVersion(); .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<WorkOrder>()
.Property(w => w.SiteCode)
.HasMaxLength(32);
builder.Entity<WorkOrder>()
.Property(w => w.InternalWONumber)
.HasMaxLength(11);
builder.Entity<WorkOrder>()
.HasIndex(w => w.SiteCode)
.HasFilter("[istemplate] = 0");
builder.Entity<WorkOrder>()
.HasIndex(w => w.InternalWONumber)
.HasFilter("[istemplate] = 0");
builder.Entity<WorkOrder>()
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
.HasFilter("[istemplate] = 0");
builder.Entity<WorkOrder>()
.Property(w => w.ExternalWorkOrderId)
.HasMaxLength(450);
builder.Entity<WorkOrder>()
.HasIndex(w => w.ExternalWorkOrderId)
.IsUnique()
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
builder.Entity<Dispatch>() builder.Entity<Dispatch>()
.Property(d => d.RowVersion) .Property(d => d.RowVersion)
.IsRowVersion(); .IsRowVersion();
@ -61,6 +102,16 @@ namespace Data.SeaHavenIndustries
.WithMany(c => c.Vendors) .WithMany(c => c.Vendors)
.HasForeignKey(v => v.CompanyId) .HasForeignKey(v => v.CompanyId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
builder.Entity<SitePreferredVendor>()
.HasIndex(p => new { p.LocationId, p.VendorId, p.Trade })
.IsUnique()
.HasFilter("[Trade] IS NOT NULL");
builder.Entity<SitePreferredVendor>()
.HasIndex(p => new { p.LocationId, p.VendorId })
.IsUnique()
.HasFilter("[Trade] IS NULL");
} }
public DbSet<Category> Categories { get; set; } public DbSet<Category> Categories { get; set; }
public DbSet<Locations> Locations { get; set; } public DbSet<Locations> Locations { get; set; }
@ -74,13 +125,17 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderContacts> WorkOrderContacts { get; set; } public DbSet<WorkOrderContacts> WorkOrderContacts { get; set; }
public DbSet<WorkOrderCategories> workOrderCategories { get; set; } public DbSet<WorkOrderCategories> workOrderCategories { get; set; }
public DbSet<WorkOrderAttachments> workOrderAttachments { get; set; } public DbSet<WorkOrderAttachments> workOrderAttachments { get; set; }
public DbSet<CompletionDocTemplate> CompletionDocTemplates { get; set; }
public DbSet<WorkOrderAuditLog> WorkOrderAuditLogs { get; set; } public DbSet<WorkOrderAuditLog> WorkOrderAuditLogs { get; set; }
public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { get; set; } public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { get; set; }
public DbSet<WorkOrderWeekRolledLedger> WorkOrderWeekRolledLedgers { get; set; }
public DbSet<DropdownOption> DropdownOptions { get; set; } public DbSet<DropdownOption> DropdownOptions { get; set; }
public DbSet<Vendor> Vendors { get; set; } public DbSet<Vendor> Vendors { get; set; }
public DbSet<VendorCompany> VendorCompanies { get; set; } public DbSet<VendorCompany> VendorCompanies { get; set; }
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; } public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }
public DbSet<VendorAccessToken> VendorAccessTokens { get; set; } public DbSet<VendorAccessToken> VendorAccessTokens { get; set; }
public DbSet<VendorCompletionDocument> VendorCompletionDocuments { get; set; }
public DbSet<SitePreferredVendor> SitePreferredVendors { get; set; }
public DbSet<Dispatch> Dispatches { get; set; } public DbSet<Dispatch> Dispatches { get; set; }
public DbSet<DispatchWorkOrder> DispatchWorkOrders { get; set; } public DbSet<DispatchWorkOrder> DispatchWorkOrders { get; set; }
public DbSet<DispatchChecklistItem> DispatchChecklistItems { get; set; } public DbSet<DispatchChecklistItem> DispatchChecklistItems { get; set; }
@ -106,6 +161,51 @@ namespace Data.SeaHavenIndustries
public DbSet<Department> Departments { get; set; } public DbSet<Department> Departments { get; set; }
public DbSet<JobTitle> JobTitles { get; set; } public DbSet<JobTitle> JobTitles { get; set; }
public DbSet<Region> Regions { get; set; } public DbSet<Region> Regions { get; set; }
public override int SaveChanges()
{
BumpRowVersionsForInMemoryProvider();
return base.SaveChanges();
}
public override Task<int> 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<WorkOrder>())
{
if (entry.State == EntityState.Modified)
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
}
foreach (var entry in ChangeTracker.Entries<Dispatch>())
{
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 public class ApplicationUser : IdentityUser

View file

@ -0,0 +1,14 @@
namespace Data.SeaHavenIndustries.Enums
{
/// <summary>
/// Shared terminal lifecycle statuses for EF-translatable filters and board rules.
/// </summary>
public static class LifecycleStatusSets
{
public static readonly LifecycleStatus?[] Terminal =
{
LifecycleStatus.Completed,
LifecycleStatus.Canceled
};
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,84 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class Phase4_SearchIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "SiteCode",
table: "workOrders",
type: "nvarchar(32)",
maxLength: 32,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)");
migrationBuilder.AlterColumn<string>(
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");
}
/// <inheritdoc />
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<string>(
name: "SiteCode",
table: "workOrders",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(32)",
oldMaxLength: 32);
migrationBuilder.AlterColumn<string>(
name: "InternalWONumber",
table: "workOrders",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(11)",
oldMaxLength: 11);
}
}
}

View file

@ -0,0 +1,88 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260625120000_Phase6_CompletionSlideOver")]
public partial class Phase6_CompletionSlideOver : Migration
{
/// <inheritdoc />
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)) <> '';
");
}
/// <inheritdoc />
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;
");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class Phase1_BoardIndexes : Migration
{
/// <inheritdoc />
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
""");
}
/// <inheritdoc />
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;
""");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class Phase5_DomainEvents : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "WorkOrderWeekRolledLedgers",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
WorkOrderId = table.Column<int>(type: "int", nullable: false),
SourceWeekStart = table.Column<DateOnly>(type: "date", nullable: false),
ProcessedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CorrelationId = table.Column<string>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "WorkOrderWeekRolledLedgers");
}
}
}

View file

@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class Phase7_ExternalWorkOrderIdUnique : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
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] <> ''");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_workOrders_ExternalWorkOrderId",
table: "workOrders");
migrationBuilder.AlterColumn<string>(
name: "ExternalWorkOrderId",
table: "workOrders",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(450)",
oldMaxLength: 450,
oldNullable: true);
}
}
}

View file

@ -0,0 +1,121 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260715160000_WoServiceNotesStructuredFields")]
public partial class WoServiceNotesStructuredFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PocName",
table: "workOrders",
type: "nvarchar(200)",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PocPhone",
table: "workOrders",
type: "nvarchar(50)",
maxLength: 50,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PocNotes",
table: "workOrders",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExtraServices",
table: "workOrders",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ServiceNotes",
table: "workOrders",
type: "nvarchar(4000)",
maxLength: 4000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "TechPhone",
table: "workOrders",
type: "nvarchar(50)",
maxLength: 50,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "VendorNotes",
table: "workOrders",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "TechPhone",
table: "Dispatches",
type: "nvarchar(50)",
maxLength: 50,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "VendorNotes",
table: "Dispatches",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
}
/// <inheritdoc />
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");
}
}
}

View file

@ -0,0 +1,33 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260715180000_WoFlagColor")]
public partial class WoFlagColor : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "FlagColor",
table: "workOrders",
type: "nvarchar(7)",
maxLength: 7,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FlagColor",
table: "workOrders");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,230 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class AddVendorRoadmapOperations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AvailabilityStatus",
table: "Vendors",
type: "nvarchar(max)",
nullable: false,
defaultValue: "Unknown");
migrationBuilder.AddColumn<DateTime>(
name: "AvailabilityUpdatedAt",
table: "Vendors",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CommercialStatusUpdatedAt",
table: "Dispatches",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "EstimatedArrivalAt",
table: "Dispatches",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "EtaManualOverride",
table: "Dispatches",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "InvoiceNumber",
table: "Dispatches",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InvoiceStatus",
table: "Dispatches",
type: "nvarchar(max)",
nullable: false,
defaultValue: "Not Submitted");
migrationBuilder.AddColumn<string>(
name: "PaymentStatus",
table: "Dispatches",
type: "nvarchar(max)",
nullable: false,
defaultValue: "Unavailable");
migrationBuilder.CreateTable(
name: "SitePreferredVendors",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
LocationId = table.Column<int>(type: "int", nullable: false),
VendorId = table.Column<int>(type: "int", nullable: false),
Trade = table.Column<string>(type: "nvarchar(450)", nullable: true),
SortOrder = table.Column<int>(type: "int", nullable: false),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierUserId = table.Column<int>(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<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
VendorId = table.Column<int>(type: "int", nullable: false),
DispatchId = table.Column<int>(type: "int", nullable: false),
WorkOrderId = table.Column<int>(type: "int", nullable: false),
OriginalFileName = table.Column<string>(type: "nvarchar(max)", nullable: false),
StoredFileName = table.Column<string>(type: "nvarchar(max)", nullable: false),
ContentType = table.Column<string>(type: "nvarchar(max)", nullable: false),
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
ScanStatus = table.Column<string>(type: "nvarchar(max)", nullable: false),
ReviewStatus = table.Column<string>(type: "nvarchar(max)", nullable: false),
RejectionReason = table.Column<string>(type: "nvarchar(max)", nullable: true),
Version = table.Column<int>(type: "int", nullable: false),
ReplacesDocumentId = table.Column<int>(type: "int", nullable: true),
ScannedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
ReviewedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierUserId = table.Column<int>(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");
}
/// <inheritdoc />
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");
}
}
}

View file

@ -107,7 +107,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Accounts"); b.ToTable("Accounts", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b =>
@ -175,7 +175,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("ContactId"); b.HasIndex("ContactId");
b.ToTable("Addresses"); b.ToTable("Addresses", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
@ -334,7 +334,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId"); b.HasIndex("LocationId");
b.ToTable("Assets"); b.ToTable("Assets", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => modelBuilder.Entity("Data.SeaHavenIndustries.Category", b =>
@ -371,7 +371,62 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Categories"); b.ToTable("Categories", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ServiceKey")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("TemplateUrl")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int?>("WorkOrderType")
.HasColumnType("int");
b.Property<string>("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 => modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b =>
@ -438,7 +493,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkerOrderId"); b.HasIndex("WorkerOrderId");
b.ToTable("Comments"); b.ToTable("Comments", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
@ -504,7 +559,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("ContactId"); b.HasIndex("ContactId");
b.ToTable("ContactDetails"); b.ToTable("ContactDetails", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b =>
@ -596,7 +651,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId"); b.HasIndex("LocationId");
b.ToTable("Contacts"); b.ToTable("Contacts", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => modelBuilder.Entity("Data.SeaHavenIndustries.Department", b =>
@ -614,7 +669,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Departments"); b.ToTable("Departments", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b =>
@ -631,6 +686,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateTime?>("AcknowledgedAt") b.Property<DateTime?>("AcknowledgedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<DateTime?>("CommercialStatusUpdatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("CompletedDate") b.Property<DateTime?>("CompletedDate")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@ -655,6 +713,19 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<bool>("EmailSent") b.Property<bool>("EmailSent")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<DateTime?>("EstimatedArrivalAt")
.HasColumnType("datetime2");
b.Property<bool>("EtaManualOverride")
.HasColumnType("bit");
b.Property<string>("InvoiceNumber")
.HasColumnType("nvarchar(max)");
b.Property<string>("InvoiceStatus")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool?>("IsDeleted") b.Property<bool?>("IsDeleted")
.HasColumnType("bit"); .HasColumnType("bit");
@ -670,6 +741,10 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("PONumber") b.Property<string>("PONumber")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("PaymentStatus")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ReplyToAddress") b.Property<string>("ReplyToAddress")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@ -684,9 +759,17 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Status") b.Property<string>("Status")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("TechPhone")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int>("VendorId") b.Property<int>("VendorId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("VendorNotes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<DateTime?>("VerifiedAt") b.Property<DateTime?>("VerifiedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@ -705,7 +788,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId"); b.HasIndex("WorkOrderId");
b.ToTable("Dispatches"); b.ToTable("Dispatches", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b =>
@ -764,7 +847,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId"); b.HasIndex("WorkOrderId");
b.ToTable("DispatchChecklistItems"); b.ToTable("DispatchChecklistItems", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b =>
@ -818,7 +901,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("DispatchId"); b.HasIndex("DispatchId");
b.ToTable("DispatchSignoffs"); b.ToTable("DispatchSignoffs", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b =>
@ -888,7 +971,7 @@ namespace Data.SeaHavenIndustries.Migrations
.IsUnique() .IsUnique()
.HasFilter("[Status] = 'Pending'"); .HasFilter("[Status] = 'Pending'");
b.ToTable("DispatchUpliftRequests"); b.ToTable("DispatchUpliftRequests", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
@ -911,7 +994,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId"); b.HasIndex("WorkOrderId");
b.ToTable("DispatchWorkOrders"); b.ToTable("DispatchWorkOrders", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b =>
@ -941,7 +1024,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("DropdownOptions"); b.ToTable("DropdownOptions", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b =>
@ -1046,7 +1129,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("RegionId"); b.HasIndex("RegionId");
b.ToTable("Employees"); b.ToTable("Employees", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b =>
@ -1086,7 +1169,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId") b.HasIndex("EmployeeId")
.IsUnique(); .IsUnique();
b.ToTable("EmployeeAddresses"); b.ToTable("EmployeeAddresses", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b =>
@ -1114,7 +1197,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId"); b.HasIndex("EmployeeId");
b.ToTable("EmployeeEmails"); b.ToTable("EmployeeEmails", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b =>
@ -1144,7 +1227,87 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId"); b.HasIndex("EmployeeId");
b.ToTable("EmployeePhones"); b.ToTable("EmployeePhones", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Events", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("AllDay")
.HasColumnType("bit");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("EndDate")
.HasColumnType("datetime2");
b.Property<string>("EndTime")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("EventColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Location")
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<int?>("RecordID")
.HasColumnType("int");
b.Property<string>("RecordName")
.HasColumnType("nvarchar(max)");
b.Property<string>("RecordTargetType")
.HasColumnType("nvarchar(max)");
b.Property<int>("ReminderMinutes")
.HasColumnType("int");
b.Property<DateTime>("StartDate")
.HasColumnType("datetime2");
b.Property<string>("StartTime")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Events");
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b =>
@ -1212,7 +1375,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId"); b.HasIndex("WorkOrderId");
b.ToTable("FollowUps"); b.ToTable("FollowUps", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b =>
@ -1237,7 +1400,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("ForgetPasswordCodes"); b.ToTable("ForgetPasswordCodes", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b =>
@ -1255,7 +1418,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("JobTitles"); b.ToTable("JobTitles", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
@ -1325,7 +1488,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Locations"); b.ToTable("Locations", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b =>
@ -1383,7 +1546,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId"); b.HasIndex("LocationId");
b.ToTable("PMSchedules"); b.ToTable("PMSchedules", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b =>
@ -1457,7 +1620,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId"); b.HasIndex("WorkorderId");
b.ToTable("Quotes"); b.ToTable("Quotes", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b =>
@ -1505,7 +1668,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("QuoteId"); b.HasIndex("QuoteId");
b.ToTable("QuotesLines"); b.ToTable("QuotesLines", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => modelBuilder.Entity("Data.SeaHavenIndustries.Region", b =>
@ -1523,7 +1686,66 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Regions"); b.ToTable("Regions", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<int>("LocationId")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<string>("Trade")
.HasColumnType("nvarchar(450)");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("VendorId")
.HasColumnType("int");
b.Property<string>("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 => modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b =>
@ -1566,7 +1788,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("TaskListTemplates"); b.ToTable("TaskListTemplates", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b =>
@ -1611,7 +1833,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("TaskListTemplateId"); b.HasIndex("TaskListTemplateId");
b.ToTable("TaskListTemplateItems"); b.ToTable("TaskListTemplateItems", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
@ -1713,7 +1935,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId"); b.HasIndex("LocationId");
b.ToTable("Templates"); b.ToTable("Templates", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
@ -1727,6 +1949,13 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Address") b.Property<string>("Address")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("AvailabilityStatus")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("AvailabilityUpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("City") b.Property<string>("City")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@ -1794,7 +2023,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("CompanyId"); b.HasIndex("CompanyId");
b.ToTable("Vendors"); b.ToTable("Vendors", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b =>
@ -1853,7 +2082,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("VendorId"); b.HasIndex("VendorId");
b.ToTable("VendorAccessTokens"); b.ToTable("VendorAccessTokens", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b =>
@ -1958,6 +2187,93 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("VendorCompanies"); b.ToTable("VendorCompanies");
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<int>("DispatchId")
.HasColumnType("int");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("RejectionReason")
.HasColumnType("nvarchar(max)");
b.Property<int?>("ReplacesDocumentId")
.HasColumnType("int");
b.Property<string>("ReviewStatus")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("ReviewedAt")
.HasColumnType("datetime2");
b.Property<string>("ScanStatus")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("ScannedAt")
.HasColumnType("datetime2");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<string>("StoredFileName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("VendorId")
.HasColumnType("int");
b.Property<int>("Version")
.HasColumnType("int");
b.Property<int>("WorkOrderId")
.HasColumnType("int");
b.Property<string>("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 => modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -2024,10 +2340,20 @@ namespace Data.SeaHavenIndustries.Migrations
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<string>("ExternalWorkOrderId") b.Property<string>("ExternalWorkOrderId")
.HasColumnType("nvarchar(max)"); .HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.Property<string>("ExtraServices")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("FlagColor")
.HasMaxLength(7)
.HasColumnType("nvarchar(7)");
b.Property<string>("InternalWONumber") b.Property<string>("InternalWONumber")
.HasColumnType("nvarchar(max)"); .HasMaxLength(11)
.HasColumnType("nvarchar(11)");
b.Property<bool?>("IsDeleted") b.Property<bool?>("IsDeleted")
.HasColumnType("bit"); .HasColumnType("bit");
@ -2059,6 +2385,18 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("PO") b.Property<string>("PO")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("PocName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("PocNotes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("PocPhone")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int?>("PrimaryDispatchId") b.Property<int?>("PrimaryDispatchId")
.HasColumnType("int"); .HasColumnType("int");
@ -2092,6 +2430,10 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("nvarchar(128)"); .HasColumnType("nvarchar(128)");
b.Property<string>("ServiceNotes")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
b.Property<string>("Severity") b.Property<string>("Severity")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@ -2105,7 +2447,8 @@ namespace Data.SeaHavenIndustries.Migrations
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("SiteCode") b.Property<string>("SiteCode")
.HasColumnType("nvarchar(max)"); .HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Source") b.Property<string>("Source")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@ -2125,12 +2468,20 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateOnly?>("TargetWeek") b.Property<DateOnly?>("TargetWeek")
.HasColumnType("date"); .HasColumnType("date");
b.Property<string>("TechPhone")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Trade") b.Property<string>("Trade")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<decimal?>("VendorNTE") b.Property<decimal?>("VendorNTE")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<string>("VendorNotes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<int?>("WorkOrderType") b.Property<int?>("WorkOrderType")
.HasColumnType("int"); .HasColumnType("int");
@ -2150,11 +2501,30 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("AssignTo"); 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("LocationId");
b.HasIndex("PrimaryDispatchId"); 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 => modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
@ -2168,6 +2538,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Attachments") b.Property<string>("Attachments")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("Category")
.HasColumnType("int");
b.Property<DateTime?>("CreatedDate") b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@ -2196,7 +2569,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId"); b.HasIndex("WorkorderId");
b.ToTable("workOrderAttachments"); b.ToTable("workOrderAttachments", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b =>
@ -2246,7 +2619,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId"); b.HasIndex("WorkOrderId");
b.ToTable("WorkOrderAuditLogs"); b.ToTable("WorkOrderAuditLogs", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b =>
@ -2290,7 +2663,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId"); b.HasIndex("WorkorderId");
b.ToTable("workOrderCategories"); b.ToTable("workOrderCategories", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b =>
@ -2337,7 +2710,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId"); b.HasIndex("WorkorderId");
b.ToTable("WorkOrderContacts"); b.ToTable("WorkOrderContacts", (string)null);
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
@ -2366,7 +2739,65 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId", "FieldName") b.HasIndex("WorkOrderId", "FieldName")
.IsUnique(); .IsUnique();
b.ToTable("WorkOrderFieldLocks"); b.ToTable("WorkOrderFieldLocks", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("CorrelationId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("ProcessedAt")
.HasColumnType("datetime2");
b.Property<DateOnly>("SourceWeekStart")
.HasColumnType("date");
b.Property<int>("WorkOrderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("WorkOrderId", "SourceWeekStart")
.IsUnique();
b.ToTable("WorkOrderWeekRolledLedgers", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("CorrelationId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("ProcessedAt")
.HasColumnType("datetime2");
b.Property<DateOnly>("SourceWeekStart")
.HasColumnType("date");
b.Property<int>("WorkOrderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("WorkOrderId", "SourceWeekStart")
.IsUnique();
b.ToTable("WorkOrderWeekRolledLedgers");
}); });
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
@ -2796,6 +3227,25 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Quotes"); 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 => modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b =>
{ {
b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template")
@ -2870,6 +3320,33 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Vendor"); 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 => modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>
{ {
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser")
@ -2956,6 +3433,17 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrder"); 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 => modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
{ {
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
@ -3060,6 +3548,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Comments"); b.Navigation("Comments");
b.Navigation("CompletionDocuments");
b.Navigation("DispatchWorkOrders"); b.Navigation("DispatchWorkOrders");
b.Navigation("Signoffs"); b.Navigation("Signoffs");
@ -3106,6 +3596,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("AuditLogs"); b.Navigation("AuditLogs");
b.Navigation("Dispatches"); b.Navigation("Dispatches");
b.Navigation("SitePreferences");
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b =>

View file

@ -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;
}
}

View file

@ -19,6 +19,12 @@ namespace Data.SeaHavenIndustries
public string? Description { get; set; } public string? Description { get; set; }
public string? Status { get; set; } public string? Status { get; set; }
public DateTime? ScheduledDate { 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? CompletedDate { get; set; }
public DateTime? DispatchedAt { get; set; } public DateTime? DispatchedAt { get; set; }
public DateTime? AcknowledgedAt { get; set; } public DateTime? AcknowledgedAt { get; set; }
@ -28,6 +34,13 @@ namespace Data.SeaHavenIndustries
public string? VerifiedBy { get; set; } public string? VerifiedBy { get; set; }
public DateTime? VerifiedAt { get; set; } public DateTime? VerifiedAt { get; set; }
/// <summary>Tech/crew phone for this dispatch (overrides Vendor.Phone on board).</summary>
[MaxLength(50)]
public string? TechPhone { get; set; }
/// <summary>Instructions for the vendor for this appointment/dispatch.</summary>
[MaxLength(2000)]
public string? VendorNotes { get; set; }
[Timestamp] [Timestamp]
public byte[]? RowVersion { get; set; } public byte[]? RowVersion { get; set; }
@ -35,5 +48,6 @@ namespace Data.SeaHavenIndustries
public ICollection<DispatchWorkOrder>? DispatchWorkOrders { get; set; } public ICollection<DispatchWorkOrder>? DispatchWorkOrders { get; set; }
public ICollection<DispatchChecklistItem>? ChecklistItems { get; set; } public ICollection<DispatchChecklistItem>? ChecklistItems { get; set; }
public ICollection<DispatchSignoff>? Signoffs { get; set; } public ICollection<DispatchSignoff>? Signoffs { get; set; }
public ICollection<VendorCompletionDocument>? CompletionDocuments { get; set; }
} }
} }

View file

@ -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; }
}
}

View file

@ -18,6 +18,8 @@ namespace Data.SeaHavenIndustries
public string? Notes { get; set; } public string? Notes { get; set; }
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public string? PreferredContact { get; set; } public string? PreferredContact { get; set; }
public string AvailabilityStatus { get; set; } = "Unknown";
public DateTime? AvailabilityUpdatedAt { get; set; }
public int? CompanyId { get; set; } public int? CompanyId { get; set; }
[ForeignKey(nameof(CompanyId))] [ForeignKey(nameof(CompanyId))]
@ -27,5 +29,6 @@ namespace Data.SeaHavenIndustries
public int TotalJobs { get; set; } public int TotalJobs { get; set; }
public ICollection<Dispatch>? Dispatches { get; set; } public ICollection<Dispatch>? Dispatches { get; set; }
public ICollection<VendorAuditLog>? AuditLogs { get; set; } public ICollection<VendorAuditLog>? AuditLogs { get; set; }
public ICollection<SitePreferredVendor>? SitePreferences { get; set; }
} }
} }

View file

@ -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; }
}
}

View file

@ -1,15 +1,14 @@
using System; using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations.Schema; using Data.SeaHavenIndustries.Enums;
using static System.Runtime.InteropServices.JavaScript.JSType;
namespace Data.SeaHavenIndustries namespace Data.SeaHavenIndustries
{ {
public class WorkOrderAttachments : FullAuditEntity public class WorkOrderAttachments : FullAuditEntity
{ {
public string? Attachments { get; set; } public string? Attachments { get; set; }
public WorkOrderMediaCategory? Category { get; set; }
public int? WorkorderId { get; set; } public int? WorkorderId { get; set; }
[ForeignKey(nameof(WorkorderId))] [ForeignKey(nameof(WorkorderId))]
public virtual WorkOrder? WorkOrder { get; set; } public virtual WorkOrder? WorkOrder { get; set; }
} }
} }

View file

@ -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; } = "";
}
}

View file

@ -27,11 +27,31 @@ namespace Data.SeaHavenIndustries
public string? LegacyStatus { get; set; } public string? LegacyStatus { get; set; }
public WorkOrderType? WorkOrderType { get; set; } public WorkOrderType? WorkOrderType { get; set; }
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary> /// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
[MaxLength(128)]
public string? Service { get; set; } public string? Service { get; set; }
public int? PrimaryDispatchId { get; set; } public int? PrimaryDispatchId { get; set; }
[ForeignKey(nameof(PrimaryDispatchId))] [ForeignKey(nameof(PrimaryDispatchId))]
public virtual Dispatch? PrimaryDispatch { get; set; } 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; }
/// <summary>JSON array of extra service labels beyond primary (Trade).</summary>
[MaxLength(2000)]
public string? ExtraServices { get; set; }
[MaxLength(4000)]
public string? ServiceNotes { get; set; }
/// <summary>Tech/crew phone when no primary dispatch; otherwise prefer Dispatch.TechPhone.</summary>
[MaxLength(50)]
public string? TechPhone { get; set; }
/// <summary>Vendor instructions when no primary dispatch; otherwise prefer Dispatch.VendorNotes.</summary>
[MaxLength(2000)]
public string? VendorNotes { get; set; }
// --- Scheduling slice --- // --- Scheduling slice ---
public DateTime? ScheduledDate { get; set; } public DateTime? ScheduledDate { get; set; }
public DateTime? ScheduledStart { get; set; } public DateTime? ScheduledStart { get; set; }
@ -51,6 +71,10 @@ namespace Data.SeaHavenIndustries
// --- Completion slice --- // --- Completion slice ---
public DocStatus? DocStatus { get; set; } public DocStatus? DocStatus { get; set; }
/// <summary>Board flag color (#RRGGBB from fixed palette). Null = no flag.</summary>
[MaxLength(7)]
public string? FlagColor { get; set; }
// --- Legacy SQL columns (drift reconciliation) --- // --- Legacy SQL columns (drift reconciliation) ---
public string? AvettaTask { get; set; } public string? AvettaTask { get; set; }
public DateOnly? AssignDate { get; set; } public DateOnly? AssignDate { get; set; }

View file

@ -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<IReadOnlyList<WorkOrderBoardRawRow>> ProjectRowsAsync(
IQueryable<WorkOrder> 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();
}
}
}

View file

@ -0,0 +1,148 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Helpers
{
public static class WorkOrderBoardQueryFilters
{
public static IQueryable<WorkOrder> ApplyBaseScope(IQueryable<WorkOrder> query)
=> query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
/// <summary>
/// Applies assignee filters. When <paramref name="myWorkOrders"/> is true and
/// <paramref name="currentUserId"/> is set, that takes precedence and
/// <paramref name="dispatchers"/> is ignored.
/// </summary>
public static IQueryable<WorkOrder> ApplyDispatcherFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<string>? 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<WorkOrder> ApplyTypeFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<WorkOrderType>? 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<WorkOrder> ApplySiteFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<string>? 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<WorkOrder> ApplyStatusFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<LifecycleStatus>? statuses)
{
if (statuses == null || statuses.Count == 0)
return query;
return query.Where(w => w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value));
}
public static IQueryable<WorkOrder> ApplyDocStatusFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<DocStatus>? docStatuses)
{
if (docStatuses == null || docStatuses.Count == 0)
return query;
return query.Where(w => w.DocStatus != null && docStatuses.Contains(w.DocStatus.Value));
}
public static IQueryable<WorkOrder> ApplyVendorFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<int>? vendorIds)
{
if (vendorIds == null || vendorIds.Count == 0)
return query;
return query.Where(w =>
w.PrimaryDispatch != null && vendorIds.Contains(w.PrimaryDispatch.VendorId));
}
public static IQueryable<WorkOrder> ApplyPmTypeFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<string>? 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<WorkOrder> ApplyDateRangeFilter(
IQueryable<WorkOrder> 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
}
}

View file

@ -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<WorkOrder> Apply(IQueryable<WorkOrder> 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))));
}
}
}

View file

@ -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<IReadOnlyList<CompletionDocTemplateRow>> 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<CompletionDocTemplateRow?> GetByIdAsync(int id)
{
return await _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.Id == id && t.IsDeleted != true)
.Select(MapRow)
.FirstOrDefaultAsync();
}
public async Task<CompletionDocTemplateRow?> 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<CompletionDocTemplate> 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<bool> 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<Func<CompletionDocTemplate, CompletionDocTemplateRow>> MapRow =
t => new CompletionDocTemplateRow(t.Id, t.Name, t.ServiceKey, t.WorkOrderType, t.TemplateUrl, t.IsActive);
}
}

View file

@ -60,6 +60,24 @@ namespace SeaHaven.DataServices.Implementation
return (items, totalCount); return (items, totalCount);
} }
public async Task<IReadOnlyList<SiteOptionRow>> 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<Locations> AddAsync(Locations location) public async Task<Locations> AddAsync(Locations location)
{ {
location.CreatedDate = DateTime.Now; location.CreatedDate = DateTime.Now;

View file

@ -151,5 +151,27 @@ namespace SeaHaven.DataServices.Implementation
.Select(user => user.Email) .Select(user => user.Email)
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
} }
public async Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids)
{
var idList = ids
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.Ordinal)
.ToList();
if (idList.Count == 0)
return new Dictionary<string, string>(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);
}
} }
} }

View file

@ -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<VendorCompletionDocument?> GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
{
return _context.VendorCompletionDocuments
.Where(document => document.DispatchId == dispatchId)
.OrderByDescending(document => document.Version)
.FirstOrDefaultAsync(cancellationToken);
}
public Task<VendorCompletionDocument?> 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);
}
}
}

View file

@ -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<IReadOnlyList<int>> 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<IReadOnlyList<NoVendorCandidateReadModel>> GetNoVendorWorkOrdersAsync(
DateTime cutoff, IReadOnlyCollection<int> 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<IReadOnlyList<ScheduledDispatchReadModel>> 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<IReadOnlyList<ActiveVendorReadModel>> 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<IReadOnlyList<SitePreferredVendor>> 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<List<SitePreferredVendor>> GetSitePreferredVendorsForReplaceAsync(int locationId, CancellationToken cancellationToken)
{
return await _context.SitePreferredVendors
.Where(p => p.LocationId == locationId)
.ToListAsync(cancellationToken);
}
public Task<bool> LocationExistsAsync(int locationId, CancellationToken cancellationToken)
=> _context.Locations.AnyAsync(location => location.Id == locationId, cancellationToken);
public async Task<Dictionary<int, Vendor>> GetVendorsByIdsAsync(IReadOnlyCollection<int> vendorIds, CancellationToken cancellationToken)
=> await _context.Vendors.Where(vendor => vendorIds.Contains(vendor.Id))
.ToDictionaryAsync(vendor => vendor.Id, cancellationToken);
public async Task<IReadOnlyList<InsightDispatchData>> 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<Vendor?> GetVendorForUpdateAsync(int vendorId, CancellationToken cancellationToken)
=> _context.Vendors.FirstOrDefaultAsync(v => v.Id == vendorId, cancellationToken);
public Task<VendorCompletionDocument?> GetCompletionDocumentForUpdateAsync(int documentId, CancellationToken cancellationToken)
=> _context.VendorCompletionDocuments.FirstOrDefaultAsync(d => d.Id == documentId, cancellationToken);
public Task<Dispatch?> GetDispatchForUpdateAsync(int dispatchId, CancellationToken cancellationToken)
=> _context.Dispatches.FirstOrDefaultAsync(d => d.Id == dispatchId, cancellationToken);
public Task<WorkOrder?> GetWorkOrderForUpdateAsync(int workOrderId, CancellationToken cancellationToken)
=> _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
public async Task<Dispatch?> 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<string> GenerateNextDispatchNumberAsync(CancellationToken cancellationToken)
=> _dispatchDataService.GenerateNextDispatchNumberAsync();
public Task<string> 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<SitePreferredVendor> preferences, CancellationToken cancellationToken)
{
_context.SitePreferredVendors.RemoveRange(preferences);
return Task.CompletedTask;
}
public Task<int> SaveChangesAsync(CancellationToken cancellationToken)
=> _context.SaveChangesAsync(cancellationToken);
}
}

View file

@ -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<WorkOrderAdvancedSearchResult> 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<WorkOrder> ApplySort(IQueryable<WorkOrder> 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)
};
}
}
}

View file

@ -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<WorkOrderBoardQueryResult> 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<IReadOnlyList<DispatcherLookupRow>> 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<WorkOrderBoardRawRow?> 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<bool> 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);
}
}
}

View file

@ -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<bool> VendorExistsAsync(int vendorId, CancellationToken cancellationToken)
=> _context.Vendors.AnyAsync(v => v.Id == vendorId && v.IsDeleted != true, cancellationToken);
public async Task<int> GetMaxWorkOrderIdAsync(CancellationToken cancellationToken)
=> await _context.workOrders.MaxAsync(w => (int?)w.Id, cancellationToken) ?? 0;
public Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
=> _context.workOrders.FirstOrDefaultAsync(
w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null),
cancellationToken);
public Task<Dispatch?> GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken)
=> _context.Dispatches.FirstOrDefaultAsync(
d => d.Id == dispatchId && d.WorkOrderId == workOrderId,
cancellationToken);
public Task<Dispatch?> 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<CancellationToken, Task> 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<BoardSaveOutcome> SaveAsync(CancellationToken cancellationToken)
{
try
{
await _context.SaveChangesAsync(cancellationToken);
return BoardSaveOutcome.Saved;
}
catch (DbUpdateConcurrencyException)
{
_context.ChangeTracker.Clear();
return BoardSaveOutcome.ConcurrencyConflict;
}
}
}
}

View file

@ -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<WorkOrder?> 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<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken)
=> _context.CompletionDocTemplates.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken);
public Task SaveAsync(CancellationToken cancellationToken)
=> _context.SaveChangesAsync(cancellationToken);
}
}

View file

@ -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<bool> ExistsAsync(int workOrderId)
=> WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
.AnyAsync(w => w.Id == workOrderId);
public async Task<WorkOrderDetailExtendedFields?> 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<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId)
{
return await _context.Comments
.AsNoTracking()
.Where(c => c.WorkerOrderId == workOrderId && c.IsDeleted != true)
.OrderByDescending(c => c.CreatedDate)
.ToListAsync();
}
public async Task<IReadOnlyList<WorkOrderAuditLog>> 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<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId)
{
return await _context.workOrderAttachments
.AsNoTracking()
.Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true)
.OrderByDescending(a => a.CreatedDate)
.ToListAsync();
}
public async Task<IReadOnlyList<DispatchSignoffRow>> 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<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId)
{
return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
.FirstOrDefaultAsync(w => w.Id == workOrderId);
}
}
}

View file

@ -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<IReadOnlyList<int>> 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<WeekRolledProcessOutcome> 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<PastDueRefreshResult> 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
};
}
}
}

View file

@ -22,9 +22,13 @@ namespace SeaHaven.DataServices.Implementation
.AnyAsync(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName, cancellationToken); .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; return;
_context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock _context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock

View file

@ -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<int> GetMaxWorkOrderIdAsync(CancellationToken cancellationToken)
=> await _context.workOrders.MaxAsync(w => (int?)w.Id, cancellationToken) ?? 0;
public Task<WorkOrder?> GetTrackedByExternalIdAsync(string externalWorkOrderId, CancellationToken cancellationToken)
=> _context.workOrders.FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalWorkOrderId, cancellationToken);
public void TrackWorkOrder(WorkOrder workOrder)
=> _context.workOrders.Add(workOrder);
public Task<Locations?> 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<CancellationToken, Task> 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);
}
}

View file

@ -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<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
=> _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
public Task<WorkOrderAttachments?> 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);
}
}

View file

@ -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<int> CountSyncRejectedSinceAsync(DateTime since, CancellationToken cancellationToken)
=> _context.WorkOrderAuditLogs
.CountAsync(l => l.Action == "SyncRejected" && l.CreatedAt >= since, cancellationToken);
public Task<int> CountFieldLocksAsync(CancellationToken cancellationToken)
=> _context.WorkOrderFieldLocks.CountAsync(cancellationToken);
public async Task<DateTime?> GetLastWeekRolledProcessedAtAsync(CancellationToken cancellationToken)
{
if (!await _context.WorkOrderWeekRolledLedgers.AnyAsync(cancellationToken))
return null;
return await _context.WorkOrderWeekRolledLedgers
.MaxAsync(l => (DateTime?)l.ProcessedAt, cancellationToken);
}
}
}

View file

@ -2,6 +2,8 @@ using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces namespace SeaHaven.DataServices.Interfaces
{ {
public record SiteOptionRow(int Id, string? Code, string? City, string? State);
public interface ILocationDataService public interface ILocationDataService
{ {
Task<Locations?> GetByIdAsync(int id); Task<Locations?> GetByIdAsync(int id);
@ -15,6 +17,8 @@ namespace SeaHaven.DataServices.Interfaces
// For addressbook endpoint - returns simplified DTO // For addressbook endpoint - returns simplified DTO
Task<(IEnumerable<object> Items, int TotalCount)> GetAddressbookPagedAsync(int page, int pageSize, string? search = null); Task<(IEnumerable<object> Items, int TotalCount)> GetAddressbookPagedAsync(int page, int pageSize, string? search = null);
Task<IReadOnlyList<SiteOptionRow>> GetSiteOptionsAsync(string? search = null);
Task<Locations> AddAsync(Locations location); Task<Locations> AddAsync(Locations location);
Task UpdateAsync(Locations location); Task UpdateAsync(Locations location);
Task DeleteAsync(int id); Task DeleteAsync(int id);

View file

@ -18,5 +18,6 @@ namespace SeaHaven.DataServices.Interfaces
Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken); Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken);
Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken); Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken);
Task<string?> GetEmailByIdAsync(string userId, CancellationToken cancellationToken); Task<string?> GetEmailByIdAsync(string userId, CancellationToken cancellationToken);
Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids);
} }
} }

View file

@ -0,0 +1,12 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces
{
public interface IVendorDocumentDataService
{
Task<VendorCompletionDocument?> GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
Task<VendorCompletionDocument?> GetForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken);
Task SaveChangesAsync(CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,33 @@
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Models;
namespace SeaHaven.DataServices.Interfaces
{
public interface IVendorOperationsDataService
{
Task<IReadOnlyList<int>> GetAssignedWorkOrderIdsAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<NoVendorCandidateReadModel>> GetNoVendorWorkOrdersAsync(DateTime cutoff, IReadOnlyCollection<int> excludeIds, CancellationToken cancellationToken);
Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<ActiveVendorReadModel>> GetActiveVendorsAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<SitePreferredVendor>> GetSitePreferredVendorsAsync(int locationId, CancellationToken cancellationToken);
Task<List<SitePreferredVendor>> GetSitePreferredVendorsForReplaceAsync(int locationId, CancellationToken cancellationToken);
Task<bool> LocationExistsAsync(int locationId, CancellationToken cancellationToken);
Task<Dictionary<int, Vendor>> GetVendorsByIdsAsync(IReadOnlyCollection<int> vendorIds, CancellationToken cancellationToken);
Task<IReadOnlyList<InsightDispatchData>> GetInsightDispatchDataAsync(DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId, CancellationToken cancellationToken);
Task<Vendor?> GetVendorForUpdateAsync(int vendorId, CancellationToken cancellationToken);
Task<VendorCompletionDocument?> GetCompletionDocumentForUpdateAsync(int documentId, CancellationToken cancellationToken);
Task<Dispatch?> GetDispatchForUpdateAsync(int dispatchId, CancellationToken cancellationToken);
Task<WorkOrder?> GetWorkOrderForUpdateAsync(int workOrderId, CancellationToken cancellationToken);
Task<Dispatch?> GetExistingActiveDispatchForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<string> GenerateNextDispatchNumberAsync(CancellationToken cancellationToken);
Task<string> 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<SitePreferredVendor> preferences, CancellationToken cancellationToken);
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,9 @@
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderAdvancedSearchDataService
{
Task<WorkOrderAdvancedSearchResult> SearchAsync(WorkOrderAdvancedSearchQuery query);
}
}

View file

@ -0,0 +1,12 @@
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderBoardDataService
{
Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query);
Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync();
Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId);
Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId);
}
}

View file

@ -0,0 +1,27 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces
{
public enum BoardSaveOutcome
{
Saved,
ConcurrencyConflict
}
public interface IWorkOrderBoardMutationDataService
{
Task<bool> VendorExistsAsync(int vendorId, CancellationToken cancellationToken);
Task<int> GetMaxWorkOrderIdAsync(CancellationToken cancellationToken);
Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<Dispatch?> GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken);
Task<Dispatch?> 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<CancellationToken, Task> work, CancellationToken cancellationToken);
Task<BoardSaveOutcome> SaveAsync(CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,12 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderCompletionDataService
{
Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
Task<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken);
Task SaveAsync(CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,27 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderDetailDataService
{
Task<bool> ExistsAsync(int workOrderId);
Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId);
Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId);
Task<IReadOnlyList<WorkOrderAuditLog>> GetAuditLogsAsync(int workOrderId, int? limit = null);
Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId);
Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId);
Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId);
}
public interface ICompletionDocTemplateDataService
{
Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType);
Task<CompletionDocTemplateRow?> GetByIdAsync(int id);
Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType);
Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template);
Task UpdateAsync(CompletionDocTemplate template);
Task<bool> DeleteAsync(int id);
}
}

View file

@ -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<IReadOnlyList<int>> GetWeekRolledCandidatesAsync(
DateOnly sourceWeekStart,
DateOnly sourceWeekEnd,
int batchSize = 500,
CancellationToken cancellationToken = default);
Task<WeekRolledProcessOutcome> TryProcessWeekRolledAsync(
int workOrderId,
DateOnly sourceWeekStart,
string correlationId,
CancellationToken cancellationToken = default);
Task<PastDueRefreshResult> RefreshPastDueFlagsAsync(
DateTime utcNow,
int batchSize = 500,
CancellationToken cancellationToken = default);
}
}

View file

@ -6,6 +6,10 @@ namespace SeaHaven.DataServices.Interfaces
{ {
Task<bool> IsLockedAsync(int workOrderId, string fieldName, CancellationToken cancellationToken); Task<bool> 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);
} }
} }

View file

@ -0,0 +1,15 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderIngestDataService
{
Task<int> GetMaxWorkOrderIdAsync(CancellationToken cancellationToken);
Task<WorkOrder?> GetTrackedByExternalIdAsync(string externalWorkOrderId, CancellationToken cancellationToken);
void TrackWorkOrder(WorkOrder workOrder);
Task<Locations?> FindLocationAsync(string name, CancellationToken cancellationToken);
Task AddAndSaveLocationAsync(Locations location, CancellationToken cancellationToken);
Task ExecuteTransactionalAsync(Func<CancellationToken, Task> work, CancellationToken cancellationToken);
Task SaveAsync(CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,12 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderMediaDataService
{
Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
void TrackAttachment(WorkOrderAttachments attachment);
Task SaveAsync(CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,11 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderOpsHealthDataService
{
Task<int> CountSyncRejectedSinceAsync(DateTime since, CancellationToken cancellationToken);
Task<int> CountFieldLocksAsync(CancellationToken cancellationToken);
Task<DateTime?> GetLastWeekRolledProcessedAtAsync(CancellationToken cancellationToken);
}
}

View file

@ -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<string>? Sites,
IReadOnlyList<WorkOrderType>? Types,
IReadOnlyList<string>? Dispatchers,
IReadOnlyList<LifecycleStatus>? Statuses,
IReadOnlyList<string>? PmTypes,
IReadOnlyList<int>? VendorIds,
IReadOnlyList<DocStatus>? DocStatuses,
bool MyWorkOrders,
string? CurrentUserId,
int Page,
int PageSize,
string SortBy,
string SortDir);
public record WorkOrderAdvancedSearchResult(
IReadOnlyList<WorkOrderBoardRawRow> Rows,
int TotalCount);
}

View file

@ -0,0 +1,65 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Interfaces
{
public record WorkOrderBoardQuery(
DateOnly WeekStart,
DateOnly WeekEnd,
IReadOnlyList<string>? Dispatchers,
bool MyWorkOrders,
IReadOnlyList<WorkOrderType>? 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<WorkOrderBoardRawRow> ScheduledRows,
IReadOnlyList<WorkOrderBoardRawRow> UnscheduledRows,
int ScheduledTotalBeforeSearch);
public record DispatcherLookupRow(
string Id,
string FirstName,
string? LastName,
string? Initials,
string? Color);
}

View file

@ -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);
}

View file

@ -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; }
}

Some files were not shown because too many files have changed in this diff Show more