mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
feat(vendors): add directory filters and details API (#25)
* feat(vendors): add directory filters and details API * fix(vendors): preserve omitted status * fix(vendors): align facet filtering * fix(vendors): address directory review findings
This commit is contained in:
parent
32b9113057
commit
1162c68596
15 changed files with 1218 additions and 42 deletions
169
Api.SeaHavenIndustries.Tests/VendorControllerTests.cs
Normal file
169
Api.SeaHavenIndustries.Tests/VendorControllerTests.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
269
Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs
Normal file
269
Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
213
Api.SeaHavenIndustries.Tests/VendorServiceTests.cs
Normal file
213
Api.SeaHavenIndustries.Tests/VendorServiceTests.cs
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
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());
|
||||||
|
|
||||||
|
[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-0100",
|
||||||
|
CompanyPhone = "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-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.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.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,16 +32,43 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("GetVendorList")]
|
[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
|
var data = pagedResult.Items.Select(v => new
|
||||||
{
|
{
|
||||||
v.Id,
|
v.Id,
|
||||||
CompanyName = v.Name,
|
CompanyName = v.Name,
|
||||||
|
v.ContactName,
|
||||||
v.Email,
|
v.Email,
|
||||||
v.Phone
|
v.Phone,
|
||||||
|
v.CompanyPhone,
|
||||||
|
v.Address,
|
||||||
|
v.City,
|
||||||
|
v.State,
|
||||||
|
Zip = v.Zipcode,
|
||||||
|
v.TradeSpecialties,
|
||||||
|
v.GoogleMapsUrl,
|
||||||
|
v.Notes,
|
||||||
|
v.IsActive,
|
||||||
|
v.TotalJobs
|
||||||
});
|
});
|
||||||
|
|
||||||
return Ok(new Pagination_DTO
|
return Ok(new Pagination_DTO
|
||||||
|
|
@ -56,17 +83,35 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
[HttpGet("GetById")]
|
[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" });
|
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)
|
if (vendor == null)
|
||||||
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
|
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
|
||||||
|
|
||||||
return Ok(vendor);
|
return Ok(new
|
||||||
|
{
|
||||||
|
vendor.Id,
|
||||||
|
CompanyName = vendor.Name,
|
||||||
|
vendor.ContactName,
|
||||||
|
vendor.Email,
|
||||||
|
vendor.Phone,
|
||||||
|
vendor.CompanyPhone,
|
||||||
|
vendor.Address,
|
||||||
|
vendor.City,
|
||||||
|
vendor.State,
|
||||||
|
Zip = vendor.Zipcode,
|
||||||
|
vendor.TradeSpecialties,
|
||||||
|
vendor.GoogleMapsUrl,
|
||||||
|
vendor.Notes,
|
||||||
|
vendor.IsActive,
|
||||||
|
vendor.TotalJobs
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
|
@ -77,7 +122,19 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
var createDto = new CreateVendorDTO
|
var createDto = new CreateVendorDTO
|
||||||
{
|
{
|
||||||
Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required")
|
Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required"),
|
||||||
|
ContactName = model.ContactName,
|
||||||
|
Email = model.Email,
|
||||||
|
Phone = model.Phone,
|
||||||
|
CompanyPhone = model.CompanyPhone,
|
||||||
|
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);
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
|
@ -111,7 +168,19 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
var updateDto = new UpdateVendorDTO
|
var updateDto = new UpdateVendorDTO
|
||||||
{
|
{
|
||||||
Name = model.CompanyName
|
Name = model.CompanyName,
|
||||||
|
ContactName = model.ContactName,
|
||||||
|
Email = model.Email,
|
||||||
|
Phone = model.Phone,
|
||||||
|
CompanyPhone = model.CompanyPhone,
|
||||||
|
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);
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
|
@ -126,7 +195,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
||||||
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
||||||
}
|
}
|
||||||
catch (KeyNotFoundException)
|
catch (InvalidOperationException)
|
||||||
{
|
{
|
||||||
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
|
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +207,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
[HttpPost("Delete")]
|
[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
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -152,9 +221,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||||
|
|
||||||
await _vendorService.DeleteVendorAsync(vendorId, userId);
|
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 (InvalidOperationException)
|
||||||
{
|
{
|
||||||
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
|
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
|
||||||
}
|
}
|
||||||
|
|
@ -212,6 +281,64 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
return Ok(result);
|
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.CompanyPhone,
|
||||||
|
v.Email,
|
||||||
|
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")]
|
[HttpGet("{id:int}/portal-token")]
|
||||||
public async Task<IActionResult> GetPortalToken(int id)
|
public async Task<IActionResult> GetPortalToken(int id)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,15 @@ namespace Api.SeaHavenIndustries.DTOs
|
||||||
public string? ContactName { get; set; }
|
public string? ContactName { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? City { get; set; }
|
public string? City { get; set; }
|
||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
public string? Zip { get; set; }
|
public string? Zip { get; set; }
|
||||||
public string? TradeSpecialties { 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 class EditVendor_DTO : Vendor_DTO
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1733,6 +1733,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Property<string>("CompanyName")
|
b.Property<string>("CompanyName")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("CompanyPhone")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("ContactName")
|
b.Property<string>("ContactName")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
|
@ -1748,6 +1751,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Property<string>("Email")
|
b.Property<string>("Email")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("GoogleMapsUrl")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
|
@ -1760,6 +1766,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Property<int?>("LastModifierUserId")
|
b.Property<int?>("LastModifierUserId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("Phone")
|
b.Property<string>("Phone")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
namespace Data.SeaHavenIndustries
|
namespace Data.SeaHavenIndustries
|
||||||
{
|
{
|
||||||
public class Vendor : FullAuditEntity
|
public class Vendor : FullAuditEntity
|
||||||
|
|
@ -6,12 +8,17 @@ namespace Data.SeaHavenIndustries
|
||||||
public string? ContactName { get; set; }
|
public string? ContactName { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? City { get; set; }
|
public string? City { get; set; }
|
||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
public string? Zip { get; set; }
|
public string? Zip { get; set; }
|
||||||
public string? TradeSpecialties { get; set; }
|
public string? TradeSpecialties { get; set; }
|
||||||
|
public string? GoogleMapsUrl { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
[NotMapped]
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
public ICollection<Dispatch>? Dispatches { get; set; }
|
public ICollection<Dispatch>? Dispatches { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Linq.Expressions;
|
||||||
|
using System.Reflection;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Implementation
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
@ -15,13 +17,16 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
||||||
public async Task<Vendor?> GetByIdAsync(int id)
|
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)
|
public async Task<Vendor?> GetByIdWithDetailsAsync(int id)
|
||||||
{
|
{
|
||||||
return await _context.Vendors
|
return await ProjectWithTotalJobs(
|
||||||
.FirstOrDefaultAsync(v => v.Id == id);
|
_context.Vendors
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(v => v.Id == id))
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<Vendor>> GetAllAsync()
|
public async Task<IEnumerable<Vendor>> GetAllAsync()
|
||||||
|
|
@ -32,24 +37,23 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
public async Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
public async Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
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);
|
var totalCount = await projectedQuery.CountAsync();
|
||||||
|
var items = await projectedQuery
|
||||||
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
|
|
||||||
.OrderByDescending(v => v.Id)
|
.OrderByDescending(v => v.Id)
|
||||||
.Skip((page - 1) * pageSize)
|
.Skip((page - 1) * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
|
|
@ -58,6 +62,198 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return (items, totalCount);
|
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,
|
||||||
|
ContactName = v.ContactName,
|
||||||
|
Email = v.Email,
|
||||||
|
Phone = v.Phone,
|
||||||
|
CompanyPhone = v.CompanyPhone,
|
||||||
|
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)
|
public async Task<Vendor> AddAsync(Vendor vendor)
|
||||||
{
|
{
|
||||||
await _context.Vendors.AddAsync(vendor);
|
await _context.Vendors.AddAsync(vendor);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,15 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task<Vendor?> GetByIdAsync(int id);
|
Task<Vendor?> GetByIdAsync(int id);
|
||||||
Task<Vendor?> GetByIdWithDetailsAsync(int id);
|
Task<Vendor?> GetByIdWithDetailsAsync(int id);
|
||||||
Task<IEnumerable<Vendor>> GetAllAsync();
|
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<Vendor> AddAsync(Vendor vendor);
|
||||||
Task UpdateAsync(Vendor vendor);
|
Task UpdateAsync(Vendor vendor);
|
||||||
Task DeleteAsync(int id);
|
Task DeleteAsync(int id);
|
||||||
|
|
|
||||||
3
SeaHaven.DataServices/Properties/AssemblyInfo.cs
Normal file
3
SeaHaven.DataServices/Properties/AssemblyInfo.cs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("Api.SeaHavenIndustries.Tests")]
|
||||||
|
|
@ -4,33 +4,52 @@ namespace SeaHaven.Services.DTOs
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? City { get; set; }
|
public string? City { get; set; }
|
||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
public string? Zipcode { 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 class CreateVendorDTO
|
||||||
{
|
{
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? City { get; set; }
|
public string? City { get; set; }
|
||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
public string? Zipcode { 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 class UpdateVendorDTO
|
||||||
{
|
{
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? City { get; set; }
|
public string? City { get; set; }
|
||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
public string? Zipcode { 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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
public async Task<VendorDTO?> GetVendorByIdAsync(int id)
|
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);
|
return vendor == null ? null : MapToDTO(vendor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,9 +45,25 @@ namespace SeaHaven.Services.Implementation
|
||||||
return vendors.Select(MapToDTO);
|
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>
|
return new PagedResult<VendorDTO>
|
||||||
{
|
{
|
||||||
Items = items.Select(MapToDTO).ToList(),
|
Items = items.Select(MapToDTO).ToList(),
|
||||||
|
|
@ -68,12 +84,18 @@ namespace SeaHaven.Services.Implementation
|
||||||
var vendor = new Vendor
|
var vendor = new Vendor
|
||||||
{
|
{
|
||||||
CompanyName = dto.Name,
|
CompanyName = dto.Name,
|
||||||
|
ContactName = dto.ContactName,
|
||||||
Email = dto.Email,
|
Email = dto.Email,
|
||||||
Phone = dto.Phone,
|
Phone = dto.Phone,
|
||||||
|
CompanyPhone = dto.CompanyPhone,
|
||||||
Address = dto.Address,
|
Address = dto.Address,
|
||||||
City = dto.City,
|
City = dto.City,
|
||||||
State = dto.State,
|
State = dto.State,
|
||||||
Zip = dto.Zipcode,
|
Zip = dto.Zipcode,
|
||||||
|
TradeSpecialties = dto.TradeSpecialties,
|
||||||
|
GoogleMapsUrl = dto.GoogleMapsUrl,
|
||||||
|
Notes = dto.Notes,
|
||||||
|
IsActive = dto.IsActive,
|
||||||
CreatedDate = DateTime.UtcNow,
|
CreatedDate = DateTime.UtcNow,
|
||||||
createdby = userId
|
createdby = userId
|
||||||
};
|
};
|
||||||
|
|
@ -95,12 +117,18 @@ namespace SeaHaven.Services.Implementation
|
||||||
throw new InvalidOperationException($"Vendor with ID {id} not found");
|
throw new InvalidOperationException($"Vendor with ID {id} not found");
|
||||||
|
|
||||||
if (dto.Name != null) vendor.CompanyName = dto.Name;
|
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.Email != null) vendor.Email = dto.Email;
|
||||||
if (dto.Phone != null) vendor.Phone = dto.Phone;
|
if (dto.Phone != null) vendor.Phone = dto.Phone;
|
||||||
|
if (dto.CompanyPhone != null) vendor.CompanyPhone = dto.CompanyPhone;
|
||||||
if (dto.Address != null) vendor.Address = dto.Address;
|
if (dto.Address != null) vendor.Address = dto.Address;
|
||||||
if (dto.City != null) vendor.City = dto.City;
|
if (dto.City != null) vendor.City = dto.City;
|
||||||
if (dto.State != null) vendor.State = dto.State;
|
if (dto.State != null) vendor.State = dto.State;
|
||||||
if (dto.Zipcode != null) vendor.Zip = dto.Zipcode;
|
if (dto.Zipcode != null) vendor.Zip = dto.Zipcode;
|
||||||
|
if (dto.TradeSpecialties != null) vendor.TradeSpecialties = dto.TradeSpecialties;
|
||||||
|
if (dto.GoogleMapsUrl != null) vendor.GoogleMapsUrl = dto.GoogleMapsUrl;
|
||||||
|
if (dto.Notes != null) vendor.Notes = dto.Notes;
|
||||||
|
if (dto.IsActive.HasValue) vendor.IsActive = dto.IsActive.Value;
|
||||||
|
|
||||||
vendor.LastModificationTime = DateTime.UtcNow;
|
vendor.LastModificationTime = DateTime.UtcNow;
|
||||||
if (int.TryParse(userId, out int userIdInt))
|
if (int.TryParse(userId, out int userIdInt))
|
||||||
|
|
@ -116,9 +144,10 @@ namespace SeaHaven.Services.Implementation
|
||||||
if (vendor == null)
|
if (vendor == null)
|
||||||
throw new InvalidOperationException($"Vendor with ID {id} not found");
|
throw new InvalidOperationException($"Vendor with ID {id} not found");
|
||||||
|
|
||||||
vendor.IsDeleted = true;
|
vendor.IsActive = false;
|
||||||
vendor.DeleterUserId = userId;
|
vendor.LastModificationTime = DateTime.UtcNow;
|
||||||
vendor.DeletionTime = DateTime.UtcNow;
|
if (int.TryParse(userId, out int userIdInt))
|
||||||
|
vendor.LastModifierUserId = userIdInt;
|
||||||
|
|
||||||
await _vendorDataService.UpdateAsync(vendor);
|
await _vendorDataService.UpdateAsync(vendor);
|
||||||
}
|
}
|
||||||
|
|
@ -139,13 +168,40 @@ namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
Id = vendor.Id,
|
Id = vendor.Id,
|
||||||
Name = vendor.CompanyName,
|
Name = vendor.CompanyName,
|
||||||
|
ContactName = vendor.ContactName,
|
||||||
Email = vendor.Email,
|
Email = vendor.Email,
|
||||||
Phone = vendor.Phone,
|
Phone = vendor.Phone,
|
||||||
|
CompanyPhone = vendor.CompanyPhone,
|
||||||
Address = vendor.Address,
|
Address = vendor.Address,
|
||||||
City = vendor.City,
|
City = vendor.City,
|
||||||
State = vendor.State,
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,15 @@ namespace SeaHaven.Services.Interfaces
|
||||||
Task<VendorDTO?> GetVendorByIdAsync(int id);
|
Task<VendorDTO?> GetVendorByIdAsync(int id);
|
||||||
Task<VendorDTO?> GetVendorByIdWithDetailsAsync(int id);
|
Task<VendorDTO?> GetVendorByIdWithDetailsAsync(int id);
|
||||||
Task<IEnumerable<VendorDTO>> GetAllVendorsAsync();
|
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> CreateVendorAsync(CreateVendorDTO dto, string userId);
|
||||||
Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);
|
Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);
|
||||||
Task DeleteVendorAsync(int id, string userId);
|
Task DeleteVendorAsync(int id, string userId);
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,22 @@ namespace SeaHaven.Services.Validation
|
||||||
.EmailAddress().WithMessage("Invalid email address")
|
.EmailAddress().WithMessage("Invalid email address")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Email));
|
.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.TradeSpecialties)
|
||||||
|
.MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters")
|
||||||
|
.When(x => !string.IsNullOrEmpty(x.TradeSpecialties));
|
||||||
|
|
||||||
RuleFor(x => x.Phone)
|
RuleFor(x => x.Phone)
|
||||||
.MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters")
|
.MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Phone));
|
.When(x => !string.IsNullOrEmpty(x.Phone));
|
||||||
|
|
||||||
|
RuleFor(x => x.CompanyPhone)
|
||||||
|
.MaximumLength(20).WithMessage("Company phone cannot exceed 20 characters")
|
||||||
|
.When(x => !string.IsNullOrEmpty(x.CompanyPhone));
|
||||||
|
|
||||||
RuleFor(x => x.Address)
|
RuleFor(x => x.Address)
|
||||||
.MaximumLength(500).WithMessage("Address cannot exceed 500 characters")
|
.MaximumLength(500).WithMessage("Address cannot exceed 500 characters")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Address));
|
.When(x => !string.IsNullOrEmpty(x.Address));
|
||||||
|
|
@ -38,6 +50,14 @@ namespace SeaHaven.Services.Validation
|
||||||
RuleFor(x => x.Zipcode)
|
RuleFor(x => x.Zipcode)
|
||||||
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Zipcode));
|
.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 +77,23 @@ namespace SeaHaven.Services.Validation
|
||||||
.EmailAddress().WithMessage("Invalid email address")
|
.EmailAddress().WithMessage("Invalid email address")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Email));
|
.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.TradeSpecialties)
|
||||||
|
.MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters")
|
||||||
|
.When(x => !string.IsNullOrEmpty(x.TradeSpecialties));
|
||||||
|
|
||||||
RuleFor(x => x.Phone)
|
RuleFor(x => x.Phone)
|
||||||
.MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters")
|
.MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Phone));
|
.When(x => !string.IsNullOrEmpty(x.Phone));
|
||||||
|
|
||||||
|
RuleFor(x => x.CompanyPhone)
|
||||||
|
.MaximumLength(20).WithMessage("Company phone cannot exceed 20 characters")
|
||||||
|
.When(x => !string.IsNullOrEmpty(x.CompanyPhone));
|
||||||
|
|
||||||
RuleFor(x => x.Address)
|
RuleFor(x => x.Address)
|
||||||
.MaximumLength(500).WithMessage("Address cannot exceed 500 characters")
|
.MaximumLength(500).WithMessage("Address cannot exceed 500 characters")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Address));
|
.When(x => !string.IsNullOrEmpty(x.Address));
|
||||||
|
|
@ -76,6 +109,21 @@ namespace SeaHaven.Services.Validation
|
||||||
RuleFor(x => x.Zipcode)
|
RuleFor(x => x.Zipcode)
|
||||||
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Zipcode));
|
.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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class VendorValidationRules
|
||||||
|
{
|
||||||
|
internal static bool BeValidHttpsUrl(string? value) =>
|
||||||
|
Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue