merge: integrate origin/dev into work-orders-phase-5

Brings in merged PR #16 and vendor/Phase 1-4 work from origin/dev into the
Phase 5 (WeekRolled) head. Only conflict was ApplicationDbContextModelSnapshot.cs
(EF model snapshot touched by both sides); resolved by taking dev's latest
snapshot (matches migration 20260723220614) and grafting the
WorkOrderWeekRolledLedger entity + relationship blocks, matching the Phase5
migration Designer exactly. Migration ordering unchanged: Phase5 (20260709)
runs before vendor migrations (20260720-20260723).
This commit is contained in:
Alexandre Brandizzi 2026-07-24 13:51:41 -03:00
commit 5fa9b4e344
41 changed files with 8461 additions and 105 deletions

View file

@ -0,0 +1,169 @@
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using System.Text.Json;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class VendorControllerTests
{
private static VendorController NewController(Mock<IVendorService> service)
{
var controller = new VendorController(
service.Object,
db: null!,
zipDistance: null!,
vendorTokens: null!,
config: null!);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, "42")
}, "Test"))
}
};
return controller;
}
[Fact]
public async Task Update_WithoutIsActive_PassesNullToServiceToPreserveStatus()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.UpdateVendorAsync(It.IsAny<int>(), It.IsAny<UpdateVendorDTO>(), It.IsAny<string>()))
.ReturnsAsync(new VendorDTO());
var controller = NewController(service);
var model = new EditVendor_DTO
{
Id = 8,
CompanyName = "Updated Name"
};
var result = await controller.Update(8, model);
result.Should().BeOfType<OkObjectResult>();
service.Verify(x => x.UpdateVendorAsync(
8,
It.Is<UpdateVendorDTO>(dto => dto.IsActive == null),
"42"), Times.Once);
}
[Fact]
public async Task Update_WithExplicitIsActive_ForwardsValueUnchanged()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.UpdateVendorAsync(It.IsAny<int>(), It.IsAny<UpdateVendorDTO>(), It.IsAny<string>()))
.ReturnsAsync(new VendorDTO());
var controller = NewController(service);
var model = new EditVendor_DTO
{
Id = 8,
CompanyName = "Updated Name",
IsActive = false
};
await controller.Update(8, model);
service.Verify(x => x.UpdateVendorAsync(
8,
It.Is<UpdateVendorDTO>(dto => dto.IsActive == false),
"42"), Times.Once);
}
[Fact]
public async Task Create_WithoutIsActive_DefaultsToActive()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.CreateVendorAsync(It.IsAny<CreateVendorDTO>(), It.IsAny<string>()))
.ReturnsAsync(new VendorDTO());
var controller = NewController(service);
var model = new Vendor_DTO
{
CompanyName = "New Co"
};
await controller.Create(model);
service.Verify(x => x.CreateVendorAsync(
It.Is<CreateVendorDTO>(dto => dto.IsActive == true),
"42"), Times.Once);
}
[Theory]
[InlineData(true, "Active Co", "Inactive Co")]
[InlineData(false, "Inactive Co", "Active Co")]
public async Task GetFacets_WithStatus_ReturnsOnlyMatchingVendors(
bool isActive,
string expected,
string excluded)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var context = new ApplicationDbContext(options);
context.Vendors.AddRange(
new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false });
await context.SaveChangesAsync();
var controller = new VendorController(
Mock.Of<IVendorService>(),
context,
zipDistance: null!,
vendorTokens: null!,
config: null!);
var result = await controller.GetFacets(isActive);
var payload = JsonSerializer.Serialize(
result.Should().BeOfType<OkObjectResult>().Subject.Value);
payload.Should().Contain(expected);
payload.Should().NotContain(excluded);
}
[Fact]
public async Task GetFacets_WithoutStatus_PreservesGlobalFacetLookup()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var context = new ApplicationDbContext(options);
context.Vendors.AddRange(
new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false });
await context.SaveChangesAsync();
var controller = new VendorController(
Mock.Of<IVendorService>(),
context,
zipDistance: null!,
vendorTokens: null!,
config: null!);
var result = await controller.GetFacets();
var payload = JsonSerializer.Serialize(
result.Should().BeOfType<OkObjectResult>().Subject.Value);
payload.Should().Contain("Active Co");
payload.Should().Contain("Inactive Co");
}
}

View file

@ -0,0 +1,324 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class VendorDataServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static ApplicationDbContext NewSqlServerContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlServer("Server=(local);Database=TranslationOnly;Trusted_Connection=True;TrustServerCertificate=True")
.Options;
return new ApplicationDbContext(options);
}
private static Vendor VendorWithJobs(
string company,
string city,
string state,
string trades,
int jobCount,
bool isActive = true)
{
return new Vendor
{
CompanyName = company,
ContactName = $"{company} Technician",
City = city,
State = state,
TradeSpecialties = trades,
IsActive = isActive,
IsDeleted = false,
Dispatches = Enumerable.Range(1, jobCount)
.Select(workOrderId => new Dispatch { WorkOrderId = workOrderId })
.ToList()
};
}
private static async Task SeedBoundaryVendors(ApplicationDbContext context)
{
context.Vendors.AddRange(
VendorWithJobs("Under Co", "Chicago", "IL", "Plumbing, HVAC", 49),
VendorWithJobs("Fifty Co", "Chicago", "IL", "Electrical", 50),
VendorWithJobs("Hundred Co", "Dallas", "TX", "Plumbing", 100),
VendorWithJobs("One Fifty Co", "Dallas", "TX", "Roofing", 150));
await context.SaveChangesAsync();
}
[Theory]
[InlineData("under-50", "Under Co")]
[InlineData("50-99", "Fifty Co")]
[InlineData("100-149", "Hundred Co")]
[InlineData("150-plus", "One Fifty Co")]
public async Task GetPagedAsync_AppliesExactJobBucketBoundaries(
string bucket,
string expectedCompany)
{
await using var context = NewContext();
await SeedBoundaryVendors(context);
var service = new VendorDataService(context);
var (items, totalCount) = await service.GetPagedAsync(
1,
12,
isActive: true,
jobBuckets: new[] { bucket });
totalCount.Should().Be(1);
items.Single().CompanyName.Should().Be(expectedCompany);
}
[Fact]
public async Task GetPagedAsync_UsesOrWithinFacetAndAndAcrossFacets()
{
await using var context = NewContext();
await SeedBoundaryVendors(context);
var service = new VendorDataService(context);
var (items, totalCount) = await service.GetPagedAsync(
1,
12,
isActive: true,
companies: new[] { "Under Co", "Hundred Co" },
trades: new[] { "HVAC", "Plumbing" },
locations: new[] { "Chicago, IL" },
jobBuckets: new[] { "under-50", "100-149" });
totalCount.Should().Be(1);
items.Single().CompanyName.Should().Be("Under Co");
}
[Fact]
public async Task GetPagedAsync_StatusAndCountAreIndependentOfInactiveHistory()
{
await using var context = NewContext();
context.Vendors.AddRange(
VendorWithJobs("Active Co", "Chicago", "IL", "Plumbing", 1),
VendorWithJobs("Inactive Co", "Chicago", "IL", "Plumbing", 1, false));
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var active = await service.GetPagedAsync(1, 1, isActive: true);
var inactive = await service.GetPagedAsync(1, 1, isActive: false);
active.TotalCount.Should().Be(1);
inactive.TotalCount.Should().Be(1);
active.Items.Single().CompanyName.Should().Be("Active Co");
inactive.Items.Single().CompanyName.Should().Be("Inactive Co");
}
[Fact]
public async Task GetPagedAsync_LocationFacetMatchesCityStateAndFullLabels()
{
await using var context = NewContext();
context.Vendors.AddRange(
new Vendor { CompanyName = "CityOnly", City = "Austin", State = null, TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "StateOnly", City = null, State = "TX", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "FullLoc", City = "Austin", State = "TX", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var cityOnly = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "Austin" });
cityOnly.TotalCount.Should().Be(1);
cityOnly.Items.Single().CompanyName.Should().Be("CityOnly");
var stateOnly = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "TX" });
stateOnly.TotalCount.Should().Be(1);
stateOnly.Items.Single().CompanyName.Should().Be("StateOnly");
var full = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "Austin, TX" });
full.TotalCount.Should().Be(1);
full.Items.Single().CompanyName.Should().Be("FullLoc");
}
[Fact]
public async Task GetPagedAsync_TradeFacetMatchesExactTokensNotSubstrings()
{
await using var context = NewContext();
context.Vendors.AddRange(
new Vendor { CompanyName = "HvacOnly", City = "Chicago", State = "IL", TradeSpecialties = "HVAC", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "HvacControls", City = "Chicago", State = "IL", TradeSpecialties = "HVAC Controls", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "PlumbingCo", City = "Chicago", State = "IL", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var hvac = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "HVAC" });
hvac.TotalCount.Should().Be(1);
hvac.Items.Single().CompanyName.Should().Be("HvacOnly");
var hvacControls = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "HVAC Controls" });
hvacControls.TotalCount.Should().Be(1);
hvacControls.Items.Single().CompanyName.Should().Be("HvacControls");
var plumb = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumb" });
plumb.TotalCount.Should().Be(0);
plumb.Items.Should().BeEmpty();
var plumbing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumbing" });
plumbing.TotalCount.Should().Be(1);
plumbing.Items.Single().CompanyName.Should().Be("PlumbingCo");
}
[Fact]
public async Task GetPagedAsync_TradeFacetMatchesTokenInCommaDelimitedList()
{
await using var context = NewContext();
context.Vendors.AddRange(
new Vendor { CompanyName = "Multi", City = "Chicago", State = "IL", TradeSpecialties = "Plumbing, HVAC", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "Middle", City = "Chicago", State = "IL", TradeSpecialties = "Electrical, Plumbing, Roofing", IsActive = true, IsDeleted = false });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var plumbing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumbing" });
plumbing.TotalCount.Should().Be(2);
plumbing.Items.Select(v => v.CompanyName).Should().BeEquivalentTo("Multi", "Middle");
var roofing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Roofing" });
roofing.TotalCount.Should().Be(1);
roofing.Items.Single().CompanyName.Should().Be("Middle");
}
[Fact]
public async Task GetPagedAsync_CountsDistinctDirectAndLinkedWorkOrders()
{
await using var context = NewContext();
var vendor = new Vendor
{
CompanyName = "History Co",
IsActive = true,
IsDeleted = false,
Dispatches = new List<Dispatch>
{
new()
{
WorkOrderId = 100,
DispatchWorkOrders = new List<DispatchWorkOrder>
{
new() { WorkOrderId = 100 },
new() { WorkOrderId = 101 }
}
},
new() { WorkOrderId = 101 }
}
};
context.Vendors.Add(vendor);
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var result = await service.GetPagedAsync(1, 12, isActive: true);
result.Items.Single().TotalJobs.Should().Be(2);
}
[Fact]
public async Task GetByIdAsync_ReturnsTrackedVendorWithoutLoadingDispatchGraph()
{
await using var context = NewContext();
var vendor = VendorWithJobs("Lean Co", "Chicago", "IL", "Plumbing", 2);
context.Vendors.Add(vendor);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
var service = new VendorDataService(context);
var result = await service.GetByIdAsync(vendor.Id);
result.Should().NotBeNull();
context.Entry(result!).State.Should().Be(EntityState.Unchanged);
context.Entry(result!).Collection(nameof(Vendor.Dispatches)).IsLoaded.Should().BeFalse();
result!.Dispatches.Should().BeNull();
}
[Theory]
[InlineData("trade")]
[InlineData("location")]
[InlineData("job-bucket")]
public void BuildDirectoryQuery_TranslatesRepresentativeFiltersForSqlServer(string filter)
{
using var context = NewSqlServerContext();
var service = new VendorDataService(context);
var query = filter switch
{
"trade" => service.BuildDirectoryQuery(trades: new[] { "Plumbing" }),
"location" => service.BuildDirectoryQuery(locations: new[] { "Chicago, IL" }),
_ => service.BuildDirectoryQuery(jobBuckets: new[] { "50-99" })
};
var sql = query.ToQueryString();
sql.Should().Contain("SELECT");
if (filter == "trade")
sql.Should().Contain("TradeSpecialties");
if (filter == "location")
sql.Should().Contain("City");
if (filter == "job-bucket")
sql.Should().Contain("UNION");
}
[Fact]
public void BuildLinkedWorkOrdersQuery_TranslatesForSqlServerWithoutClientEvaluation()
{
using var context = NewSqlServerContext();
var service = new VendorDataService(context);
var sql = service.BuildLinkedWorkOrdersQuery(60).ToQueryString();
sql.Should().Contain("SELECT");
sql.Should().Contain("Dispatches");
sql.Should().Contain("DispatchWorkOrders");
sql.Should().Contain("workOrders");
}
[Fact]
public async Task GetLinkedWorkOrdersAsync_DedupsToOneRecordPerWorkOrderAcrossDirectAndMultiLinks()
{
await using var context = NewContext();
var vendor = new Vendor { Id = 60, CompanyName = "Linked Co", IsActive = true };
context.Vendors.Add(vendor);
context.workOrders.AddRange(
new WorkOrder
{
Id = 701,
WorkerOrderNumber = "WO-701",
WorkerOrderTitle = "Direct And Multi",
Status = "Scheduled",
LifecycleStatus = LifecycleStatus.Scheduled
},
new WorkOrder
{
Id = 702,
WorkerOrderNumber = "WO-702",
WorkerOrderTitle = "Multi Only",
Status = "Scheduled",
LifecycleStatus = LifecycleStatus.Scheduled
});
var directDispatch = new Dispatch { Id = 7001, VendorId = 60, WorkOrderId = 701, Vendor = vendor };
var multiDispatch = new Dispatch { Id = 7002, VendorId = 60, WorkOrderId = null, Vendor = vendor };
context.Dispatches.AddRange(directDispatch, multiDispatch);
context.DispatchWorkOrders.AddRange(
new DispatchWorkOrder { DispatchId = 7002, Dispatch = multiDispatch, WorkOrderId = 702 },
new DispatchWorkOrder { DispatchId = 7002, Dispatch = multiDispatch, WorkOrderId = 701 });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var result = await service.GetLinkedWorkOrdersAsync(60);
result.Should().HaveCount(2);
result.Select(x => x.WorkOrderId).Should().BeEquivalentTo(new[] { 701, 702 });
result.Select(x => x.WorkOrderId).Should().OnlyHaveUniqueItems();
}
}

View file

@ -0,0 +1,313 @@
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 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 controller = new VendorOperationsController(
context,
new DispatchDataService(context),
TimeProvider.System);
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();
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();
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");
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<VendorOperationsController.SitePreferenceRequest>
{
new() { VendorId = inactive.Id }
});
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);
var pdf = (FileContentResult)await controller.ExportInsightsPdf(null, null, null, null, null);
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 VendorOperationsController.VendorAssignmentRequest { VendorId = vendor.Id });
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 VendorOperationsController.VendorAssignmentRequest
{
VendorId = vendor.Id,
ScheduledStart = assignedWorkOrder.ScheduledStart,
ScheduledEnd = assignedWorkOrder.ScheduledEnd,
EstimatedArrivalAt = eta,
EtaManualOverride = true
});
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 VendorOperationsController.VendorAssignmentRequest
{
VendorId = replacementVendor.Id,
ScheduledStart = workOrder.ScheduledStart,
ScheduledEnd = workOrder.ScheduledEnd
});
result.Should().BeOfType<OkObjectResult>();
context.Dispatches.Should().ContainSingle();
(await context.Dispatches.SingleAsync()).VendorId.Should().Be(replacementVendor.Id);
}
}

View file

@ -0,0 +1,164 @@
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Moq;
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 configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["VendorPortal:TokenLifetimeDays"] = "365",
["SendGrid:ApiKey"] = "not-used"
})
.Build();
var environment = new Mock<IWebHostEnvironment>();
environment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot);
var controller = new VendorPortalController(
context,
new VendorPortalTokenService(context, configuration),
configuration,
new SendMessage(configuration),
environment.Object);
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

@ -0,0 +1,481 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using FluentValidation;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class VendorServiceTests
{
private static VendorService NewService(Mock<IVendorDataService> data) =>
new(data.Object, new CreateVendorValidation(), new UpdateVendorValidation(), new WorkOrderVendorUpdateValidation());
[Fact]
public async Task CreateVendor_PersistsCompleteProfile()
{
var data = new Mock<IVendorDataService>();
Vendor? saved = null;
data.Setup(x => x.AddAsync(It.IsAny<Vendor>()))
.ReturnsAsync((Vendor vendor) => { saved = vendor; vendor.Id = 7; return vendor; });
var result = await NewService(data).CreateVendorAsync(new CreateVendorDTO
{
Name = "Gateway Plumbing",
ContactName = "Jordan Lee",
Email = "dispatch@gateway.example",
Phone = "(555) 555-0100",
CompanyPhone = "(555) 555-0199",
Address = "10 Lake St",
City = "Chicago",
State = "IL",
Zipcode = "60601",
TradeSpecialties = "Plumbing, Backflow",
GoogleMapsUrl = "https://maps.google.com/example",
Notes = "After-hours dispatch available",
IsActive = true
}, "42");
saved.Should().NotBeNull();
saved!.ContactName.Should().Be("Jordan Lee");
saved.TradeSpecialties.Should().Be("Plumbing, Backflow");
saved.CompanyPhone.Should().Be("(555) 555-0199");
saved.GoogleMapsUrl.Should().Be("https://maps.google.com/example");
saved.Notes.Should().Be("After-hours dispatch available");
saved.City.Should().Be("Chicago");
result.IsActive.Should().BeTrue();
result.ContactName.Should().Be("Jordan Lee");
}
[Fact]
public async Task UpdateVendor_UpdatesProfileAndActiveState()
{
var existing = new Vendor { Id = 8, CompanyName = "Old", IsActive = true };
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(8)).ReturnsAsync(existing);
data.Setup(x => x.GetLinkedWorkOrdersAsync(8)).ReturnsAsync(new List<LinkedWorkOrderInfo>());
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
var result = await NewService(data).UpdateVendorAsync(8, new UpdateVendorDTO
{
Name = "New",
ContactName = "Casey",
TradeSpecialties = "HVAC",
IsActive = false
}, "42");
existing.CompanyName.Should().Be("New");
existing.ContactName.Should().Be("Casey");
existing.TradeSpecialties.Should().Be("HVAC");
existing.IsActive.Should().BeFalse();
existing.LastModifierUserId.Should().Be(42);
result.IsActive.Should().BeFalse();
}
[Fact]
public async Task DeleteVendor_DeactivatesWithoutDeletingHistory()
{
var existing = new Vendor { Id = 9, CompanyName = "Keep History", IsActive = true };
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(9)).ReturnsAsync(existing);
data.Setup(x => x.GetLinkedWorkOrdersAsync(9)).ReturnsAsync(new List<LinkedWorkOrderInfo>());
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
await NewService(data).DeleteVendorAsync(9, "42");
existing.IsActive.Should().BeFalse();
existing.IsDeleted.Should().NotBeTrue();
existing.DeletionTime.Should().BeNull();
existing.LastModificationTime.Should().NotBeNull();
}
[Fact]
public async Task GetVendorsPaged_RequestsSelectedStatusAndMapsDirectoryFields()
{
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetPagedAsync(
1,
12,
"chicago",
false,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>()))
.ReturnsAsync((new[]
{
new Vendor
{
Id = 10,
CompanyName = "Inactive Vendor",
ContactName = "Taylor",
City = "Chicago",
State = "IL",
TradeSpecialties = "Electrical",
IsActive = false
}
}.AsEnumerable(), 1));
var result = await NewService(data).GetVendorsPagedAsync(1, 12, "chicago", false);
result.TotalCount.Should().Be(1);
result.Items.Single().Should().BeEquivalentTo(new
{
Id = 10,
Name = "Inactive Vendor",
ContactName = "Taylor",
City = "Chicago",
State = "IL",
TradeSpecialties = "Electrical",
IsActive = false
}, options => options.ExcludingMissingMembers());
}
[Fact]
public async Task GetVendorById_CountsDistinctHistoricalWorkOrders()
{
var vendor = new Vendor
{
Id = 11,
CompanyName = "History Vendor",
Dispatches = new List<Dispatch>
{
new()
{
WorkOrderId = 100,
DispatchWorkOrders = new List<DispatchWorkOrder>
{
new() { WorkOrderId = 100 },
new() { WorkOrderId = 101 }
}
},
new() { WorkOrderId = 101 }
}
};
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdWithDetailsAsync(11)).ReturnsAsync(vendor);
var result = await NewService(data).GetVendorByIdAsync(11);
result.Should().NotBeNull();
result!.TotalJobs.Should().Be(2);
}
[Fact]
public async Task GetVendorsPaged_ForwardsFacetFilters()
{
var companies = new[] { "Gateway Plumbing", "Roto-Rooter Dallas" };
var trades = new[] { "Backflow Preventers" };
var locations = new[] { "Dallas, TX" };
var jobBuckets = new[] { "100-149" };
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetPagedAsync(
1,
25,
"gateway",
true,
companies,
trades,
locations,
jobBuckets))
.ReturnsAsync((Enumerable.Empty<Vendor>(), 0));
await NewService(data).GetVendorsPagedAsync(
1,
25,
"gateway",
true,
companies,
trades,
locations,
jobBuckets);
data.VerifyAll();
}
[Fact]
public void CreateVendor_RequiresTechnicianNameAndHttpsMapUrl()
{
var validator = new CreateVendorValidation();
var result = validator.Validate(new CreateVendorDTO
{
Name = "Gateway Plumbing",
ContactName = "",
GoogleMapsUrl = "http://maps.google.com/gateway"
});
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName));
result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl));
}
[Fact]
public async Task UpdateVendor_DeactivatingWithOpenWorkOrders_ThrowsAndLeavesActive()
{
var existing = new Vendor { Id = 20, CompanyName = "Guarded", IsActive = true };
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(20)).ReturnsAsync(existing);
data.Setup(x => x.GetLinkedWorkOrdersAsync(20))
.ReturnsAsync(new List<LinkedWorkOrderInfo>
{
new()
{
WorkOrderId = 500,
WorkOrderNumber = "WO-500",
Status = "Scheduled",
LifecycleStatus = LifecycleStatus.Scheduled
}
});
var act = () => NewService(data).UpdateVendorAsync(20, new UpdateVendorDTO { IsActive = false }, "42");
await act.Should().ThrowAsync<VendorDeactivationBlockedException>();
existing.IsActive.Should().BeTrue();
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), Times.Never);
}
[Fact]
public async Task UpdateVendor_WithUnknownCompanyId_ThrowsAndDoesNotFallBackToName()
{
var existing = new Vendor { Id = 30, CompanyName = "Existing", IsActive = true };
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(30)).ReturnsAsync(existing);
data.Setup(x => x.GetCompanyByIdAsync(999)).ReturnsAsync((VendorCompany?)null);
var act = () => NewService(data).UpdateVendorAsync(30, new UpdateVendorDTO
{
CompanyId = 999,
Name = "Fallback Try"
}, "42");
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.GetCompanyByNormalizedNameAsync(It.IsAny<string>()), Times.Never);
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), Times.Never);
}
[Fact]
public async Task CreateVendor_PropagatesExistingCompanyIdWithoutCreatingCompany()
{
var data = new Mock<IVendorDataService>();
var company = new VendorCompany { Id = 77, Name = "Acme", NormalizedName = "acme" };
data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(company);
Vendor? saved = null;
data.Setup(x => x.AddAsync(It.IsAny<Vendor>()))
.ReturnsAsync((Vendor v) => { saved = v; v.Id = 40; return v; });
var result = await NewService(data).CreateVendorAsync(new CreateVendorDTO
{
Name = "Acme",
CompanyId = 77,
ContactName = "Riley"
}, "42");
saved.Should().NotBeNull();
saved!.CompanyId.Should().Be(77);
result.CompanyId.Should().Be(77);
data.Verify(x => x.AddCompanyAsync(It.IsAny<VendorCompany>()), Times.Never);
}
[Fact]
public void CreateVendor_RejectsInvalidPhoneAndInvalidPreferredContact()
{
var validator = new CreateVendorValidation();
var result = validator.Validate(new CreateVendorDTO
{
Name = "Acme",
ContactName = "Riley",
Phone = "555-1234",
PreferredContact = "Fax"
});
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorDTO.Phone));
result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorDTO.PreferredContact));
}
[Fact]
public async Task CreateVendor_NormalizesCommonNorthAmericanPhoneFormatsBeforeValidation()
{
var data = new Mock<IVendorDataService>();
Vendor? saved = null;
data.Setup(x => x.AddAsync(It.IsAny<Vendor>()))
.ReturnsAsync((Vendor vendor) =>
{
saved = vendor;
vendor.Id = 41;
return vendor;
});
await NewService(data).CreateVendorAsync(new CreateVendorDTO
{
Name = "Acme",
ContactName = "Riley",
Phone = "+1 312 555 0100"
}, "42");
saved!.Phone.Should().Be("(312) 555-0100");
}
[Fact]
public async Task UpdateVendor_RoutesCompanyOwnedFieldsToTheLinkedCompany()
{
var existing = new Vendor
{
Id = 42,
CompanyId = 77,
CompanyName = "Acme",
Address = "Old address",
IsActive = true
};
var company = new VendorCompany
{
Id = 77,
Name = "Acme",
NormalizedName = "acme",
Address = "Old address"
};
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(42)).ReturnsAsync(existing);
data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(company);
data.Setup(x => x.UpdateCompanyAsync(company)).Returns(Task.CompletedTask);
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
await NewService(data).UpdateVendorAsync(42, new UpdateVendorDTO
{
Name = "Acme Services",
Address = "200 New Street"
}, "42");
company.Name.Should().Be("Acme Services");
company.NormalizedName.Should().Be("acme services");
company.Address.Should().Be("200 New Street");
existing.Address.Should().Be("200 New Street");
data.Verify(x => x.UpdateCompanyAsync(company), Times.Once);
}
[Fact]
public async Task UpdateVendor_SparseCompanyDoesNotEraseExistingVendorFields()
{
var existing = new Vendor
{
Id = 43,
CompanyId = 77,
CompanyName = "Acme",
Address = "Keep this address",
CompanyPhone = "(312) 555-0199",
IsActive = true
};
var sparseCompany = new VendorCompany
{
Id = 77,
Name = "Acme",
NormalizedName = "acme"
};
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(43)).ReturnsAsync(existing);
data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(sparseCompany);
data.Setup(x => x.UpdateCompanyAsync(sparseCompany)).Returns(Task.CompletedTask);
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
await NewService(data).UpdateVendorAsync(43, new UpdateVendorDTO
{
Name = "Acme"
}, "42");
existing.Address.Should().Be("Keep this address");
existing.CompanyPhone.Should().Be("(312) 555-0199");
}
[Fact]
public void WorkOrderVendorUpdate_AcceptsCanonicalPhoneAndValidPreferredContact()
{
var validator = new WorkOrderVendorUpdateValidation();
var result = validator.Validate(new WorkOrderVendorUpdateDTO
{
WorkOrderId = 5,
Phone = "(312) 555-0100",
PreferredContact = "Text"
});
result.IsValid.Should().BeTrue();
}
[Fact]
public async Task UpdateVendorFromWorkOrder_RejectsUnassignedWorkOrder()
{
var existing = new Vendor { Id = 50, ContactName = "Original", IsActive = true };
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(50)).ReturnsAsync(existing);
data.Setup(x => x.IsVendorAssignedToWorkOrderAsync(50, 777)).ReturnsAsync(false);
var act = () => NewService(data).UpdateVendorFromWorkOrderAsync(50, new WorkOrderVendorUpdateDTO
{
WorkOrderId = 777,
ContactName = "Should Not Apply"
}, "42");
await act.Should().ThrowAsync<InvalidOperationException>();
existing.ContactName.Should().Be("Original");
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), Times.Never);
}
[Fact]
public async Task UpdateVendorFromWorkOrder_RecordsAuditOldAndNewValues()
{
var existing = new Vendor
{
Id = 50,
CompanyName = "Assigned",
ContactName = "Old Name",
Phone = "(312) 555-0000",
PreferredContact = "Phone",
IsActive = true
};
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(50)).ReturnsAsync(existing);
data.Setup(x => x.IsVendorAssignedToWorkOrderAsync(50, 777)).ReturnsAsync(true);
var auditLogs = new List<VendorAuditLog>();
data.Setup(x => x.UpdateWithAuditLogsAsync(
existing,
It.IsAny<IReadOnlyCollection<VendorAuditLog>>()))
.Callback<Vendor, IReadOnlyCollection<VendorAuditLog>>((_, logs) => auditLogs.AddRange(logs))
.Returns(Task.CompletedTask);
await NewService(data).UpdateVendorFromWorkOrderAsync(50, new WorkOrderVendorUpdateDTO
{
WorkOrderId = 777,
ContactName = "New Name",
Phone = "(312) 555-0199",
PreferredContact = "Email"
}, "42");
existing.ContactName.Should().Be("New Name");
existing.Phone.Should().Be("(312) 555-0199");
existing.PreferredContact.Should().Be("Email");
auditLogs.Should().NotBeEmpty();
auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.ContactName) && a.OldValue == "Old Name" && a.NewValue == "New Name");
auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.Phone) && a.OldValue == "(312) 555-0000" && a.NewValue == "(312) 555-0199");
auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.PreferredContact) && a.OldValue == "Phone" && a.NewValue == "Email");
}
[Fact]
public async Task GetDeactivationImpact_RejectsUnknownVendor()
{
var data = new Mock<IVendorDataService>();
data.Setup(x => x.ExistsAsync(404)).ReturnsAsync(false);
var act = () => NewService(data).GetDeactivationImpactAsync(404);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*404*not found");
data.Verify(x => x.GetLinkedWorkOrdersAsync(It.IsAny<int>()), Times.Never);
}
}

View file

@ -32,16 +32,45 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpGet("GetVendorList")]
public async Task<IActionResult> GetVendorList(string? search = "", int page = 1, int pageSize = 10)
public async Task<IActionResult> GetVendorList(
string? search = "",
int page = 1,
int pageSize = 10,
bool? isActive = true,
[FromQuery] string[]? companies = null,
[FromQuery] string[]? trades = null,
[FromQuery] string[]? locations = null,
[FromQuery] string[]? jobBuckets = null)
{
var pagedResult = await _vendorService.GetVendorsPagedAsync(page, pageSize, search);
var pagedResult = await _vendorService.GetVendorsPagedAsync(
page,
pageSize,
search,
isActive,
companies,
trades,
locations,
jobBuckets);
var data = pagedResult.Items.Select(v => new
{
v.Id,
CompanyName = v.Name,
v.CompanyId,
v.ContactName,
v.Email,
v.Phone
v.Phone,
v.CompanyPhone,
v.PreferredContact,
v.Address,
v.City,
v.State,
Zip = v.Zipcode,
v.TradeSpecialties,
v.GoogleMapsUrl,
v.Notes,
v.IsActive,
v.TotalJobs
});
return Ok(new Pagination_DTO
@ -56,17 +85,37 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet("{id}")]
[HttpGet("GetById")]
public async Task<IActionResult> GetVendorById([FromQuery] int? id)
public async Task<IActionResult> GetVendorById([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId)
{
if (id == null)
var vendorId = id ?? queryId;
if (vendorId == null)
return BadRequest(new Response { Status = "Error", Message = "Id is required" });
var vendor = await _vendorService.GetVendorByIdAsync(id.Value);
var vendor = await _vendorService.GetVendorByIdAsync(vendorId.Value);
if (vendor == null)
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
return Ok(vendor);
return Ok(new
{
vendor.Id,
CompanyName = vendor.Name,
vendor.CompanyId,
vendor.ContactName,
vendor.Email,
vendor.Phone,
vendor.CompanyPhone,
vendor.PreferredContact,
vendor.Address,
vendor.City,
vendor.State,
Zip = vendor.Zipcode,
vendor.TradeSpecialties,
vendor.GoogleMapsUrl,
vendor.Notes,
vendor.IsActive,
vendor.TotalJobs
});
}
[HttpPost]
@ -77,7 +126,21 @@ namespace Api.SeaHavenIndustries.Controllers
{
var createDto = new CreateVendorDTO
{
Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required")
Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required"),
CompanyId = model.CompanyId,
ContactName = model.ContactName,
Email = model.Email,
Phone = model.Phone,
CompanyPhone = model.CompanyPhone,
PreferredContact = model.PreferredContact,
Address = model.Address,
City = model.City,
State = model.State,
Zipcode = model.Zip,
TradeSpecialties = model.TradeSpecialties,
GoogleMapsUrl = model.GoogleMapsUrl,
Notes = model.Notes,
IsActive = model.IsActive ?? true
};
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
@ -111,7 +174,21 @@ namespace Api.SeaHavenIndustries.Controllers
var updateDto = new UpdateVendorDTO
{
Name = model.CompanyName
Name = model.CompanyName,
CompanyId = model.CompanyId,
ContactName = model.ContactName,
Email = model.Email,
Phone = model.Phone,
CompanyPhone = model.CompanyPhone,
PreferredContact = model.PreferredContact,
Address = model.Address,
City = model.City,
State = model.State,
Zipcode = model.Zip,
TradeSpecialties = model.TradeSpecialties,
GoogleMapsUrl = model.GoogleMapsUrl,
Notes = model.Notes,
IsActive = model.IsActive
};
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
@ -126,7 +203,16 @@ namespace Api.SeaHavenIndustries.Controllers
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (KeyNotFoundException)
catch (VendorDeactivationBlockedException dbex)
{
return Conflict(new
{
Status = "Conflict",
Message = dbex.Message,
OpenWorkOrders = dbex.OpenWorkOrders
});
}
catch (InvalidOperationException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
}
@ -138,7 +224,7 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpDelete("{id}")]
[HttpPost("Delete")]
public async Task<IActionResult> Delete([FromRoute] int? id, [FromQuery] int? queryId = null)
public async Task<IActionResult> Delete([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId = null)
{
try
{
@ -152,9 +238,18 @@ namespace Api.SeaHavenIndustries.Controllers
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
await _vendorService.DeleteVendorAsync(vendorId, userId);
return Ok(new DataResponse { Message = "Vendor Deleted", Status = "200" });
return Ok(new DataResponse { Message = "Vendor Deactivated", Status = "200" });
}
catch (KeyNotFoundException)
catch (VendorDeactivationBlockedException dbex)
{
return Conflict(new
{
Status = "Conflict",
Message = dbex.Message,
OpenWorkOrders = dbex.OpenWorkOrders
});
}
catch (InvalidOperationException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
}
@ -164,6 +259,64 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
[HttpGet("{id:int}/deactivation-impact")]
public async Task<IActionResult> GetDeactivationImpact(int id)
{
try
{
var impact = await _vendorService.GetDeactivationImpactAsync(id);
return Ok(impact);
}
catch (InvalidOperationException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
}
}
[HttpPut("{id:int}/work-order-update")]
public async Task<IActionResult> UpdateFromWorkOrder(int id, [FromBody] WorkOrderVendorUpdate_DTO model)
{
try
{
if (model == null || model.WorkOrderId <= 0)
return BadRequest(new Response { Status = "Error", Message = "A valid workOrderId is required" });
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
var dto = new WorkOrderVendorUpdateDTO
{
WorkOrderId = model.WorkOrderId,
ContactName = model.ContactName,
PreferredContact = model.PreferredContact,
Phone = model.Phone,
Email = model.Email,
Notes = model.Notes
};
var result = await _vendorService.UpdateVendorFromWorkOrderAsync(id, dto, userId);
return Ok(result);
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (InvalidOperationException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor or work order link not found" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
}
}
[HttpGet("Dropdown")]
public async Task<IActionResult> GetDropdown([FromQuery] string? trade = null, [FromQuery] string? siteZip = null)
{
@ -174,6 +327,8 @@ namespace Api.SeaHavenIndustries.Controllers
{
v.Id,
v.CompanyName,
v.ContactName,
v.PreferredContact,
v.TradeSpecialties,
v.Address,
v.City,
@ -191,6 +346,8 @@ namespace Api.SeaHavenIndustries.Controllers
{
v.Id,
v.CompanyName,
v.ContactName,
v.PreferredContact,
v.TradeSpecialties,
address = string.Join(", ", addr),
distanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null
@ -212,6 +369,66 @@ namespace Api.SeaHavenIndustries.Controllers
return Ok(result);
}
[HttpGet("facets")]
public async Task<IActionResult> GetFacets([FromQuery] bool? isActive = null)
{
var query = _db.Vendors
.AsNoTracking()
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
if (isActive.HasValue)
query = query.Where(v => v.IsActive == isActive.Value);
var vendors = await query
.OrderByDescending(v => v.Id)
.ToListAsync();
var companies = vendors
.Where(v => !string.IsNullOrWhiteSpace(v.CompanyName))
.GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.OrderBy(v => v.CompanyName)
.Select(v => new
{
Name = v.CompanyName,
v.CompanyId,
v.CompanyPhone,
v.Email,
v.PreferredContact,
v.Address,
v.City,
v.State,
Zip = v.Zip,
v.GoogleMapsUrl
});
var trades = vendors
.SelectMany(v => (v.TradeSpecialties ?? "")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(value => value);
var locations = vendors
.Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State))
.Select(v => new { v.City, v.State, Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value))) })
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
.OrderBy(location => location.Label);
return Ok(new
{
Companies = companies,
Trades = trades,
Locations = locations,
JobBuckets = new[]
{
new { Id = "under-50", Label = "Under 50" },
new { Id = "50-99", Label = "50–99" },
new { Id = "100-149", Label = "100–149" },
new { Id = "150-plus", Label = "150+" }
}
});
}
[HttpGet("{id:int}/portal-token")]
public async Task<IActionResult> GetPortalToken(int id)
{

View file

@ -0,0 +1,724 @@
using System.Security.Claims;
using System.Text;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/vendor-operations")]
public class VendorOperationsController : ControllerBase
{
private static readonly string[] TerminalDispatchStatuses = { "Completed", "Verified", "Cancelled", "Canceled" };
private readonly ApplicationDbContext _db;
private readonly IDispatchDataService _dispatchDataService;
private readonly TimeProvider _timeProvider;
public VendorOperationsController(
ApplicationDbContext db,
IDispatchDataService dispatchDataService,
TimeProvider timeProvider)
{
_db = db;
_dispatchDataService = dispatchDataService;
_timeProvider = timeProvider;
}
[HttpGet("notifications")]
public async Task<IActionResult> GetNotifications()
{
var now = _timeProvider.GetUtcNow().UtcDateTime;
var cutoff = now.AddHours(48);
var directlyAssignedIds = await _db.Dispatches
.AsNoTracking()
.Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") && d.WorkOrderId.HasValue)
.Select(d => d.WorkOrderId!.Value)
.ToListAsync();
var multiplyAssignedIds = await _db.DispatchWorkOrders
.AsNoTracking()
.Where(link => link.Dispatch != null
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? ""))
.Select(link => link.WorkOrderId)
.ToListAsync();
var assignedWorkOrderIds = directlyAssignedIds.Concat(multiplyAssignedIds).Distinct().ToList();
var noVendor = await _db.workOrders
.AsNoTracking()
.Where(wo => wo.ScheduledDate.HasValue
&& wo.ScheduledDate.Value <= cutoff
&& wo.LifecycleStatus != LifecycleStatus.Completed
&& wo.LifecycleStatus != LifecycleStatus.Canceled
&& !assignedWorkOrderIds.Contains(wo.Id))
.OrderBy(wo => wo.ScheduledDate)
.Select(wo => new
{
Id = $"no-vendor-{wo.Id}",
Type = "NoVendor",
Severity = "Medium",
Group = "No Vendor",
Title = "No vendor assigned — 48h to service",
WorkOrderId = wo.Id,
WorkOrderNumber = wo.InternalWONumber ?? wo.WorkerOrderNumber,
TriggeredAt = wo.ScheduledDate!.Value.AddHours(-48),
ScheduledAt = wo.ScheduledDate,
Action = new { Label = "Choose vendor", Href = $"/workorders/{wo.Id}?assignVendor=1" }
})
.ToListAsync();
var dispatches = await ActiveScheduledDispatches().ToListAsync();
var conflicts = BuildConflictPairs(dispatches)
.Select(pair => new
{
Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}",
Type = "VendorConflict",
Severity = "Medium",
Group = "Vendor Conflict",
Title = "Vendor has overlapping appointments",
VendorId = pair.Left.VendorId,
VendorName = pair.Left.VendorName,
WorkOrders = new[]
{
new { pair.Left.WorkOrderId, pair.Left.WorkOrderNumber, pair.Left.Start, pair.Left.End },
new { pair.Right.WorkOrderId, pair.Right.WorkOrderNumber, pair.Right.Start, pair.Right.End }
},
TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start,
Actions = new[]
{
new { Label = pair.Left.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Left.WorkOrderId}" },
new { Label = pair.Right.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Right.WorkOrderId}" }
}
})
.ToList();
return Ok(new
{
GeneratedAt = now,
Items = noVendor.Cast<object>().Concat(conflicts).OrderBy(item =>
item.GetType().GetProperty("TriggeredAt")?.GetValue(item)).ToList()
});
}
[HttpGet("availability")]
public async Task<IActionResult> GetAvailability(
[FromQuery] DateTime? start,
[FromQuery] DateTime? end,
[FromQuery] int? locationId,
[FromQuery] string? trade)
{
var now = _timeProvider.GetUtcNow().UtcDateTime;
var requestedStart = start ?? now;
var requestedEnd = end ?? requestedStart.AddHours(2);
if (requestedEnd <= requestedStart)
return BadRequest(new Response { Status = "Error", Message = "end must be after start" });
var vendors = await _db.Vendors.AsNoTracking()
.Where(v => v.IsActive && (v.IsDeleted == null || v.IsDeleted == false))
.ToListAsync();
var preferences = locationId.HasValue
? await _db.SitePreferredVendors.AsNoTracking()
.Where(p => p.LocationId == locationId.Value)
.ToListAsync()
: new List<SitePreferredVendor>();
var dispatches = await ActiveScheduledDispatches().ToListAsync();
var result = vendors.Select(vendor =>
{
var matchingDispatches = dispatches
.Where(dispatch => dispatch.VendorId == vendor.Id
&& WindowsOverlap(requestedStart, requestedEnd, dispatch.Start, dispatch.End))
.ToList();
var matchingPreference = preferences
.Where(preference => preference.VendorId == vendor.Id
&& (string.IsNullOrWhiteSpace(preference.Trade)
|| string.Equals(preference.Trade, trade, StringComparison.OrdinalIgnoreCase)))
.OrderBy(preference => preference.SortOrder)
.FirstOrDefault();
var signalIsFresh = vendor.AvailabilityUpdatedAt.HasValue
&& vendor.AvailabilityUpdatedAt.Value >= now.AddHours(-24);
var status = matchingDispatches.Count > 0
? "Unavailable"
: signalIsFresh && new[] { "Available", "Unavailable" }.Contains(vendor.AvailabilityStatus)
? vendor.AvailabilityStatus
: "Unknown";
return new
{
vendor.Id,
vendor.CompanyName,
vendor.ContactName,
vendor.TradeSpecialties,
AvailabilityStatus = status,
vendor.AvailabilityUpdatedAt,
IsPreferred = matchingPreference != null,
PreferenceRank = matchingPreference?.SortOrder,
Conflicts = matchingDispatches.Select(dispatch => new
{
dispatch.WorkOrderId,
dispatch.WorkOrderNumber,
dispatch.Start,
dispatch.End
})
};
})
.OrderBy(vendor => vendor.IsPreferred ? 0 : 1)
.ThenBy(vendor => vendor.PreferenceRank ?? int.MaxValue)
.ThenBy(vendor => vendor.AvailabilityStatus == "Available" ? 0
: vendor.AvailabilityStatus == "Unknown" ? 1 : 2)
.ThenBy(vendor => vendor.CompanyName)
.ToList();
return Ok(new { GeneratedAt = now, StaleAfterHours = 24, Data = result });
}
[HttpPut("vendors/{vendorId:int}/availability")]
public async Task<IActionResult> UpdateAvailability(int vendorId, [FromBody] AvailabilityRequest body)
{
var normalized = body.Status?.Trim();
if (normalized is not ("Available" or "Unavailable" or "Unknown"))
return BadRequest(new Response { Status = "Error", Message = "status must be Available, Unavailable, or Unknown" });
var vendor = await _db.Vendors.FindAsync(vendorId);
if (vendor == null) return NotFound();
var now = _timeProvider.GetUtcNow().UtcDateTime;
var actor = Actor();
var previous = vendor.AvailabilityStatus;
vendor.AvailabilityStatus = normalized;
vendor.AvailabilityUpdatedAt = now;
vendor.LastModificationTime = now;
_db.VendorAuditLogs.Add(new VendorAuditLog
{
VendorId = vendorId,
FieldName = nameof(Vendor.AvailabilityStatus),
OldValue = previous,
NewValue = normalized,
Actor = actor,
CreatedAt = now
});
await _db.SaveChangesAsync();
return Ok(new { vendor.Id, vendor.AvailabilityStatus, vendor.AvailabilityUpdatedAt });
}
[HttpGet("sites/{locationId:int}/preferred-vendors")]
public async Task<IActionResult> GetSitePreferences(int locationId)
{
var preferences = await _db.SitePreferredVendors
.AsNoTracking()
.Where(p => p.LocationId == locationId)
.Include(p => p.Vendor)
.OrderBy(p => p.SortOrder)
.Select(p => new
{
p.Id,
p.LocationId,
p.VendorId,
VendorName = p.Vendor!.CompanyName,
p.Trade,
p.SortOrder,
IsStale = !p.Vendor.IsActive || (p.Vendor.IsDeleted ?? false),
p.LastModificationTime,
p.UpdatedBy
})
.ToListAsync();
return Ok(preferences);
}
[HttpPut("sites/{locationId:int}/preferred-vendors")]
public async Task<IActionResult> ReplaceSitePreferences(int locationId, [FromBody] List<SitePreferenceRequest> body)
{
if (!await _db.Locations.AnyAsync(location => location.Id == locationId))
return NotFound(new Response { Status = "Error", Message = "Location not found" });
if (body.GroupBy(item => new { item.VendorId, Trade = item.Trade?.Trim().ToLowerInvariant() }).Any(group => group.Count() > 1))
return BadRequest(new Response { Status = "Error", Message = "Duplicate vendor/trade preference" });
var vendorIds = body.Select(item => item.VendorId).Distinct().ToList();
var vendors = await _db.Vendors.Where(vendor => vendorIds.Contains(vendor.Id)).ToDictionaryAsync(vendor => vendor.Id);
foreach (var item in body)
{
if (!vendors.TryGetValue(item.VendorId, out var vendor) || !vendor.IsActive || (vendor.IsDeleted ?? false))
return BadRequest(new Response { Status = "Error", Message = $"Vendor {item.VendorId} is inactive or missing" });
if (!string.IsNullOrWhiteSpace(item.Trade)
&& !(vendor.TradeSpecialties ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Contains(item.Trade.Trim(), StringComparer.OrdinalIgnoreCase))
return BadRequest(new Response { Status = "Error", Message = $"Vendor {item.VendorId} does not support trade '{item.Trade}'" });
}
var now = _timeProvider.GetUtcNow().UtcDateTime;
var actor = Actor();
var existing = await _db.SitePreferredVendors.Where(p => p.LocationId == locationId).ToListAsync();
_db.SitePreferredVendors.RemoveRange(existing);
_db.SitePreferredVendors.AddRange(body.Select((item, index) => new SitePreferredVendor
{
LocationId = locationId,
VendorId = item.VendorId,
Trade = string.IsNullOrWhiteSpace(item.Trade) ? null : item.Trade.Trim(),
SortOrder = item.SortOrder ?? index,
CreatedDate = now,
LastModificationTime = now,
createdby = actor,
UpdatedBy = actor
}));
foreach (var vendorId in existing.Select(p => p.VendorId).Concat(vendorIds).Distinct())
{
_db.VendorAuditLogs.Add(new VendorAuditLog
{
VendorId = vendorId,
FieldName = $"SitePreference:{locationId}",
OldValue = string.Join(",", existing.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")),
NewValue = string.Join(",", body.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")),
Actor = actor,
CreatedAt = now
});
}
await _db.SaveChangesAsync();
return await GetSitePreferences(locationId);
}
[HttpPut("documents/{documentId:int}/review")]
public async Task<IActionResult> ReviewDocument(int documentId, [FromBody] DocumentReviewRequest body)
{
if (body.ReviewStatus is not ("Approved" or "Rejected"))
return BadRequest(new Response { Status = "Error", Message = "reviewStatus must be Approved or Rejected" });
if (body.ReviewStatus == "Rejected" && string.IsNullOrWhiteSpace(body.RejectionReason))
return BadRequest(new Response { Status = "Error", Message = "A rejection reason is required" });
var document = await _db.VendorCompletionDocuments.FindAsync(documentId);
if (document == null) return NotFound();
if (document.ScanStatus != "Passed")
return Conflict(new Response { Status = "Conflict", Message = "A document cannot be approved or reviewed until malware scanning passes" });
var now = _timeProvider.GetUtcNow().UtcDateTime;
var previous = document.ReviewStatus;
document.ReviewStatus = body.ReviewStatus;
document.RejectionReason = body.ReviewStatus == "Rejected" ? body.RejectionReason?.Trim() : null;
document.ReviewedAt = now;
document.LastModificationTime = now;
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = document.WorkOrderId,
UserId = Actor(),
FieldName = $"Completion Document {document.OriginalFileName}",
OldValue = previous,
NewValue = document.ReviewStatus,
Action = "completion_document_reviewed",
CreatedAt = now
});
await _db.SaveChangesAsync();
return Ok(new { document.Id, document.ReviewStatus, document.RejectionReason, document.ReviewedAt });
}
[HttpPut("dispatches/{dispatchId:int}/commercial-status")]
public async Task<IActionResult> UpdateCommercialStatus(int dispatchId, [FromBody] CommercialStatusRequest body)
{
var invoiceStatuses = new[] { "Not Submitted", "Processing", "Confirmed", "Rejected" };
var paymentStatuses = new[] { "Unavailable", "Pending", "Scheduled", "Paid", "On Hold" };
if (!invoiceStatuses.Contains(body.InvoiceStatus))
return BadRequest(new Response { Status = "Error", Message = "Invalid invoice status" });
if (!paymentStatuses.Contains(body.PaymentStatus))
return BadRequest(new Response { Status = "Error", Message = "Invalid payment status" });
var dispatch = await _db.Dispatches.FindAsync(dispatchId);
if (dispatch == null) return NotFound();
var now = _timeProvider.GetUtcNow().UtcDateTime;
var oldValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}";
dispatch.InvoiceStatus = body.InvoiceStatus;
dispatch.PaymentStatus = body.PaymentStatus;
dispatch.InvoiceNumber = string.IsNullOrWhiteSpace(body.InvoiceNumber) ? null : body.InvoiceNumber.Trim();
dispatch.CommercialStatusUpdatedAt = now;
dispatch.LastModificationTime = now;
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
UserId = Actor(),
FieldName = $"Dispatch {dispatch.DispatchNumber} Commercial Status",
OldValue = oldValue,
NewValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}",
Action = "commercial_status_updated",
CreatedAt = now
});
await _db.SaveChangesAsync();
return Ok(new
{
dispatch.Id,
dispatch.PONumber,
dispatch.InvoiceNumber,
dispatch.InvoiceStatus,
dispatch.PaymentStatus,
dispatch.CommercialStatusUpdatedAt
});
}
[HttpPut("work-orders/{workOrderId:int}/assignment")]
public async Task<IActionResult> AssignVendor(int workOrderId, [FromBody] VendorAssignmentRequest body)
{
var workOrder = await _db.workOrders.FindAsync(workOrderId);
if (workOrder == null) return NotFound(new Response { Status = "Error", Message = "Work order not found" });
var vendor = await _db.Vendors.FindAsync(body.VendorId);
if (vendor == null || !vendor.IsActive || (vendor.IsDeleted ?? false))
return BadRequest(new Response { Status = "Error", Message = "Only an active vendor can be assigned" });
var start = body.ScheduledStart ?? workOrder.ScheduledStart ?? workOrder.ScheduledDate;
var end = body.ScheduledEnd ?? workOrder.ScheduledEnd ?? start?.AddHours(2);
var scheduledDispatches = start.HasValue && end.HasValue
? await ActiveScheduledDispatches()
.Where(dispatch => dispatch.VendorId == vendor.Id && dispatch.WorkOrderId != workOrderId)
.ToListAsync()
: new List<ScheduledDispatch>();
var conflicts = start.HasValue && end.HasValue
? scheduledDispatches.Where(dispatch => WindowsOverlap(start.Value, end.Value, dispatch.Start, dispatch.End)).ToList()
: new List<ScheduledDispatch>();
var linkedDispatchIds = _db.DispatchWorkOrders
.Where(link => link.WorkOrderId == workOrderId)
.Select(link => link.DispatchId);
var dispatch = await _db.Dispatches
.Where(item => (item.WorkOrderId == workOrderId || linkedDispatchIds.Contains(item.Id))
&& !TerminalDispatchStatuses.Contains(item.Status ?? ""))
.OrderByDescending(item => item.CreatedDate)
.FirstOrDefaultAsync();
var now = _timeProvider.GetUtcNow().UtcDateTime;
var previousVendorId = dispatch?.VendorId;
if (dispatch == null)
{
dispatch = new Dispatch
{
WorkOrderId = workOrderId,
VendorId = vendor.Id,
DispatchNumber = await _dispatchDataService.GenerateNextDispatchNumberAsync(),
PONumber = await _dispatchDataService.GenerateNextPONumberAsync(),
Status = "Sent",
ScheduledDate = start,
EstimatedArrivalAt = body.EstimatedArrivalAt,
EtaManualOverride = body.EtaManualOverride,
DispatchedAt = now,
CreatedDate = now,
createdby = Actor()
};
_db.Dispatches.Add(dispatch);
}
else
{
dispatch.VendorId = vendor.Id;
dispatch.ScheduledDate = start;
dispatch.EstimatedArrivalAt = body.EstimatedArrivalAt;
dispatch.EtaManualOverride = body.EtaManualOverride;
dispatch.LastModificationTime = now;
}
workOrder.ScheduledStart = body.ScheduledStart ?? workOrder.ScheduledStart;
workOrder.ScheduledEnd = body.ScheduledEnd ?? workOrder.ScheduledEnd;
workOrder.ScheduledDate = start ?? workOrder.ScheduledDate;
workOrder.LastModificationTime = now;
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = workOrderId,
UserId = Actor(),
FieldName = "Vendor Assignment",
OldValue = previousVendorId?.ToString(),
NewValue = vendor.Id.ToString(),
Action = previousVendorId.HasValue ? "vendor_assignment_changed" : "vendor_assigned",
CreatedAt = now
});
await _db.SaveChangesAsync();
return Ok(new
{
dispatch.Id,
dispatch.VendorId,
VendorName = vendor.CompanyName,
dispatch.EstimatedArrivalAt,
dispatch.EtaManualOverride,
AvailabilityStatus = conflicts.Count > 0 ? "Unavailable"
: vendor.AvailabilityUpdatedAt >= now.AddHours(-24) ? vendor.AvailabilityStatus : "Unknown",
Conflicts = conflicts.Select(conflict => new
{
conflict.WorkOrderId,
conflict.WorkOrderNumber,
conflict.Start,
conflict.End
})
});
}
[HttpGet("insights")]
public async Task<IActionResult> GetInsights(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int? locationId,
[FromQuery] string? trade,
[FromQuery] int? vendorId)
{
var rows = await BuildInsightRows(from, to, locationId, trade, vendorId);
return Ok(new
{
GeneratedAt = _timeProvider.GetUtcNow().UtcDateTime,
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)
{
var rows = await BuildInsightRows(from, to, locationId, trade, vendorId);
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-{DateTime.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)
{
var rows = await BuildInsightRows(from, to, locationId, trade, vendorId);
var lines = new List<string>
{
"Vendor Performance Insights",
$"Generated: {_timeProvider.GetUtcNow():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-{DateTime.UtcNow:yyyyMMddHHmmss}.pdf");
}
private IQueryable<ScheduledDispatch> ActiveScheduledDispatches()
{
var direct = _db.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 ScheduledDispatch
{
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 = _db.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 ScheduledDispatch
{
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 direct.Concat(linked);
}
private static IEnumerable<(ScheduledDispatch Left, ScheduledDispatch Right)> BuildConflictPairs(List<ScheduledDispatch> dispatches)
{
for (var leftIndex = 0; leftIndex < dispatches.Count; leftIndex++)
{
for (var rightIndex = leftIndex + 1; rightIndex < dispatches.Count; rightIndex++)
{
var left = dispatches[leftIndex];
var right = dispatches[rightIndex];
if (left.VendorId == right.VendorId
&& left.WorkOrderId != right.WorkOrderId
&& WindowsOverlap(left.Start, left.End, right.Start, right.End))
yield return (left, right);
}
}
}
private async Task<List<VendorInsightRow>> BuildInsightRows(
DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId)
{
var query = _db.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);
var dispatches = await query.ToListAsync();
return dispatches.GroupBy(dispatch => dispatch.VendorId)
.Select(group =>
{
var completed = group.Where(dispatch => dispatch.Status is "Completed" or "Verified").ToList();
var canceled = group.Count(dispatch => dispatch.Status is "Cancelled" or "Canceled" or "Refused");
var acknowledgedWithSchedule = group.Where(dispatch => dispatch.AcknowledgedAt.HasValue && dispatch.ScheduledDate.HasValue).ToList();
var completedWithDue = completed.Where(dispatch => dispatch.CompletedDate.HasValue && dispatch.WorkOrder?.DueDate != null).ToList();
var cycleRows = completed.Where(dispatch => dispatch.CompletedDate.HasValue && (dispatch.DispatchedAt ?? dispatch.CreatedDate).HasValue).ToList();
return new VendorInsightRow
{
VendorId = group.Key,
Vendor = group.First().Vendor?.CompanyName ?? $"Vendor {group.Key}",
CompletedJobs = completed.Count,
CancellationRefusalRate = Rate(canceled, group.Count()),
OnTimeArrivalRate = Rate(
acknowledgedWithSchedule.Count(dispatch => dispatch.AcknowledgedAt <= dispatch.ScheduledDate),
acknowledgedWithSchedule.Count),
OnTimeCompletionRate = Rate(
completedWithDue.Count(dispatch => dispatch.CompletedDate <= dispatch.WorkOrder!.DueDate),
completedWithDue.Count),
AverageCycleHours = cycleRows.Count == 0 ? null : cycleRows.Average(dispatch =>
(dispatch.CompletedDate!.Value - (dispatch.DispatchedAt ?? dispatch.CreatedDate)!.Value).TotalHours)
};
})
.OrderBy(row => row.Vendor)
.ToList();
}
private string Actor() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.Identity?.Name
?? "unknown";
private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd)
=> leftStart < rightEnd && rightStart < leftEnd;
private static decimal Rate(int numerator, int denominator)
=> denominator == 0 ? 0 : Math.Round((decimal)numerator / denominator, 4);
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());
}
public sealed class AvailabilityRequest
{
public string? Status { get; set; }
}
public sealed class SitePreferenceRequest
{
public int VendorId { get; set; }
public string? Trade { get; set; }
public int? SortOrder { get; set; }
}
public sealed class DocumentReviewRequest
{
public string ReviewStatus { get; set; } = "";
public string? RejectionReason { get; set; }
}
public sealed class CommercialStatusRequest
{
public string InvoiceStatus { get; set; } = "Not Submitted";
public string PaymentStatus { get; set; } = "Unavailable";
public string? InvoiceNumber { get; set; }
}
public sealed class VendorAssignmentRequest
{
public int VendorId { get; set; }
public DateTime? ScheduledStart { get; set; }
public DateTime? ScheduledEnd { get; set; }
public DateTime? EstimatedArrivalAt { get; set; }
public bool EtaManualOverride { get; set; }
}
private sealed class ScheduledDispatch
{
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; }
}
private sealed class VendorInsightRow
{
public int VendorId { get; set; }
public string Vendor { get; set; } = "";
public int CompletedJobs { get; set; }
public decimal CancellationRefusalRate { get; set; }
public decimal OnTimeArrivalRate { get; set; }
public decimal OnTimeCompletionRate { get; set; }
public double? AverageCycleHours { get; set; }
}
}
}

View file

@ -20,13 +20,20 @@ namespace Api.SeaHavenIndustries.Controllers
private readonly VendorPortalTokenService _tokens;
private readonly IConfiguration _config;
private readonly SendMessage _sendMessage;
private readonly IWebHostEnvironment _environment;
public VendorPortalController(ApplicationDbContext db, VendorPortalTokenService tokens, IConfiguration config, SendMessage sendMessage)
public VendorPortalController(
ApplicationDbContext db,
VendorPortalTokenService tokens,
IConfiguration config,
SendMessage sendMessage,
IWebHostEnvironment environment)
{
_db = db;
_tokens = tokens;
_config = config;
_sendMessage = sendMessage;
_environment = environment;
}
[HttpGet("session")]
@ -71,6 +78,9 @@ namespace Api.SeaHavenIndustries.Controllers
d.Id,
d.DispatchNumber,
d.PONumber,
d.InvoiceNumber,
d.InvoiceStatus,
d.PaymentStatus,
d.Status,
d.NTEAmount,
d.ScheduledDate,
@ -108,6 +118,27 @@ namespace Api.SeaHavenIndustries.Controllers
.Select(s => new { s.Id, s.SignoffType, s.Name, s.SignatureMethod, s.SignedAt })
.ToListAsync();
var documents = await _db.VendorCompletionDocuments
.Where(document => document.DispatchId == id && document.VendorId == vendor.Id)
.OrderByDescending(document => document.CreatedDate)
.Select(document => new
{
document.Id,
document.OriginalFileName,
document.ContentType,
document.SizeBytes,
document.ScanStatus,
document.ReviewStatus,
document.RejectionReason,
document.Version,
document.ReplacesDocumentId,
document.CreatedDate,
document.ScannedAt,
document.ReviewedAt,
CanDownload = document.ScanStatus == "Passed"
})
.ToListAsync();
var rawUplifts = await (from u in _db.DispatchUpliftRequests
where u.DispatchId == id && (u.IsDeleted == null || u.IsDeleted == false)
join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs
@ -210,6 +241,10 @@ namespace Api.SeaHavenIndustries.Controllers
dispatch.Id,
dispatch.DispatchNumber,
dispatch.PONumber,
dispatch.InvoiceNumber,
dispatch.InvoiceStatus,
dispatch.PaymentStatus,
dispatch.CommercialStatusUpdatedAt,
dispatch.Status,
dispatch.NTEAmount,
dispatch.Description,
@ -239,6 +274,7 @@ namespace Api.SeaHavenIndustries.Controllers
},
Checklist = checklist,
Signoffs = signoffs,
Documents = documents,
Comments = comments,
UpliftRequests = upliftRequests
}
@ -589,6 +625,133 @@ namespace Api.SeaHavenIndustries.Controllers
return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } });
}
[HttpPost("dispatches/{id:int}/documents")]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<IActionResult> UploadCompletionDocument(
int id,
[FromForm] IFormFile file,
[FromForm] int? replacesDocumentId = null)
{
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
if (file == null || file.Length == 0)
return BadRequest(new Response { Status = "Error", Message = "A non-empty file is required" });
if (file.Length > 10 * 1024 * 1024)
return BadRequest(new Response { Status = "Error", Message = "Files must be 10 MB or smaller" });
var allowed = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["application/pdf"] = new[] { ".pdf" },
["image/jpeg"] = new[] { ".jpg", ".jpeg" },
["image/png"] = new[] { ".png" }
};
var extension = Path.GetExtension(file.FileName);
if (!allowed.TryGetValue(file.ContentType, out var extensions)
|| !extensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
return BadRequest(new Response { Status = "Error", Message = "Only PDF, JPG, and PNG files are allowed" });
await using var signatureStream = file.OpenReadStream();
if (!await HasExpectedSignature(signatureStream, file.ContentType))
return BadRequest(new Response { Status = "Error", Message = "File content does not match its declared type" });
var workOrderId = dispatch.WorkOrderId
?? await _db.DispatchWorkOrders
.Where(link => link.DispatchId == dispatch.Id)
.OrderBy(link => link.Id)
.Select(link => link.WorkOrderId)
.FirstOrDefaultAsync();
if (workOrderId == 0)
return BadRequest(new Response { Status = "Error", Message = "Dispatch is not linked to a work order" });
VendorCompletionDocument? replaced = null;
if (replacesDocumentId.HasValue)
{
replaced = await _db.VendorCompletionDocuments.FirstOrDefaultAsync(document =>
document.Id == replacesDocumentId.Value
&& document.DispatchId == id
&& document.VendorId == vendor.Id);
if (replaced == null)
return BadRequest(new Response { Status = "Error", Message = "Replacement document was not found" });
}
var now = DateTime.UtcNow;
var document = new VendorCompletionDocument
{
VendorId = vendor.Id,
DispatchId = id,
WorkOrderId = workOrderId,
OriginalFileName = Path.GetFileName(file.FileName),
StoredFileName = $"{Guid.NewGuid():N}{extension.ToLowerInvariant()}",
ContentType = file.ContentType,
SizeBytes = file.Length,
ScanStatus = "Pending",
ReviewStatus = "Processing",
Version = (replaced?.Version ?? 0) + 1,
ReplacesDocumentId = replaced?.Id,
CreatedDate = now,
createdby = $"vendor:{vendor.Id}"
};
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await using (var target = System.IO.File.Create(path))
{
await file.CopyToAsync(target);
}
try
{
_db.VendorCompletionDocuments.Add(document);
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = document.WorkOrderId,
FieldName = "Completion Document",
OldValue = replaced?.OriginalFileName,
NewValue = document.OriginalFileName,
Action = replaced == null ? "vendor_document_uploaded" : "vendor_document_replaced",
CreatedAt = now
});
await _db.SaveChangesAsync();
}
catch
{
System.IO.File.Delete(path);
throw;
}
return Ok(new DataResponse
{
Status = "Success",
Data = new
{
document.Id,
document.OriginalFileName,
document.SizeBytes,
document.ScanStatus,
document.ReviewStatus,
document.Version,
document.ReplacesDocumentId
}
});
}
[HttpGet("dispatches/{id:int}/documents/{documentId:int}")]
public async Task<IActionResult> DownloadCompletionDocument(int id, int documentId)
{
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var document = await _db.VendorCompletionDocuments.AsNoTracking().FirstOrDefaultAsync(item =>
item.Id == documentId && item.DispatchId == id && item.VendorId == vendor.Id);
if (document == null) return NotFound();
if (document.ScanStatus != "Passed")
return StatusCode(StatusCodes.Status423Locked,
new Response { Status = "Processing", Message = "This file remains quarantined until malware scanning passes" });
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
if (!System.IO.File.Exists(path)) return NotFound();
return PhysicalFile(path, document.ContentType, document.OriginalFileName);
}
private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, Vendor vendor)
{
try
@ -636,6 +799,19 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
private static async Task<bool> HasExpectedSignature(Stream stream, string contentType)
{
var bytes = new byte[8];
var count = await stream.ReadAsync(bytes);
if (string.Equals(contentType, "application/pdf", StringComparison.OrdinalIgnoreCase))
return count >= 5 && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46 && bytes[4] == 0x2D;
if (string.Equals(contentType, "image/jpeg", StringComparison.OrdinalIgnoreCase))
return count >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
if (string.Equals(contentType, "image/png", StringComparison.OrdinalIgnoreCase))
return count >= 8 && bytes.SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A });
return false;
}
private async Task<Vendor?> ResolveVendorAsync()
{
if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null;

View file

@ -95,6 +95,12 @@ namespace Api.SeaHavenIndustries.Controllers
_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,
@ -109,19 +115,26 @@ namespace Api.SeaHavenIndustries.Controllers
if (weekValidationError != null)
return BadRequest(weekValidationError);
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var query = new WorkOrderBoardQueryDto
try
{
WeekStart = weekStart,
WeekEnd = weekEnd,
Dispatchers = dispatchers,
MyWorkOrders = myWorkOrders,
Types = types,
Search = search
};
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);
var result = await _workOrderBoardService.GetBoardAsync(query, userId);
return Ok(result);
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("board/search")]
@ -139,6 +152,10 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
/// <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()
{
@ -670,6 +687,8 @@ namespace Api.SeaHavenIndustries.Controllers
d.Description,
d.Status,
d.ScheduledDate,
d.EstimatedArrivalAt,
d.EtaManualOverride,
d.CompletedDate,
d.DispatchedAt,
d.EmailSent,
@ -1039,7 +1058,10 @@ namespace Api.SeaHavenIndustries.Controllers
foreach (var vendorId in model.VendorIds)
{
var vendor = await _vendorDataService.GetByIdAsync(vendorId);
if (vendor == null) continue;
if (vendor == null)
return BadRequest(new Response { Status = "Error", Message = $"Vendor {vendorId} was not found" });
if (!vendor.IsActive || (vendor.IsDeleted ?? false))
return BadRequest(new Response { Status = "Error", Message = $"Vendor {vendorId} is inactive and cannot receive a new assignment" });
var poNumber = $"VPO-{nextPO:D5}";
nextPO++;
@ -1058,6 +1080,8 @@ namespace Api.SeaHavenIndustries.Controllers
Description = model.Description,
Status = "Sent",
ScheduledDate = model.ScheduledDate,
EstimatedArrivalAt = model.EstimatedArrivalAt,
EtaManualOverride = model.EtaManualOverride,
DispatchedAt = DateTime.UtcNow,
ReplyToAddress = replyTo,
DispatchWorkOrders = workOrders.Select(wo => new DispatchWorkOrder { WorkOrderId = wo.Id }).ToList()
@ -1137,6 +1161,19 @@ namespace Api.SeaHavenIndustries.Controllers
Action = AuditAction.Dispatch.ToString(),
CreatedAt = DateTime.UtcNow
});
if (model.EstimatedArrivalAt.HasValue)
{
await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = wo.Id,
UserId = userId,
FieldName = "Vendor ETA",
OldValue = "",
NewValue = model.EstimatedArrivalAt.Value.ToUniversalTime().ToString("O"),
Action = model.EtaManualOverride ? "vendor_eta_manual_override" : "vendor_eta_set",
CreatedAt = DateTime.UtcNow
});
}
}
dispatches.Add(new { savedDispatch.Id, dispatchNumber, poNumber, vendorName = vendor.CompanyName, savedDispatch.Status, workOrderCount = workOrders.Count });
@ -1163,6 +1200,8 @@ namespace Api.SeaHavenIndustries.Controllers
d.Description,
d.Status,
d.ScheduledDate,
d.EstimatedArrivalAt,
d.EtaManualOverride,
d.CompletedDate,
d.DispatchedAt,
d.EmailSent,
@ -1189,6 +1228,8 @@ namespace Api.SeaHavenIndustries.Controllers
x.Description,
x.Status,
x.ScheduledDate,
x.EstimatedArrivalAt,
x.EtaManualOverride,
x.CompletedDate,
x.DispatchedAt,
x.EmailSent,
@ -1277,6 +1318,21 @@ namespace Api.SeaHavenIndustries.Controllers
dispatch.Status = model.Status;
dispatch.NTEAmount = model.NTEAmount;
dispatch.ScheduledDate = model.ScheduledDate;
if (dispatch.EstimatedArrivalAt != model.EstimatedArrivalAt)
{
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
UserId = userId,
FieldName = "Vendor ETA",
OldValue = dispatch.EstimatedArrivalAt?.ToUniversalTime().ToString("O") ?? "",
NewValue = model.EstimatedArrivalAt?.ToUniversalTime().ToString("O") ?? "",
Action = model.EtaManualOverride ? "vendor_eta_manual_override" : "vendor_eta_change",
CreatedAt = DateTime.UtcNow
});
}
dispatch.EstimatedArrivalAt = model.EstimatedArrivalAt;
dispatch.EtaManualOverride = model.EtaManualOverride;
dispatch.CompletedDate = model.CompletedDate;
dispatch.Description = model.Description;

View file

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

View file

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

View file

@ -3,19 +3,34 @@ namespace Api.SeaHavenIndustries.DTOs
public class Vendor_DTO
{
public string? CompanyName { get; set; }
public int? CompanyId { get; set; }
public string? ContactName { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? CompanyPhone { get; set; }
public string? PreferredContact { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? TradeSpecialties { get; set; }
public bool IsActive { get; set; } = true;
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public bool? IsActive { get; set; }
}
public class EditVendor_DTO : Vendor_DTO
{
public int Id { get; set; }
}
public class WorkOrderVendorUpdate_DTO
{
public int WorkOrderId { get; set; }
public string? ContactName { get; set; }
public string? PreferredContact { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? Notes { get; set; }
}
}

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,75 @@
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, VendorCompletionDocument document) =>
Path.Combine(Root(contentRoot), document.VendorId.ToString(), document.DispatchId.ToString(), document.StoredFileName);
}
}

View file

@ -42,6 +42,9 @@ option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().All
builder.Services.AddScoped<SendMessage>();
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.VendorPortalTokenService>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<IDocumentScanner, ClamAvDocumentScanner>();
builder.Services.AddHostedService<VendorDocumentScanWorker>();
builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp =>
{
var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance();

View file

@ -14,6 +14,12 @@
// Provide the real value via environment variable SendGrid__ApiKey or the instance secret store.
"ApiKey": "${SENDGRID_API_KEY}"
},
// Production must supply a reachable ClamAV Host (environment variable ClamAV__Host).
// While Host is empty the scanner is unavailable and uploads stay quarantined (423 Locked).
"ClamAV": {
"Host": "",
"Port": 3310
},
"AllowedHosts": "*",
"JWT": {
"ValidAudience": "http://console.seahavenind.com",

View file

@ -17,6 +17,13 @@
// Provide the real value via environment variable SendGrid__ApiKey or user-secrets.
"ApiKey": "${SENDGRID_API_KEY}"
},
// Malware scanner for vendor completion document uploads. While Host is empty the
// scanner is considered unavailable and uploaded files stay quarantined (download
// returns 423 Locked until a scan passes). Production must supply a real Host.
"ClamAV": {
"Host": "",
"Port": 3310
},
"AllowedHosts": "*",
"JWT": {
"ValidAudience": "http://localhost:4200",

View file

@ -77,6 +77,26 @@ namespace Data.SeaHavenIndustries
.WithMany()
.HasForeignKey(w => w.PrimaryDispatchId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<VendorCompany>()
.HasIndex(c => c.NormalizedName)
.IsUnique();
builder.Entity<Vendor>()
.HasOne(v => v.Company)
.WithMany(c => c.Vendors)
.HasForeignKey(v => v.CompanyId)
.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<Locations> Locations { get; set; }
@ -95,7 +115,11 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderWeekRolledLedger> WorkOrderWeekRolledLedgers { get; set; }
public DbSet<DropdownOption> DropdownOptions { get; set; }
public DbSet<Vendor> Vendors { get; set; }
public DbSet<VendorCompany> VendorCompanies { get; set; }
public DbSet<VendorAuditLog> VendorAuditLogs { 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<DispatchWorkOrder> DispatchWorkOrders { get; set; }
public DbSet<DispatchChecklistItem> DispatchChecklistItems { get; set; }

View file

@ -0,0 +1,41 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260720220000_AddVendorDirectoryDetails")]
public partial class AddVendorDirectoryDetails : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CompanyPhone",
table: "Vendors",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "GoogleMapsUrl",
table: "Vendors",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Notes",
table: "Vendors",
type: "nvarchar(max)",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "CompanyPhone", table: "Vendors");
migrationBuilder.DropColumn(name: "GoogleMapsUrl", table: "Vendors");
migrationBuilder.DropColumn(name: "Notes", table: "Vendors");
}
}
}

View file

@ -0,0 +1,185 @@
using System;
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260723194013_AddVendorCompanyAndAudit")]
public partial class AddVendorCompanyAndAudit : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "CompanyId",
table: "Vendors",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PreferredContact",
table: "Vendors",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.CreateTable(
name: "VendorCompanies",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
NormalizedName = table.Column<string>(type: "nvarchar(450)", nullable: false),
CompanyPhone = table.Column<string>(type: "nvarchar(max)", nullable: true),
Email = table.Column<string>(type: "nvarchar(max)", nullable: true),
Address = table.Column<string>(type: "nvarchar(max)", nullable: true),
City = table.Column<string>(type: "nvarchar(max)", nullable: true),
State = table.Column<string>(type: "nvarchar(max)", nullable: true),
Zip = table.Column<string>(type: "nvarchar(max)", nullable: true),
GoogleMapsUrl = 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_VendorCompanies", x => x.Id);
});
migrationBuilder.CreateTable(
name: "VendorAuditLogs",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
VendorId = table.Column<int>(type: "int", nullable: false),
WorkOrderId = table.Column<int>(type: "int", nullable: true),
FieldName = table.Column<string>(type: "nvarchar(max)", nullable: true),
OldValue = table.Column<string>(type: "nvarchar(max)", nullable: true),
NewValue = table.Column<string>(type: "nvarchar(max)", nullable: true),
Actor = table.Column<string>(type: "nvarchar(max)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VendorAuditLogs", x => x.Id);
table.ForeignKey(
name: "FK_VendorAuditLogs_Vendors_VendorId",
column: x => x.VendorId,
principalTable: "Vendors",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
// Backfill: create one VendorCompany per distinct (case-insensitive, trimmed) legacy name.
// Keep all company-owned fields from the same deterministic source vendor row.
migrationBuilder.Sql(@"
WITH RankedVendors AS (
SELECT
v.[CompanyName] AS [Name],
LOWER(LTRIM(RTRIM(v.[CompanyName]))) AS [NormalizedName],
v.[CompanyPhone],
v.[Email],
v.[Address],
v.[City],
v.[State],
v.[Zip],
v.[GoogleMapsUrl],
ROW_NUMBER() OVER (
PARTITION BY LOWER(LTRIM(RTRIM(v.[CompanyName])))
ORDER BY v.[Id]
) AS [RowNumber]
FROM [Vendors] v
WHERE v.[CompanyName] IS NOT NULL AND LTRIM(RTRIM(v.[CompanyName])) <> ''
)
INSERT INTO [VendorCompanies] (
[Name], [NormalizedName], [CompanyPhone], [Email], [Address], [City], [State], [Zip], [GoogleMapsUrl], [CreatedDate]
)
SELECT
ranked.[Name],
ranked.[NormalizedName],
ranked.[CompanyPhone],
ranked.[Email],
ranked.[Address],
ranked.[City],
ranked.[State],
ranked.[Zip],
ranked.[GoogleMapsUrl],
GETUTCDATE() AS [CreatedDate]
FROM RankedVendors ranked
WHERE ranked.[RowNumber] = 1;
UPDATE v
SET v.[CompanyId] = vc.[Id]
FROM [Vendors] v
INNER JOIN [VendorCompanies] vc
ON vc.[NormalizedName] = LOWER(LTRIM(RTRIM(v.[CompanyName])))
WHERE v.[CompanyName] IS NOT NULL AND LTRIM(RTRIM(v.[CompanyName])) <> '';
");
migrationBuilder.CreateIndex(
name: "IX_Vendors_CompanyId",
table: "Vendors",
column: "CompanyId");
migrationBuilder.CreateIndex(
name: "IX_VendorAuditLogs_VendorId",
table: "VendorAuditLogs",
column: "VendorId");
migrationBuilder.CreateIndex(
name: "IX_VendorCompanies_NormalizedName",
table: "VendorCompanies",
column: "NormalizedName",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_Vendors_VendorCompanies_CompanyId",
table: "Vendors",
column: "CompanyId",
principalTable: "VendorCompanies",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Vendors_VendorCompanies_CompanyId",
table: "Vendors");
migrationBuilder.DropIndex(
name: "IX_Vendors_CompanyId",
table: "Vendors");
migrationBuilder.DropIndex(
name: "IX_VendorAuditLogs_VendorId",
table: "VendorAuditLogs");
migrationBuilder.DropIndex(
name: "IX_VendorCompanies_NormalizedName",
table: "VendorCompanies");
migrationBuilder.DropTable(
name: "VendorAuditLogs");
migrationBuilder.DropTable(
name: "VendorCompanies");
migrationBuilder.DropColumn(
name: "CompanyId",
table: "Vendors");
migrationBuilder.DropColumn(
name: "PreferredContact",
table: "Vendors");
}
}
}

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.ToTable("Accounts", (string)null);
b.ToTable("Accounts");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b =>
@ -175,7 +175,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("ContactId");
b.ToTable("Addresses", (string)null);
b.ToTable("Addresses");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
@ -334,7 +334,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("Assets", (string)null);
b.ToTable("Assets");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Category", b =>
@ -371,7 +371,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Categories", (string)null);
b.ToTable("Categories");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b =>
@ -438,7 +438,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkerOrderId");
b.ToTable("Comments", (string)null);
b.ToTable("Comments");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
@ -504,7 +504,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("ContactId");
b.ToTable("ContactDetails", (string)null);
b.ToTable("ContactDetails");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b =>
@ -596,7 +596,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("Contacts", (string)null);
b.ToTable("Contacts");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Department", b =>
@ -614,7 +614,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Departments", (string)null);
b.ToTable("Departments");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b =>
@ -631,6 +631,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateTime?>("AcknowledgedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("CommercialStatusUpdatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("CompletedDate")
.HasColumnType("datetime2");
@ -655,6 +658,19 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<bool>("EmailSent")
.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")
.HasColumnType("bit");
@ -670,6 +686,10 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("PONumber")
.HasColumnType("nvarchar(max)");
b.Property<string>("PaymentStatus")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ReplyToAddress")
.HasColumnType("nvarchar(max)");
@ -705,7 +725,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("Dispatches", (string)null);
b.ToTable("Dispatches");
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b =>
@ -764,7 +784,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("DispatchChecklistItems", (string)null);
b.ToTable("DispatchChecklistItems");
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b =>
@ -818,7 +838,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("DispatchId");
b.ToTable("DispatchSignoffs", (string)null);
b.ToTable("DispatchSignoffs");
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b =>
@ -888,7 +908,7 @@ namespace Data.SeaHavenIndustries.Migrations
.IsUnique()
.HasFilter("[Status] = 'Pending'");
b.ToTable("DispatchUpliftRequests", (string)null);
b.ToTable("DispatchUpliftRequests");
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
@ -911,7 +931,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("DispatchWorkOrders", (string)null);
b.ToTable("DispatchWorkOrders");
});
modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b =>
@ -941,7 +961,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("DropdownOptions", (string)null);
b.ToTable("DropdownOptions");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b =>
@ -1046,7 +1066,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("RegionId");
b.ToTable("Employees", (string)null);
b.ToTable("Employees");
});
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b =>
@ -1086,7 +1106,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId")
.IsUnique();
b.ToTable("EmployeeAddresses", (string)null);
b.ToTable("EmployeeAddresses");
});
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b =>
@ -1114,7 +1134,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId");
b.ToTable("EmployeeEmails", (string)null);
b.ToTable("EmployeeEmails");
});
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b =>
@ -1144,7 +1164,87 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId");
b.ToTable("EmployeePhones", (string)null);
b.ToTable("EmployeePhones");
});
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 =>
@ -1212,7 +1312,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("FollowUps", (string)null);
b.ToTable("FollowUps");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b =>
@ -1237,7 +1337,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("ForgetPasswordCodes", (string)null);
b.ToTable("ForgetPasswordCodes");
});
modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b =>
@ -1255,7 +1355,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("JobTitles", (string)null);
b.ToTable("JobTitles");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
@ -1325,7 +1425,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Locations", (string)null);
b.ToTable("Locations");
});
modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b =>
@ -1383,7 +1483,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("PMSchedules", (string)null);
b.ToTable("PMSchedules");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b =>
@ -1457,7 +1557,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("Quotes", (string)null);
b.ToTable("Quotes");
});
modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b =>
@ -1505,7 +1605,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("QuoteId");
b.ToTable("QuotesLines", (string)null);
b.ToTable("QuotesLines");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Region", b =>
@ -1523,7 +1623,66 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Regions", (string)null);
b.ToTable("Regions");
});
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 =>
@ -1566,7 +1725,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("TaskListTemplates", (string)null);
b.ToTable("TaskListTemplates");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b =>
@ -1611,7 +1770,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("TaskListTemplateId");
b.ToTable("TaskListTemplateItems", (string)null);
b.ToTable("TaskListTemplateItems");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
@ -1713,7 +1872,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("Templates", (string)null);
b.ToTable("Templates");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
@ -1727,12 +1886,25 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Address")
.HasColumnType("nvarchar(max)");
b.Property<string>("AvailabilityStatus")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("AvailabilityUpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("City")
.HasColumnType("nvarchar(max)");
b.Property<int?>("CompanyId")
.HasColumnType("int");
b.Property<string>("CompanyName")
.HasColumnType("nvarchar(max)");
b.Property<string>("CompanyPhone")
.HasColumnType("nvarchar(max)");
b.Property<string>("ContactName")
.HasColumnType("nvarchar(max)");
@ -1748,6 +1920,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<string>("GoogleMapsUrl")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
@ -1760,9 +1935,15 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<string>("Phone")
.HasColumnType("nvarchar(max)");
b.Property<string>("PreferredContact")
.HasColumnType("nvarchar(max)");
b.Property<string>("State")
.HasColumnType("nvarchar(max)");
@ -1777,7 +1958,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Vendors", (string)null);
b.HasIndex("CompanyId");
b.ToTable("Vendors");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b =>
@ -1836,7 +2019,196 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("VendorId");
b.ToTable("VendorAccessTokens", (string)null);
b.ToTable("VendorAccessTokens");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Actor")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("FieldName")
.HasColumnType("nvarchar(max)");
b.Property<string>("NewValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("OldValue")
.HasColumnType("nvarchar(max)");
b.Property<int>("VendorId")
.HasColumnType("int");
b.Property<int?>("WorkOrderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("VendorId");
b.ToTable("VendorAuditLogs");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Address")
.HasColumnType("nvarchar(max)");
b.Property<string>("City")
.HasColumnType("nvarchar(max)");
b.Property<string>("CompanyPhone")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<string>("GoogleMapsUrl")
.HasColumnType("nvarchar(max)");
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>("NormalizedName")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("State")
.HasColumnType("nvarchar(max)");
b.Property<string>("Zip")
.HasColumnType("nvarchar(max)");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique();
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 =>
@ -2094,7 +2466,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("workOrderAttachments", (string)null);
b.ToTable("workOrderAttachments");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b =>
@ -2144,7 +2516,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("WorkOrderAuditLogs", (string)null);
b.ToTable("WorkOrderAuditLogs");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b =>
@ -2188,7 +2560,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("workOrderCategories", (string)null);
b.ToTable("workOrderCategories");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b =>
@ -2235,7 +2607,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("WorkOrderContacts", (string)null);
b.ToTable("WorkOrderContacts");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
@ -2264,7 +2636,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId", "FieldName")
.IsUnique();
b.ToTable("WorkOrderFieldLocks", (string)null);
b.ToTable("WorkOrderFieldLocks");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
@ -2293,7 +2665,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId", "SourceWeekStart")
.IsUnique();
b.ToTable("WorkOrderWeekRolledLedgers", (string)null);
b.ToTable("WorkOrderWeekRolledLedgers");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
@ -2723,6 +3095,25 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Quotes");
});
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
{
b.HasOne("Data.SeaHavenIndustries.Locations", "Location")
.WithMany()
.HasForeignKey("LocationId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor")
.WithMany("SitePreferences")
.HasForeignKey("VendorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Location");
b.Navigation("Vendor");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b =>
{
b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template")
@ -2765,6 +3156,16 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("POC");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
{
b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company")
.WithMany("Vendors")
.HasForeignKey("CompanyId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Company");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b =>
{
b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor")
@ -2776,6 +3177,44 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Vendor");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b =>
{
b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor")
.WithMany("AuditLogs")
.HasForeignKey("VendorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Vendor");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b =>
{
b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch")
.WithMany("CompletionDocuments")
.HasForeignKey("DispatchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor")
.WithMany()
.HasForeignKey("VendorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
.WithMany()
.HasForeignKey("WorkOrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Dispatch");
b.Navigation("Vendor");
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>
{
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser")
@ -2977,6 +3416,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Comments");
b.Navigation("CompletionDocuments");
b.Navigation("DispatchWorkOrders");
b.Navigation("Signoffs");
@ -3020,7 +3461,16 @@ namespace Data.SeaHavenIndustries.Migrations
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
{
b.Navigation("AuditLogs");
b.Navigation("Dispatches");
b.Navigation("SitePreferences");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b =>
{
b.Navigation("Vendors");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>

View file

@ -19,6 +19,12 @@ namespace Data.SeaHavenIndustries
public string? Description { get; set; }
public string? Status { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateTime? EstimatedArrivalAt { get; set; }
public bool EtaManualOverride { get; set; }
public string InvoiceStatus { get; set; } = "Not Submitted";
public string PaymentStatus { get; set; } = "Unavailable";
public string? InvoiceNumber { get; set; }
public DateTime? CommercialStatusUpdatedAt { get; set; }
public DateTime? CompletedDate { get; set; }
public DateTime? DispatchedAt { get; set; }
public DateTime? AcknowledgedAt { get; set; }
@ -35,5 +41,6 @@ namespace Data.SeaHavenIndustries
public ICollection<DispatchWorkOrder>? DispatchWorkOrders { get; set; }
public ICollection<DispatchChecklistItem>? ChecklistItems { get; set; }
public ICollection<DispatchSignoff>? Signoffs { get; set; }
public ICollection<VendorCompletionDocument>? CompletionDocuments { get; set; }
}
}

View file

@ -0,0 +1,15 @@
using Data.SeaHavenIndustries.Enums;
namespace Data.SeaHavenIndustries
{
public class LinkedWorkOrderInfo
{
public int WorkOrderId { get; set; }
public string? WorkOrderNumber { get; set; }
public string? WorkOrderTitle { get; set; }
public string? Status { get; set; }
public LifecycleStatus? LifecycleStatus { get; set; }
public DateTime? ScheduledDate { get; set; }
public int? DispatchId { 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

@ -1,3 +1,5 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
public class Vendor : FullAuditEntity
@ -6,12 +8,27 @@ namespace Data.SeaHavenIndustries
public string? ContactName { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? CompanyPhone { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? TradeSpecialties { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; } = true;
public string? PreferredContact { get; set; }
public string AvailabilityStatus { get; set; } = "Unknown";
public DateTime? AvailabilityUpdatedAt { get; set; }
public int? CompanyId { get; set; }
[ForeignKey(nameof(CompanyId))]
public virtual VendorCompany? Company { get; set; }
[NotMapped]
public int TotalJobs { get; set; }
public ICollection<Dispatch>? Dispatches { get; set; }
public ICollection<VendorAuditLog>? AuditLogs { get; set; }
public ICollection<SitePreferredVendor>? SitePreferences { get; set; }
}
}

View file

@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
public class VendorAuditLog
{
public int Id { get; set; }
public int VendorId { get; set; }
[ForeignKey(nameof(VendorId))]
public virtual Vendor? Vendor { get; set; }
public int? WorkOrderId { get; set; }
public string? FieldName { get; set; }
public string? OldValue { get; set; }
public string? NewValue { get; set; }
public string? Actor { get; set; }
public DateTime CreatedAt { get; set; }
}
}

View file

@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
public class VendorCompany : FullAuditEntity
{
public string Name { get; set; } = string.Empty;
public string NormalizedName { get; set; } = string.Empty;
public string? CompanyPhone { get; set; }
public string? Email { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public ICollection<Vendor>? Vendors { 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

@ -27,6 +27,7 @@ namespace Data.SeaHavenIndustries
public string? LegacyStatus { get; set; }
public WorkOrderType? WorkOrderType { get; set; }
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
[MaxLength(128)]
public string? Service { get; set; }
public int? PrimaryDispatchId { get; set; }
[ForeignKey(nameof(PrimaryDispatchId))]

View file

@ -8,6 +8,11 @@ namespace SeaHaven.DataServices.Helpers
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,

View file

@ -1,5 +1,7 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using System.Linq.Expressions;
using System.Reflection;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
@ -15,13 +17,16 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Vendor?> GetByIdAsync(int id)
{
return await _context.Vendors.FindAsync(id);
return await _context.Vendors.FirstOrDefaultAsync(v => v.Id == id);
}
public async Task<Vendor?> GetByIdWithDetailsAsync(int id)
{
return await _context.Vendors
.FirstOrDefaultAsync(v => v.Id == id);
return await ProjectWithTotalJobs(
_context.Vendors
.AsNoTracking()
.Where(v => v.Id == id))
.FirstOrDefaultAsync();
}
public async Task<IEnumerable<Vendor>> GetAllAsync()
@ -32,24 +37,23 @@ namespace SeaHaven.DataServices.Implementation
public async Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
int page,
int pageSize,
string? search = null)
string? search = null,
bool? isActive = true,
IReadOnlyCollection<string>? companies = null,
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null)
{
search ??= "";
var projectedQuery = BuildDirectoryQuery(
search,
isActive,
companies,
trades,
locations,
jobBuckets);
var query = _context.Vendors.Where(v => v.IsActive);
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.ToLower();
query = query.Where(v =>
(v.CompanyName ?? "").ToLower().Contains(s) ||
(v.ContactName ?? "").ToLower().Contains(s) ||
(v.Email ?? "").ToLower().Contains(s) ||
(v.TradeSpecialties ?? "").ToLower().Contains(s));
}
var totalCount = await query.CountAsync();
var items = await query
var totalCount = await projectedQuery.CountAsync();
var items = await projectedQuery
.OrderByDescending(v => v.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
@ -58,6 +62,200 @@ namespace SeaHaven.DataServices.Implementation
return (items, totalCount);
}
internal IQueryable<Vendor> BuildDirectoryQuery(
string? search = null,
bool? isActive = true,
IReadOnlyCollection<string>? companies = null,
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null)
{
var query = _context.Vendors
.AsNoTracking()
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
if (isActive.HasValue)
query = query.Where(v => v.IsActive == isActive.Value);
if (!string.IsNullOrWhiteSpace(search))
{
var normalizedSearch = search.ToLower();
query = query.Where(v =>
(v.CompanyName ?? "").ToLower().Contains(normalizedSearch) ||
(v.ContactName ?? "").ToLower().Contains(normalizedSearch) ||
(v.Email ?? "").ToLower().Contains(normalizedSearch) ||
(v.TradeSpecialties ?? "").ToLower().Contains(normalizedSearch) ||
(v.City ?? "").ToLower().Contains(normalizedSearch) ||
(v.State ?? "").ToLower().Contains(normalizedSearch) ||
(v.Zip ?? "").ToLower().Contains(normalizedSearch));
}
var selectedCompanies = companies?
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim().ToLower())
.Distinct()
.ToArray() ?? Array.Empty<string>();
if (selectedCompanies.Length > 0)
query = query.Where(v =>
selectedCompanies.Contains((v.CompanyName ?? "").ToLower()));
var selectedTrades = trades?
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim().ToLower())
.Distinct()
.ToArray() ?? Array.Empty<string>();
if (selectedTrades.Length > 0)
query = query.Where(BuildTradePredicate(selectedTrades));
var selectedLocations = locations?
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim().ToLower())
.Distinct()
.ToArray() ?? Array.Empty<string>();
if (selectedLocations.Length > 0)
query = query.Where(BuildLocationPredicate(selectedLocations));
var result = ProjectWithTotalJobs(query);
var selectedJobBuckets = jobBuckets?
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim().ToLower())
.Distinct()
.ToArray() ?? Array.Empty<string>();
if (selectedJobBuckets.Length == 0)
return result;
var under50 = selectedJobBuckets.Contains("under-50");
var from50To99 = selectedJobBuckets.Contains("50-99");
var from100To149 = selectedJobBuckets.Contains("100-149");
var from150 = selectedJobBuckets.Contains("150-plus");
return result.Where(v =>
(under50 && v.TotalJobs < 50) ||
(from50To99 && v.TotalJobs >= 50 && v.TotalJobs <= 99) ||
(from100To149 && v.TotalJobs >= 100 && v.TotalJobs <= 149) ||
(from150 && v.TotalJobs >= 150));
}
private IQueryable<Vendor> ProjectWithTotalJobs(IQueryable<Vendor> query) =>
query.Select(v => new Vendor
{
Id = v.Id,
CompanyName = v.CompanyName,
CompanyId = v.CompanyId,
ContactName = v.ContactName,
Email = v.Email,
Phone = v.Phone,
CompanyPhone = v.CompanyPhone,
PreferredContact = v.PreferredContact,
Address = v.Address,
City = v.City,
State = v.State,
Zip = v.Zip,
TradeSpecialties = v.TradeSpecialties,
GoogleMapsUrl = v.GoogleMapsUrl,
Notes = v.Notes,
IsActive = v.IsActive,
IsDeleted = v.IsDeleted,
TotalJobs = _context.Dispatches
.Where(dispatch =>
dispatch.VendorId == v.Id &&
dispatch.WorkOrderId.HasValue)
.Select(dispatch => dispatch.WorkOrderId!.Value)
.Union(
_context.DispatchWorkOrders
.Where(link => link.Dispatch != null && link.Dispatch.VendorId == v.Id)
.Select(link => link.WorkOrderId))
.Count()
});
private static readonly MethodInfo StringConcatMethod =
typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!;
private static Expression<Func<Vendor, bool>> BuildTradePredicate(
IReadOnlyCollection<string> selectedTrades)
{
var vendor = Expression.Parameter(typeof(Vendor), "vendor");
var specialties = Expression.Coalesce(
Expression.Property(vendor, nameof(Vendor.TradeSpecialties)),
Expression.Constant(string.Empty));
var lowered = Expression.Call(specialties, nameof(string.ToLower), Type.EmptyTypes);
var delimiters = new[] { ", ", "," };
Expression? body = null;
foreach (var trade in selectedTrades)
{
var token = Expression.Constant(trade);
body = OrElse(body, Expression.Equal(lowered, token));
foreach (var delimiter in delimiters)
{
var delim = Expression.Constant(delimiter);
var tokenWithTrailing = Concat(token, delim);
var leadingToken = Concat(delim, token);
var wrappedToken = Concat(leadingToken, delim);
body = OrElse(body, StartsWith(lowered, tokenWithTrailing));
body = OrElse(body, EndsWith(lowered, leadingToken));
body = OrElse(body, Contains(lowered, wrappedToken));
}
}
return Expression.Lambda<Func<Vendor, bool>>(body ?? Expression.Constant(false), vendor);
}
private static Expression<Func<Vendor, bool>> BuildLocationPredicate(
IReadOnlyCollection<string> selectedLocations)
{
var vendor = Expression.Parameter(typeof(Vendor), "vendor");
var label = BuildLocationLabelBody(vendor);
var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes);
Expression? body = null;
foreach (var location in selectedLocations)
body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location)));
return Expression.Lambda<Func<Vendor, bool>>(body ?? Expression.Constant(false), vendor);
}
private static Expression BuildLocationLabelBody(ParameterExpression vendor)
{
var city = Expression.Property(vendor, nameof(Vendor.City));
var state = Expression.Property(vendor, nameof(Vendor.State));
var cityBlank = IsBlank(city);
var stateBlank = IsBlank(state);
var cityCommaState = Concat(Concat(city, Expression.Constant(", ")), state);
var onlyState = Expression.Condition(stateBlank, Expression.Constant(string.Empty), state);
var cityOrBoth = Expression.Condition(stateBlank, city, cityCommaState);
return Expression.Condition(cityBlank, onlyState, cityOrBoth);
}
private static Expression IsBlank(Expression value)
{
var isNull = Expression.Equal(value, Expression.Constant(null, value.Type));
var trimmed = Expression.Call(value, nameof(string.Trim), Type.EmptyTypes);
var isEmpty = Expression.Equal(trimmed, Expression.Constant(string.Empty));
return Expression.Condition(isNull, Expression.Constant(true), isEmpty);
}
private static Expression Concat(Expression left, Expression right) =>
Expression.Call(StringConcatMethod, left, right);
private static Expression StartsWith(Expression instance, Expression value) =>
Expression.Call(instance, nameof(string.StartsWith), Type.EmptyTypes, value);
private static Expression EndsWith(Expression instance, Expression value) =>
Expression.Call(instance, nameof(string.EndsWith), Type.EmptyTypes, value);
private static Expression Contains(Expression instance, Expression value) =>
Expression.Call(instance, nameof(string.Contains), Type.EmptyTypes, value);
private static Expression OrElse(Expression? accumulated, Expression next) =>
accumulated is null ? next : Expression.OrElse(accumulated, next);
public async Task<Vendor> AddAsync(Vendor vendor)
{
await _context.Vendors.AddAsync(vendor);
@ -102,5 +300,102 @@ namespace SeaHaven.DataServices.Implementation
{
return await _context.Vendors.CountAsync();
}
public async Task<VendorCompany?> GetCompanyByIdAsync(int id)
{
return await _context.VendorCompanies
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == id);
}
public async Task<VendorCompany?> GetCompanyByNormalizedNameAsync(string normalizedName)
{
return await _context.VendorCompanies
.AsNoTracking()
.FirstOrDefaultAsync(c => c.NormalizedName == normalizedName);
}
public async Task<VendorCompany> AddCompanyAsync(VendorCompany company)
{
await _context.VendorCompanies.AddAsync(company);
try
{
await _context.SaveChangesAsync();
return company;
}
catch (DbUpdateException)
{
_context.Entry(company).State = EntityState.Detached;
var existing = await _context.VendorCompanies
.AsNoTracking()
.SingleOrDefaultAsync(c => c.NormalizedName == company.NormalizedName);
if (existing != null)
return existing;
throw;
}
}
public async Task UpdateCompanyAsync(VendorCompany company)
{
_context.VendorCompanies.Update(company);
await _context.SaveChangesAsync();
}
internal IQueryable<LinkedWorkOrderInfo> BuildLinkedWorkOrdersQuery(int vendorId)
{
var directLinks = _context.Dispatches
.Where(d => d.VendorId == vendorId && d.WorkOrderId.HasValue)
.Select(d => new { DispatchId = d.Id, WorkOrderId = d.WorkOrderId!.Value });
var multiLinks = _context.DispatchWorkOrders
.Where(link => link.Dispatch != null && link.Dispatch.VendorId == vendorId)
.Select(link => new { DispatchId = link.DispatchId, WorkOrderId = link.WorkOrderId });
var linkedIds = directLinks.Concat(multiLinks);
return from link in linkedIds
join wo in _context.workOrders on link.WorkOrderId equals wo.Id
orderby wo.Id, link.DispatchId
select new LinkedWorkOrderInfo
{
WorkOrderId = wo.Id,
WorkOrderNumber = wo.WorkerOrderNumber,
WorkOrderTitle = wo.WorkerOrderTitle,
Status = wo.Status,
LifecycleStatus = wo.LifecycleStatus,
ScheduledDate = wo.ScheduledDate,
DispatchId = link.DispatchId
};
}
public async Task<List<LinkedWorkOrderInfo>> GetLinkedWorkOrdersAsync(int vendorId)
{
var raw = await BuildLinkedWorkOrdersQuery(vendorId).ToListAsync();
return raw.DistinctBy(x => x.WorkOrderId).ToList();
}
public async Task<bool> IsVendorAssignedToWorkOrderAsync(int vendorId, int workOrderId)
{
var hasDirect = await _context.Dispatches
.AnyAsync(d => d.VendorId == vendorId && d.WorkOrderId == workOrderId);
if (hasDirect)
return true;
return await _context.DispatchWorkOrders
.AnyAsync(link => link.WorkOrderId == workOrderId
&& link.Dispatch != null
&& link.Dispatch.VendorId == vendorId);
}
public async Task UpdateWithAuditLogsAsync(
Vendor vendor,
IReadOnlyCollection<VendorAuditLog> auditLogs)
{
_context.Vendors.Update(vendor);
if (auditLogs.Count > 0)
await _context.VendorAuditLogs.AddRangeAsync(auditLogs);
await _context.SaveChangesAsync();
}
}
}

View file

@ -7,7 +7,15 @@ namespace SeaHaven.DataServices.Interfaces
Task<Vendor?> GetByIdAsync(int id);
Task<Vendor?> GetByIdWithDetailsAsync(int id);
Task<IEnumerable<Vendor>> GetAllAsync();
Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(int page, int pageSize, string? search = null);
Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
int page,
int pageSize,
string? search = null,
bool? isActive = true,
IReadOnlyCollection<string>? companies = null,
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null);
Task<Vendor> AddAsync(Vendor vendor);
Task UpdateAsync(Vendor vendor);
Task DeleteAsync(int id);
@ -15,5 +23,13 @@ namespace SeaHaven.DataServices.Interfaces
Task<VendorAccessToken?> GetActiveTokenAsync(int vendorId);
Task<VendorAccessToken?> GetTokenByStringAsync(string token);
Task<int> CountAsync();
Task<VendorCompany?> GetCompanyByIdAsync(int id);
Task<VendorCompany?> GetCompanyByNormalizedNameAsync(string normalizedName);
Task<VendorCompany> AddCompanyAsync(VendorCompany company);
Task UpdateCompanyAsync(VendorCompany company);
Task<List<LinkedWorkOrderInfo>> GetLinkedWorkOrdersAsync(int vendorId);
Task<bool> IsVendorAssignedToWorkOrderAsync(int vendorId, int workOrderId);
Task UpdateWithAuditLogsAsync(Vendor vendor, IReadOnlyCollection<VendorAuditLog> auditLogs);
}
}

View file

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Api.SeaHavenIndustries.Tests")]

View file

@ -4,33 +4,96 @@ namespace SeaHaven.Services.DTOs
{
public int Id { get; set; }
public string? Name { get; set; }
public int? CompanyId { get; set; }
public string? ContactName { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? CompanyPhone { get; set; }
public string? PreferredContact { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zipcode { get; set; }
public string? TradeSpecialties { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public int TotalJobs { get; set; }
public bool IsActive { get; set; }
}
public class CreateVendorDTO
{
public required string Name { get; set; }
public int? CompanyId { get; set; }
public string? ContactName { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? CompanyPhone { get; set; }
public string? PreferredContact { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zipcode { get; set; }
public string? TradeSpecialties { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; } = true;
}
public class UpdateVendorDTO
{
public string? Name { get; set; }
public int? CompanyId { get; set; }
public string? ContactName { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? CompanyPhone { get; set; }
public string? PreferredContact { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zipcode { get; set; }
public string? TradeSpecialties { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public bool? IsActive { get; set; }
}
public class VendorDeactivationImpactDTO
{
public int VendorId { get; set; }
public bool CanDeactivate { get; set; }
public List<LinkedWorkOrderDTO> OpenWorkOrders { get; set; } = new();
}
public class LinkedWorkOrderDTO
{
public int WorkOrderId { get; set; }
public string? WorkOrderNumber { get; set; }
public string? WorkOrderTitle { get; set; }
public string? Status { get; set; }
public DateTime? ScheduledDate { get; set; }
public int? DispatchId { get; set; }
}
public class WorkOrderVendorUpdateDTO
{
public int WorkOrderId { get; set; }
public string? ContactName { get; set; }
public string? PreferredContact { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? Notes { get; set; }
}
public class VendorDeactivationBlockedException : InvalidOperationException
{
public List<LinkedWorkOrderDTO> OpenWorkOrders { get; }
public VendorDeactivationBlockedException(string message, List<LinkedWorkOrderDTO> openWorkOrders)
: base(message)
{
OpenWorkOrders = openWorkOrders;
}
}
}

View file

@ -0,0 +1,25 @@
using System.Text.RegularExpressions;
namespace SeaHaven.Services.Helpers
{
public static class VendorPhoneNormalizer
{
public static readonly Regex NorthAmericanPhoneRegex = new(
@"^\(\d{3}\) \d{3}-\d{4}$",
RegexOptions.Compiled);
public static string? NormalizeToCanonical(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
var digits = new string(value.Where(char.IsDigit).ToArray());
if (digits.Length == 11 && digits.StartsWith('1'))
digits = digits[1..];
return digits.Length == 10
? $"({digits[..3]}) {digits[3..6]}-{digits[6..]}"
: value;
}
}
}

View file

@ -1,35 +1,40 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentValidation;
using FluentValidation.Results;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
namespace SeaHaven.Services.Implementation
{
/// <summary>
/// Vendor service implementation
/// Contains business logic, validation, and orchestration for Vendor operations
/// </summary>
public class VendorService : IVendorService
{
private static readonly string[] TerminalLegacyStatuses =
{ "completed", "cancelled", "canceled" };
private readonly IVendorDataService _vendorDataService;
private readonly ICreateVendorValidation _createValidator;
private readonly IUpdateVendorValidation _updateValidator;
private readonly IWorkOrderVendorUpdateValidation _workOrderUpdateValidator;
public VendorService(
IVendorDataService vendorDataService,
ICreateVendorValidation createValidator,
IUpdateVendorValidation updateValidator)
IUpdateVendorValidation updateValidator,
IWorkOrderVendorUpdateValidation workOrderUpdateValidator)
{
_vendorDataService = vendorDataService;
_createValidator = createValidator;
_updateValidator = updateValidator;
_workOrderUpdateValidator = workOrderUpdateValidator;
}
public async Task<VendorDTO?> GetVendorByIdAsync(int id)
{
var vendor = await _vendorDataService.GetByIdAsync(id);
var vendor = await _vendorDataService.GetByIdWithDetailsAsync(id);
return vendor == null ? null : MapToDTO(vendor);
}
@ -45,9 +50,25 @@ namespace SeaHaven.Services.Implementation
return vendors.Select(MapToDTO);
}
public async Task<PagedResult<VendorDTO>> GetVendorsPagedAsync(int page, int pageSize, string? search = null)
public async Task<PagedResult<VendorDTO>> GetVendorsPagedAsync(
int page,
int pageSize,
string? search = null,
bool? isActive = true,
IReadOnlyCollection<string>? companies = null,
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null)
{
var (items, totalCount) = await _vendorDataService.GetPagedAsync(page, pageSize, search);
var (items, totalCount) = await _vendorDataService.GetPagedAsync(
page,
pageSize,
search,
isActive,
companies,
trades,
locations,
jobBuckets);
return new PagedResult<VendorDTO>
{
Items = items.Select(MapToDTO).ToList(),
@ -59,31 +80,45 @@ namespace SeaHaven.Services.Implementation
public async Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId)
{
NormalizePhoneFields(dto);
var validationResult = await _createValidator.ValidateAsync(dto);
if (!validationResult.IsValid)
{
throw new ValidationException(validationResult.Errors);
}
var company = await ResolveCompanyAsync(dto.CompanyId, dto.Name, dto, userId);
var vendor = new Vendor
{
CompanyName = dto.Name,
ContactName = dto.ContactName,
Email = dto.Email,
Phone = dto.Phone,
Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone),
CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone),
PreferredContact = dto.PreferredContact,
Address = dto.Address,
City = dto.City,
State = dto.State,
Zip = dto.Zipcode,
TradeSpecialties = dto.TradeSpecialties,
GoogleMapsUrl = dto.GoogleMapsUrl,
Notes = dto.Notes,
IsActive = dto.IsActive,
CompanyId = company?.Id,
CreatedDate = DateTime.UtcNow,
createdby = userId
};
ApplyCompanyFields(vendor, company);
var created = await _vendorDataService.AddAsync(vendor);
return MapToDTO(created);
}
public async Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId)
{
NormalizePhoneFields(dto);
var validationResult = await _updateValidator.ValidateAsync(dto);
if (!validationResult.IsValid)
{
@ -94,13 +129,28 @@ namespace SeaHaven.Services.Implementation
if (vendor == null)
throw new InvalidOperationException($"Vendor with ID {id} not found");
if (dto.IsActive.HasValue && !dto.IsActive.Value)
await AssertNoOpenLinkedWorkOrdersAsync(id);
if (dto.Name != null) vendor.CompanyName = dto.Name;
if (dto.ContactName != null) vendor.ContactName = dto.ContactName;
if (dto.Email != null) vendor.Email = dto.Email;
if (dto.Phone != null) vendor.Phone = dto.Phone;
if (dto.Address != null) vendor.Address = dto.Address;
if (dto.City != null) vendor.City = dto.City;
if (dto.State != null) vendor.State = dto.State;
if (dto.Zipcode != null) vendor.Zip = dto.Zipcode;
if (dto.Phone != null) vendor.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
if (dto.PreferredContact != null) vendor.PreferredContact = dto.PreferredContact;
if (dto.TradeSpecialties != null) vendor.TradeSpecialties = dto.TradeSpecialties;
if (dto.Notes != null) vendor.Notes = dto.Notes;
if (dto.IsActive.HasValue) vendor.IsActive = dto.IsActive.Value;
if (dto.CompanyId.HasValue || dto.Name != null || HasCompanyFieldUpdate(dto))
{
var company = await ResolveCompanyAsync(
dto.CompanyId ?? vendor.CompanyId,
dto.Name ?? vendor.CompanyName,
dto,
userId);
vendor.CompanyId = company?.Id;
ApplyCompanyFields(vendor, company);
}
vendor.LastModificationTime = DateTime.UtcNow;
if (int.TryParse(userId, out int userIdInt))
@ -116,9 +166,12 @@ namespace SeaHaven.Services.Implementation
if (vendor == null)
throw new InvalidOperationException($"Vendor with ID {id} not found");
vendor.IsDeleted = true;
vendor.DeleterUserId = userId;
vendor.DeletionTime = DateTime.UtcNow;
await AssertNoOpenLinkedWorkOrdersAsync(id);
vendor.IsActive = false;
vendor.LastModificationTime = DateTime.UtcNow;
if (int.TryParse(userId, out int userIdInt))
vendor.LastModifierUserId = userIdInt;
await _vendorDataService.UpdateAsync(vendor);
}
@ -133,19 +186,338 @@ namespace SeaHaven.Services.Implementation
return await _vendorDataService.CountAsync();
}
public async Task<VendorDeactivationImpactDTO> GetDeactivationImpactAsync(int vendorId)
{
if (!await _vendorDataService.ExistsAsync(vendorId))
throw new InvalidOperationException($"Vendor with ID {vendorId} not found");
var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId);
var openWorkOrders = linked
.Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status))
.Select(MapToLinkedWorkOrderDTO)
.ToList();
return new VendorDeactivationImpactDTO
{
VendorId = vendorId,
CanDeactivate = openWorkOrders.Count == 0,
OpenWorkOrders = openWorkOrders
};
}
public async Task<VendorDTO> UpdateVendorFromWorkOrderAsync(
int vendorId,
WorkOrderVendorUpdateDTO dto,
string userId)
{
dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
var validationResult = await _workOrderUpdateValidator.ValidateAsync(dto);
if (!validationResult.IsValid)
{
throw new ValidationException(validationResult.Errors);
}
var vendor = await _vendorDataService.GetByIdAsync(vendorId);
if (vendor == null)
throw new InvalidOperationException($"Vendor with ID {vendorId} not found");
var isAssigned = await _vendorDataService.IsVendorAssignedToWorkOrderAsync(vendorId, dto.WorkOrderId);
if (!isAssigned)
throw new InvalidOperationException(
$"Vendor {vendorId} is not actively linked to work order {dto.WorkOrderId}.");
var changes = new List<VendorAuditLog>();
var now = DateTime.UtcNow;
void RecordChange(string fieldName, string? oldValue, string? newValue)
{
if (!string.Equals(oldValue, newValue, StringComparison.Ordinal))
{
changes.Add(new VendorAuditLog
{
VendorId = vendorId,
WorkOrderId = dto.WorkOrderId,
FieldName = fieldName,
OldValue = oldValue,
NewValue = newValue,
Actor = userId,
CreatedAt = now
});
}
}
if (dto.ContactName != null)
{
RecordChange(nameof(Vendor.ContactName), vendor.ContactName, dto.ContactName);
vendor.ContactName = dto.ContactName;
}
if (dto.PreferredContact != null)
{
RecordChange(nameof(Vendor.PreferredContact), vendor.PreferredContact, dto.PreferredContact);
vendor.PreferredContact = dto.PreferredContact;
}
if (dto.Phone != null)
{
var normalized = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
RecordChange(nameof(Vendor.Phone), vendor.Phone, normalized);
vendor.Phone = normalized;
}
if (dto.Email != null)
{
RecordChange(nameof(Vendor.Email), vendor.Email, dto.Email);
vendor.Email = dto.Email;
}
if (dto.Notes != null)
{
RecordChange(nameof(Vendor.Notes), vendor.Notes, dto.Notes);
vendor.Notes = dto.Notes;
}
vendor.LastModificationTime = now;
if (int.TryParse(userId, out int userIdInt))
vendor.LastModifierUserId = userIdInt;
await _vendorDataService.UpdateWithAuditLogsAsync(vendor, changes);
return MapToDTO(vendor);
}
private async Task<VendorCompany?> ResolveCompanyAsync(
int? requestedCompanyId,
string? companyName,
object dto,
string userId)
{
if (requestedCompanyId.HasValue)
{
var byId = await _vendorDataService.GetCompanyByIdAsync(requestedCompanyId.Value);
if (byId != null)
{
ApplyCompanyUpdates(byId, companyName, dto, userId);
await _vendorDataService.UpdateCompanyAsync(byId);
return byId;
}
throw new ValidationException(new[]
{
new ValidationFailure(
nameof(CreateVendorDTO.CompanyId),
$"No vendor company exists with ID {requestedCompanyId.Value}.")
});
}
if (string.IsNullOrWhiteSpace(companyName))
return null;
var normalizedName = companyName.Trim().ToLowerInvariant();
var existing = await _vendorDataService.GetCompanyByNormalizedNameAsync(normalizedName);
if (existing != null)
{
ApplyCompanyUpdates(existing, companyName, dto, userId);
await _vendorDataService.UpdateCompanyAsync(existing);
return existing;
}
string? companyPhone = null;
string? email = null;
string? address = null;
string? city = null;
string? state = null;
string? zip = null;
string? mapsUrl = null;
if (dto is CreateVendorDTO create)
{
companyPhone = VendorPhoneNormalizer.NormalizeToCanonical(create.CompanyPhone);
email = create.Email;
address = create.Address;
city = create.City;
state = create.State;
zip = create.Zipcode;
mapsUrl = create.GoogleMapsUrl;
}
else if (dto is UpdateVendorDTO update)
{
companyPhone = VendorPhoneNormalizer.NormalizeToCanonical(update.CompanyPhone);
email = update.Email;
address = update.Address;
city = update.City;
state = update.State;
zip = update.Zipcode;
mapsUrl = update.GoogleMapsUrl;
}
var company = new VendorCompany
{
Name = companyName.Trim(),
NormalizedName = normalizedName,
CompanyPhone = companyPhone,
Email = email,
Address = address,
City = city,
State = state,
Zip = zip,
GoogleMapsUrl = mapsUrl,
CreatedDate = DateTime.UtcNow,
createdby = userId
};
return await _vendorDataService.AddCompanyAsync(company);
}
private static void ApplyCompanyFields(Vendor vendor, VendorCompany? company)
{
if (company == null)
return;
if (company.CompanyPhone != null) vendor.CompanyPhone = company.CompanyPhone;
if (company.Address != null) vendor.Address = company.Address;
if (company.City != null) vendor.City = company.City;
if (company.State != null) vendor.State = company.State;
if (company.Zip != null) vendor.Zip = company.Zip;
if (company.GoogleMapsUrl != null) vendor.GoogleMapsUrl = company.GoogleMapsUrl;
}
private static bool HasCompanyFieldUpdate(UpdateVendorDTO dto) =>
dto.CompanyPhone != null ||
dto.Address != null ||
dto.City != null ||
dto.State != null ||
dto.Zipcode != null ||
dto.GoogleMapsUrl != null;
private static void ApplyCompanyUpdates(
VendorCompany company,
string? companyName,
object dto,
string userId)
{
if (!string.IsNullOrWhiteSpace(companyName))
{
company.Name = companyName.Trim();
company.NormalizedName = company.Name.ToLowerInvariant();
}
if (dto is CreateVendorDTO create)
{
if (create.CompanyPhone != null) company.CompanyPhone = create.CompanyPhone;
if (create.Address != null) company.Address = create.Address;
if (create.City != null) company.City = create.City;
if (create.State != null) company.State = create.State;
if (create.Zipcode != null) company.Zip = create.Zipcode;
if (create.GoogleMapsUrl != null) company.GoogleMapsUrl = create.GoogleMapsUrl;
}
else if (dto is UpdateVendorDTO update)
{
if (update.CompanyPhone != null) company.CompanyPhone = update.CompanyPhone;
if (update.Address != null) company.Address = update.Address;
if (update.City != null) company.City = update.City;
if (update.State != null) company.State = update.State;
if (update.Zipcode != null) company.Zip = update.Zipcode;
if (update.GoogleMapsUrl != null) company.GoogleMapsUrl = update.GoogleMapsUrl;
}
company.LastModificationTime = DateTime.UtcNow;
if (int.TryParse(userId, out int userIdInt))
company.LastModifierUserId = userIdInt;
}
private static void NormalizePhoneFields(CreateVendorDTO dto)
{
dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
dto.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone);
}
private static void NormalizePhoneFields(UpdateVendorDTO dto)
{
dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
dto.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone);
}
private async Task AssertNoOpenLinkedWorkOrdersAsync(int vendorId)
{
var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId);
var openWorkOrders = linked
.Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status))
.Select(MapToLinkedWorkOrderDTO)
.ToList();
if (openWorkOrders.Count > 0)
{
throw new VendorDeactivationBlockedException(
"Vendor cannot be deactivated while linked work orders are open or scheduled.",
openWorkOrders);
}
}
private static bool IsTerminalWorkOrderStatus(LifecycleStatus? lifecycleStatus, string? legacyStatus)
{
if (lifecycleStatus == LifecycleStatus.Completed || lifecycleStatus == LifecycleStatus.Canceled)
return true;
if (!string.IsNullOrWhiteSpace(legacyStatus) &&
TerminalLegacyStatuses.Contains(legacyStatus.Trim().ToLowerInvariant()))
return true;
return false;
}
private static LinkedWorkOrderDTO MapToLinkedWorkOrderDTO(LinkedWorkOrderInfo info) => new()
{
WorkOrderId = info.WorkOrderId,
WorkOrderNumber = info.WorkOrderNumber,
WorkOrderTitle = info.WorkOrderTitle,
Status = info.Status,
ScheduledDate = info.ScheduledDate,
DispatchId = info.DispatchId
};
private VendorDTO MapToDTO(Vendor vendor)
{
return new VendorDTO
{
Id = vendor.Id,
Name = vendor.CompanyName,
CompanyId = vendor.CompanyId,
ContactName = vendor.ContactName,
Email = vendor.Email,
Phone = vendor.Phone,
CompanyPhone = vendor.CompanyPhone,
PreferredContact = vendor.PreferredContact,
Address = vendor.Address,
City = vendor.City,
State = vendor.State,
Zipcode = vendor.Zip
Zipcode = vendor.Zip,
TradeSpecialties = vendor.TradeSpecialties,
GoogleMapsUrl = vendor.GoogleMapsUrl,
Notes = vendor.Notes,
IsActive = vendor.IsActive,
TotalJobs = vendor.Dispatches == null
? vendor.TotalJobs
: CountDistinctWorkOrders(vendor)
};
}
private static int CountDistinctWorkOrders(Vendor vendor)
{
if (vendor.Dispatches == null)
return 0;
return vendor.Dispatches
.SelectMany(dispatch =>
{
var linked = dispatch.DispatchWorkOrders?.Select(item => item.WorkOrderId)
?? Enumerable.Empty<int>();
return dispatch.WorkOrderId.HasValue
? linked.Append(dispatch.WorkOrderId.Value)
: linked;
})
.Distinct()
.Count();
}
}
}

View file

@ -7,11 +7,21 @@ namespace SeaHaven.Services.Interfaces
Task<VendorDTO?> GetVendorByIdAsync(int id);
Task<VendorDTO?> GetVendorByIdWithDetailsAsync(int id);
Task<IEnumerable<VendorDTO>> GetAllVendorsAsync();
Task<PagedResult<VendorDTO>> GetVendorsPagedAsync(int page, int pageSize, string? search = null);
Task<PagedResult<VendorDTO>> GetVendorsPagedAsync(
int page,
int pageSize,
string? search = null,
bool? isActive = true,
IReadOnlyCollection<string>? companies = null,
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null);
Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId);
Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);
Task DeleteVendorAsync(int id, string userId);
Task<bool> VendorExistsAsync(int id);
Task<int> GetTotalVendorCountAsync();
Task<VendorDeactivationImpactDTO> GetDeactivationImpactAsync(int vendorId);
Task<VendorDTO> UpdateVendorFromWorkOrderAsync(int vendorId, WorkOrderVendorUpdateDTO dto, string userId);
}
}

View file

@ -1,5 +1,6 @@
using FluentValidation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
namespace SeaHaven.Services.Validation
{
@ -19,10 +20,29 @@ namespace SeaHaven.Services.Validation
.EmailAddress().WithMessage("Invalid email address")
.When(x => !string.IsNullOrEmpty(x.Email));
RuleFor(x => x.ContactName)
.NotEmpty().WithMessage("Technician name is required")
.MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters");
RuleFor(x => x.PreferredContact)
.Must(VendorValidationRules.BeValidPreferredContact)
.WithMessage("PreferredContact must be one of: Phone, Email, Text")
.When(x => !string.IsNullOrEmpty(x.PreferredContact));
RuleFor(x => x.TradeSpecialties)
.MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters")
.When(x => !string.IsNullOrEmpty(x.TradeSpecialties));
RuleFor(x => x.Phone)
.MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters")
.Must(VendorValidationRules.BeValidNorthAmericanPhone)
.WithMessage("Phone must be in North American format: (XXX) XXX-XXXX")
.When(x => !string.IsNullOrEmpty(x.Phone));
RuleFor(x => x.CompanyPhone)
.Must(VendorValidationRules.BeValidNorthAmericanPhone)
.WithMessage("Company phone must be in North American format: (XXX) XXX-XXXX")
.When(x => !string.IsNullOrEmpty(x.CompanyPhone));
RuleFor(x => x.Address)
.MaximumLength(500).WithMessage("Address cannot exceed 500 characters")
.When(x => !string.IsNullOrEmpty(x.Address));
@ -38,6 +58,14 @@ namespace SeaHaven.Services.Validation
RuleFor(x => x.Zipcode)
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
.When(x => !string.IsNullOrEmpty(x.Zipcode));
RuleFor(x => x.GoogleMapsUrl)
.Must(VendorValidationRules.BeValidHttpsUrl).WithMessage("Google Maps URL must be a valid HTTPS URL")
.When(x => !string.IsNullOrEmpty(x.GoogleMapsUrl));
RuleFor(x => x.Notes)
.MaximumLength(2000).WithMessage("Notes cannot exceed 2000 characters")
.When(x => !string.IsNullOrEmpty(x.Notes));
}
}
@ -57,10 +85,30 @@ namespace SeaHaven.Services.Validation
.EmailAddress().WithMessage("Invalid email address")
.When(x => !string.IsNullOrEmpty(x.Email));
RuleFor(x => x.ContactName)
.NotEmpty().WithMessage("Technician name is required")
.MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters")
.When(x => x.ContactName != null);
RuleFor(x => x.PreferredContact)
.Must(VendorValidationRules.BeValidPreferredContact)
.WithMessage("PreferredContact must be one of: Phone, Email, Text")
.When(x => !string.IsNullOrEmpty(x.PreferredContact));
RuleFor(x => x.TradeSpecialties)
.MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters")
.When(x => !string.IsNullOrEmpty(x.TradeSpecialties));
RuleFor(x => x.Phone)
.MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters")
.Must(VendorValidationRules.BeValidNorthAmericanPhone)
.WithMessage("Phone must be in North American format: (XXX) XXX-XXXX")
.When(x => !string.IsNullOrEmpty(x.Phone));
RuleFor(x => x.CompanyPhone)
.Must(VendorValidationRules.BeValidNorthAmericanPhone)
.WithMessage("Company phone must be in North American format: (XXX) XXX-XXXX")
.When(x => !string.IsNullOrEmpty(x.CompanyPhone));
RuleFor(x => x.Address)
.MaximumLength(500).WithMessage("Address cannot exceed 500 characters")
.When(x => !string.IsNullOrEmpty(x.Address));
@ -76,6 +124,64 @@ namespace SeaHaven.Services.Validation
RuleFor(x => x.Zipcode)
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
.When(x => !string.IsNullOrEmpty(x.Zipcode));
RuleFor(x => x.GoogleMapsUrl)
.Must(VendorValidationRules.BeValidHttpsUrl).WithMessage("Google Maps URL must be a valid HTTPS URL")
.When(x => !string.IsNullOrEmpty(x.GoogleMapsUrl));
RuleFor(x => x.Notes)
.MaximumLength(2000).WithMessage("Notes cannot exceed 2000 characters")
.When(x => !string.IsNullOrEmpty(x.Notes));
}
}
public interface IWorkOrderVendorUpdateValidation : IValidator<WorkOrderVendorUpdateDTO>
{
}
public class WorkOrderVendorUpdateValidation : AbstractValidator<WorkOrderVendorUpdateDTO>, IWorkOrderVendorUpdateValidation
{
public WorkOrderVendorUpdateValidation()
{
RuleFor(x => x.WorkOrderId)
.GreaterThan(0).WithMessage("A valid work order id is required");
RuleFor(x => x.PreferredContact)
.Must(VendorValidationRules.BeValidPreferredContact)
.WithMessage("PreferredContact must be one of: Phone, Email, Text")
.When(x => !string.IsNullOrEmpty(x.PreferredContact));
RuleFor(x => x.Phone)
.Must(VendorValidationRules.BeValidNorthAmericanPhone)
.WithMessage("Phone must be in North American format: (XXX) XXX-XXXX")
.When(x => !string.IsNullOrEmpty(x.Phone));
RuleFor(x => x.Email)
.EmailAddress().WithMessage("Invalid email address")
.When(x => !string.IsNullOrEmpty(x.Email));
RuleFor(x => x.Notes)
.MaximumLength(2000).WithMessage("Notes cannot exceed 2000 characters")
.When(x => !string.IsNullOrEmpty(x.Notes));
RuleFor(x => x.ContactName)
.MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters")
.When(x => !string.IsNullOrEmpty(x.ContactName));
}
}
internal static class VendorValidationRules
{
private static readonly string[] AllowedPreferredContacts = { "Phone", "Email", "Text" };
internal static bool BeValidHttpsUrl(string? value) =>
Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps;
internal static bool BeValidPreferredContact(string? value) =>
AllowedPreferredContacts.Contains(value, StringComparer.OrdinalIgnoreCase);
internal static bool BeValidNorthAmericanPhone(string? value) =>
value is not null && VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(value);
}
}

View file

@ -0,0 +1,159 @@
# Fase 1 — Weekly Board (leitura)
**Programa:** Work Orders Board
**Objetivo:** Endpoint window-based para carregar a visão semanal do SHOC (Mon–Fri + Unscheduled) com projeção das 14 colunas operacionais.
---
## Endpoints
### `GET /api/workorders/board`
Retorna WOs agendadas na semana + seção **Unscheduled** em uma única chamada.
**Autenticação:** Bearer JWT (`[Authorize]`)
**Query params:**
| Param | Obrigatório | Descrição |
|-------|-------------|-----------|
| `weekStart` | Sim (`BindRequired`) | Segunda-feira da semana (`YYYY-MM-DD`). Omitir retorna **400**. |
| `weekEnd` | Não | Default: `weekStart + 4 dias` (sexta). Janela pode ter até 7 dias. |
| `dispatchers` | Não | Lista de GUIDs; use `__unassigned__` para não atribuídos. **Ignorado** quando `myWorkOrders=true`. |
| `myWorkOrders` | Não | `true` → filtra `AssignTo == usuário logado` e tem **precedência** sobre `dispatchers` (não é AND). |
| `types` | Não | Valores enum `WorkOrderType` (`PM`, `PO`, `Emergency`, etc.) |
| `search` | Não | Busca contextual (site, WO#, location, dispatcher, trade, vendor, status) |
**Exemplo:**
```bash
curl -H "Authorization: Bearer <token>" \
"https://localhost:5001/api/workorders/board?weekStart=2026-06-22&myWorkOrders=true"
```
**Response:**
```json
{
"weekStart": "2026-06-22",
"weekEnd": "2026-06-26",
"counts": { "returned": 42, "total": 58 },
"unscheduled": [ /* WorkOrderBoardRowDto[] */ ],
"scheduled": [ /* WorkOrderBoardRowDto[] — agrupar por dayGroup no FE */ ]
}
```
- `counts.total` — WOs agendadas na semana (filtros dispatcher/tipo, **sem** search)
- `counts.returned` — WOs agendadas após aplicar search
- `dayGroup` — `monday`…`friday` para dias úteis; **`null` no sábado/domingo** (a janela pode incluir fim de semana; o FE deve tratar `dayGroup` nulo)
- Referência de dia / past-due: **UTC** nesta fase (timezone de negócio fica para fase posterior)
### `GET /api/workorders/lookups/dispatchers`
Lista opções para o filtro/dropdown de assignee do board.
**Escopo atual (placeholder):** todos os users com `IsDeleted != true` — ainda **sem** filtro por role. Quando roles de assignee estiverem definidos, este lookup será restrito.
```json
[
{ "id": "guid", "name": "Jane Doe", "initials": "JD", "color": "#4A90D9" }
]
```
---
## Matriz coluna → campo API
| Coluna board | Campos response |
|--------------|-----------------|
| WO# | `woNumber`, `rescheduleCount`, `carriedOver` |
| Type | `workOrderType`, `isPastDue` |
| Site | `siteCode`, `locationName`, `pocName`, `pocPhone`, `pocNotes` |
| Status | `lifecycleStatus`, `lifecycleStatusLabel`, `legacyStatus` |
| Assignee | `dispatcherId`, `dispatcherName`, `initials`, `color` |
| Due | `dueDate` |
| Scheduled | `scheduledDate`, `targetWeek`, `scheduleWeekOnly`, `dayGroup` |
| Vendor | `vendorId`, `vendorName`, `techName`, `techPhone` |
| Appt | `apptDate`, `apptTime` |
| Past Due | `isPastDue` (derivado on-read) |
| Carried | `carriedOver` |
| Reschedule | `rescheduleCount` |
| Doc | `docStatus` |
| Actions | FE only |
---
## Regras de derivação
### isPastDue
```
ScheduledDate < UTC hoje
AND LifecycleStatus NOT IN (Complete, Canceled, Closed)
```
Implementação: `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs`
### Vendor / Appt
Projeção read-only via `WorkOrder.PrimaryDispatchId` → `Dispatch` → `Vendor` ([ADR](../phase-0/adr-vendor-source-of-truth.md)).
### Janela semanal
WO entra em **scheduled** se:
- `ScheduledDate` entre `weekStart` e `weekEnd`, **ou**
- `ScheduleWeekOnly == true` e `TargetWeek == weekStart`
**Unscheduled:** `ScheduledDate == null` e status não terminal (`LifecycleStatusSets.Terminal`).
---
## Código entregue
| Camada | Arquivo |
|--------|---------|
| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` |
| Derived fields | `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs` |
| Data | `SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs` |
| Service | `SeaHaven.Services/Implementation/WorkOrderBoardService.cs` |
| API | `Api.SeaHavenIndustries/Controllers/WorkOrderController.cs` (`board`, `lookups/dispatchers`) |
| Migration | `Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs` |
| Testes | `SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs`, `WorkOrderBoardServiceTests.cs` |
---
## Gates de aceite
### Desenvolvimento (concluído)
- [x] Endpoint `GET board` — semana + Unscheduled
- [x] Projeção 12/14 colunas de dados
- [x] `isPastDue` derivado on-read
- [x] Vendor via dispatch primário
- [x] Filtros dispatcher / My WOs / tipo / search
- [x] Contador X of Y
- [x] Índices Tier S
- [x] 14 testes unitários board (+ 12 Fase 0 = 26 total)
### Staging / produção (pendente)
- [ ] Sign-off CTO/PO (herda gates Fase 0)
- [ ] Dry-run migration Phase0 + Phase1 em staging
- [ ] Tier volume confirmado em staging/prod
- [ ] Smoke checklist Blazor / Sync / Portal
- [ ] Gate Vendor Portal regression (endpoints dispatch inalterados)
- [ ] Latência aceitável com volume real (Tier S: &lt; 25k WOs)
- [ ] Feature flag SHOC board em staging
---
## Coexistência
Endpoints legados (`GetWorkOrderList`, `GetWorkorderById`, etc.) **não foram alterados**. O board usa contrato novo em rotas separadas.
---
## Próximo passo
**Fase 2** — Inline edit + concorrência (dual RowVersion, audit por campo, PATCH granular).