mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
SH-281: group vendor directory by company (#115)
* feat(vendors): group directory by company * style(vendors): format company directory query * fix(vendors): preserve technician list contract
This commit is contained in:
parent
eb9b7eb50e
commit
67089c2135
10 changed files with 1329 additions and 60 deletions
|
|
@ -39,7 +39,7 @@ public class VendorControllerTests
|
|||
var service = new Mock<IVendorService>();
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var token = cancellation.Token;
|
||||
service.Setup(x => x.GetVendorDirectoryPagedAsync(
|
||||
service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
||||
1,
|
||||
10,
|
||||
"clayton",
|
||||
|
|
@ -53,7 +53,7 @@ public class VendorControllerTests
|
|||
|
||||
await NewController(service).GetVendorList(search: "clayton", cancellationToken: token);
|
||||
|
||||
service.Verify(x => x.GetVendorDirectoryPagedAsync(
|
||||
service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
||||
1,
|
||||
10,
|
||||
"clayton",
|
||||
|
|
@ -63,6 +63,175 @@ public class VendorControllerTests
|
|||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
token), Times.Once);
|
||||
service.Verify(x => x.GetVendorCompanyDirectoryPagedAsync(
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<bool?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorList_ReturnsIndividualTechnicianRowsWithoutGroupedTechnicians()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<bool?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>
|
||||
{
|
||||
Items = new List<VendorDirectoryItemDTO>
|
||||
{
|
||||
new() { Id = 7, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech A" },
|
||||
new() { Id = 9, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech B" }
|
||||
},
|
||||
TotalCount = 2,
|
||||
Page = 1,
|
||||
PageSize = 10
|
||||
});
|
||||
|
||||
var result = await NewController(service).GetVendorList();
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var paged = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
|
||||
var rows = ((System.Collections.IEnumerable)paged.Data!).Cast<object>().ToList();
|
||||
|
||||
// Legacy contract: same-company technicians stay as distinct top-level rows
|
||||
// carrying their own vendor ids, and the response shape has no grouped
|
||||
// Technicians property.
|
||||
rows.Should().HaveCount(2);
|
||||
rows.Select(r => r.GetType().GetProperty("Id")!.GetValue(r)).Should().BeEquivalentTo(new[] { 7, 9 });
|
||||
rows.Should().OnlyContain(r => r.GetType().GetProperty("Technicians") == null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorList_PreservesLegacyPageSizeAboveGroupedDirectoryBound()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
||||
1,
|
||||
500,
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<bool?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO> { TotalCount = 420, Page = 1, PageSize = 500 });
|
||||
|
||||
var result = await NewController(service).GetVendorList(page: 1, pageSize: 500);
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var paged = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
|
||||
paged.PageSize.Should().Be(500);
|
||||
paged.TotalPages.Should().Be(1);
|
||||
service.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorDirectoryList_DelegatesToCompanyDirectoryServiceWithNestedTechnicians()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var token = cancellation.Token;
|
||||
service.Setup(x => x.GetVendorCompanyDirectoryPagedAsync(
|
||||
1,
|
||||
10,
|
||||
"clayton",
|
||||
true,
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
token))
|
||||
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>
|
||||
{
|
||||
Items = new List<VendorDirectoryItemDTO>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 9,
|
||||
CompanyId = 3,
|
||||
CompanyName = "Gateway Plumbing",
|
||||
Email = "company@gatewayplumbing.example",
|
||||
TotalJobs = 6,
|
||||
Technicians = new List<VendorDirectoryTechnicianDTO>
|
||||
{
|
||||
new() { Id = 7, ContactName = "Tech A", Email = "tech.a@example.com" },
|
||||
new() { Id = 9, ContactName = "Tech B", Email = "tech.b@example.com" }
|
||||
}
|
||||
}
|
||||
},
|
||||
TotalCount = 1,
|
||||
Page = 1,
|
||||
PageSize = 10
|
||||
});
|
||||
|
||||
var result = await NewController(service).GetVendorDirectoryList(search: "clayton", cancellationToken: token);
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var paged = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
|
||||
var company = ((System.Collections.IEnumerable)paged.Data!).Cast<object>().Single();
|
||||
|
||||
// SH-281: company row with nested technicians; top-level Email stays the
|
||||
// company email while technician emails stay on the nested rows.
|
||||
company.GetType().GetProperty("Email")!.GetValue(company).Should().Be("company@gatewayplumbing.example");
|
||||
var technicians = (System.Collections.IEnumerable)company.GetType().GetProperty("Technicians")!.GetValue(company)!;
|
||||
technicians.Cast<object>().Select(t => t.GetType().GetProperty("Id")!.GetValue(t))
|
||||
.Should().BeEquivalentTo(new[] { 7, 9 });
|
||||
technicians.Cast<object>().Select(t => t.GetType().GetProperty("Email")!.GetValue(t))
|
||||
.Should().BeEquivalentTo(new[] { "tech.a@example.com", "tech.b@example.com" });
|
||||
|
||||
service.Verify(x => x.GetVendorCompanyDirectoryPagedAsync(
|
||||
1,
|
||||
10,
|
||||
"clayton",
|
||||
true,
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
token), Times.Once);
|
||||
service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<bool?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VendorDirectoryEndpoints_AdvertiseDistinctCompatibilityAndCompanyRoutes()
|
||||
{
|
||||
var legacyRoutes = typeof(VendorController)
|
||||
.GetMethod(nameof(VendorController.GetVendorList))!
|
||||
.GetCustomAttributes(typeof(HttpGetAttribute), inherit: false)
|
||||
.Cast<HttpGetAttribute>()
|
||||
.Select(attribute => attribute.Template);
|
||||
var companyRoutes = typeof(VendorController)
|
||||
.GetMethod(nameof(VendorController.GetVendorDirectoryList))!
|
||||
.GetCustomAttributes(typeof(HttpGetAttribute), inherit: false)
|
||||
.Cast<HttpGetAttribute>()
|
||||
.Select(attribute => attribute.Template);
|
||||
|
||||
legacyRoutes.Should().BeEquivalentTo("GetVendorList");
|
||||
companyRoutes.Should().BeEquivalentTo("GetVendorDirectoryList");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using FluentAssertions;
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.DataServices.Models;
|
||||
using System.Text.RegularExpressions;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
|
@ -327,6 +328,158 @@ public class VendorDataServiceTests
|
|||
result.Select(x => x.WorkOrderId).Should().OnlyHaveUniqueItems();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCompanyDirectoryPagedAsync_GroupsCompaniesAndKeepsLegacyRowsDistinct()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
var linkedCompany = new VendorCompany
|
||||
{
|
||||
Name = "Linked Co",
|
||||
NormalizedName = "linked co",
|
||||
Email = "dispatch@linked.example",
|
||||
CompanyPhone = "555-0100",
|
||||
Address = "100 Company Way",
|
||||
City = "Chicago",
|
||||
State = "IL"
|
||||
};
|
||||
var companyOnly = new VendorCompany { Name = "Company Only", NormalizedName = "company only" };
|
||||
context.VendorCompanies.AddRange(linkedCompany, companyOnly);
|
||||
context.Vendors.AddRange(
|
||||
new Vendor
|
||||
{
|
||||
Company = linkedCompany,
|
||||
CompanyName = "Stale Linked Name",
|
||||
ContactName = "Older Tech",
|
||||
Email = "older@linked.example",
|
||||
Phone = "555-0101",
|
||||
TradeSpecialties = "Plumbing",
|
||||
IsActive = true,
|
||||
IsDeleted = false,
|
||||
Dispatches = new List<Dispatch> { new() { WorkOrderId = 101 } }
|
||||
},
|
||||
new Vendor
|
||||
{
|
||||
Company = linkedCompany,
|
||||
CompanyName = "Another Stale Name",
|
||||
ContactName = "Newer Tech",
|
||||
Email = "newer@linked.example",
|
||||
Phone = "555-0102",
|
||||
TradeSpecialties = "HVAC, plumbing",
|
||||
IsActive = true,
|
||||
IsDeleted = false,
|
||||
Dispatches = new List<Dispatch> { new() { WorkOrderId = 102 } }
|
||||
},
|
||||
new Vendor { CompanyName = " Legacy Co ", ContactName = "Legacy One", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = "legacy co", ContactName = "Legacy Two", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = " ", ContactName = "Unnamed One", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = null, ContactName = "Unnamed Two", IsActive = true, IsDeleted = false });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new VendorDataService(context);
|
||||
var (items, totalCount) = await service.GetCompanyDirectoryPagedAsync(1, 50, isActive: true);
|
||||
|
||||
totalCount.Should().Be(5);
|
||||
var linked = items.Single(item => item.CompanyId == linkedCompany.Id);
|
||||
linked.CompanyName.Should().Be("Linked Co");
|
||||
linked.Email.Should().Be("dispatch@linked.example");
|
||||
linked.ContactName.Should().Be("Newer Tech");
|
||||
linked.Technicians.Should().HaveCount(2);
|
||||
linked.TotalJobs.Should().Be(2);
|
||||
linked.TradeSpecialties.Should().Be("HVAC, plumbing");
|
||||
|
||||
var canonicalSearch = await service.GetCompanyDirectoryPagedAsync(
|
||||
1, 50, search: "Linked Co", isActive: true);
|
||||
canonicalSearch.Items.Should().ContainSingle(item => item.CompanyId == linkedCompany.Id);
|
||||
|
||||
var canonicalCompanyFilter = await service.GetCompanyDirectoryPagedAsync(
|
||||
1, 50, isActive: true, companies: new[] { "Linked Co" });
|
||||
canonicalCompanyFilter.Items.Should().ContainSingle(item => item.CompanyId == linkedCompany.Id);
|
||||
|
||||
var legacy = items.Single(item =>
|
||||
item.Technicians.Any(tech => tech.ContactName == "Legacy One"));
|
||||
legacy.Technicians.Select(tech => tech.ContactName)
|
||||
.Should().BeEquivalentTo("Legacy One", "Legacy Two");
|
||||
items.Count(item => item.Technicians.Count == 1 &&
|
||||
item.Technicians.Single().ContactName?.StartsWith("Unnamed") == true).Should().Be(2);
|
||||
items.Single(item => item.CompanyId == companyOnly.Id).Technicians.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCompanyDirectoryPagedAsync_FilterSelectsGroupAndSearchPromotesMatchingTechnician()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
var company = new VendorCompany { Name = "Search Co", NormalizedName = "search co" };
|
||||
context.VendorCompanies.Add(company);
|
||||
var older = new Vendor
|
||||
{
|
||||
Company = company,
|
||||
CompanyName = company.Name,
|
||||
ContactName = "Alice Plumber",
|
||||
TradeSpecialties = "Plumbing",
|
||||
IsActive = true,
|
||||
IsDeleted = false,
|
||||
Dispatches = new List<Dispatch> { new() { WorkOrderId = 201 } }
|
||||
};
|
||||
var newer = new Vendor
|
||||
{
|
||||
Company = company,
|
||||
CompanyName = company.Name,
|
||||
ContactName = "Zed HVAC",
|
||||
TradeSpecialties = "HVAC",
|
||||
IsActive = true,
|
||||
IsDeleted = false,
|
||||
Dispatches = new List<Dispatch> { new() { WorkOrderId = 202 } }
|
||||
};
|
||||
var inactive = new Vendor
|
||||
{
|
||||
Company = company,
|
||||
CompanyName = company.Name,
|
||||
ContactName = "Inactive Tech",
|
||||
TradeSpecialties = "Roofing",
|
||||
IsActive = false,
|
||||
IsDeleted = false,
|
||||
Dispatches = new List<Dispatch> { new() { WorkOrderId = 203 } }
|
||||
};
|
||||
context.Vendors.AddRange(older, newer, inactive);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new VendorDataService(context);
|
||||
var search = await service.GetCompanyDirectoryPagedAsync(1, 10, search: "Alice", isActive: true);
|
||||
var trade = await service.GetCompanyDirectoryPagedAsync(
|
||||
1, 10, isActive: true, trades: new[] { "Plumbing" });
|
||||
|
||||
search.TotalCount.Should().Be(1);
|
||||
search.Items.Single().Id.Should().Be(older.Id);
|
||||
search.Items.Single().ContactName.Should().Be("Alice Plumber");
|
||||
search.Items.Single().Technicians.Should().HaveCount(2);
|
||||
search.Items.Single().TotalJobs.Should().Be(2);
|
||||
|
||||
trade.TotalCount.Should().Be(1);
|
||||
trade.Items.Single().Id.Should().Be(newer.Id);
|
||||
trade.Items.Single().Technicians.Should().HaveCount(2);
|
||||
trade.Items.Single().Technicians.Should().NotContain(tech => tech.Id == inactive.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCompanyDirectoryKeysQuery_TranslatesGroupingAndFacetFiltersToSqlServer()
|
||||
{
|
||||
using var context = NewSqlServerContext();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var sql = service.BuildCompanyDirectoryKeysQuery(
|
||||
search: "alice",
|
||||
trades: new[] { "Plumbing" },
|
||||
locations: new[] { "Chicago, IL" },
|
||||
jobBuckets: new[] { "under-50" }).ToQueryString();
|
||||
|
||||
sql.Should().Contain("GROUP BY");
|
||||
sql.Should().Contain("LEFT JOIN");
|
||||
sql.Should().Contain("TradeSpecialties");
|
||||
sql.Should().Contain("TotalJobs");
|
||||
Regex.IsMatch(sql, @"SUM\s*\(\s*\(\s*SELECT", RegexOptions.IgnoreCase)
|
||||
.Should().BeFalse("company totals must sum pre-aggregated scalar job counts");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDirectoryPagedAsync_IncludesCompanyOnlyRowsAndPreservesTechnicianRows()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -152,12 +152,12 @@ public class VendorServiceTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorDirectoryPaged_ForwardsCancellationToken()
|
||||
public async Task GetVendorCompanyDirectoryPaged_ForwardsCancellationTokenAndMapsNestedTechnicians()
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var token = cancellation.Token;
|
||||
data.Setup(x => x.GetDirectoryPagedAsync(
|
||||
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
|
||||
1,
|
||||
12,
|
||||
"clayton",
|
||||
|
|
@ -167,16 +167,43 @@ public class VendorServiceTests
|
|||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
token))
|
||||
.ReturnsAsync((Array.Empty<VendorDirectoryRow>(), 0));
|
||||
.ReturnsAsync((new[]
|
||||
{
|
||||
new VendorCompanyGroup
|
||||
{
|
||||
Id = 7,
|
||||
CompanyId = 3,
|
||||
CompanyName = "Gateway Plumbing",
|
||||
ContactName = "Primary Tech",
|
||||
Email = "company@gatewayplumbing.example",
|
||||
TotalJobs = 4,
|
||||
Technicians = new[]
|
||||
{
|
||||
new VendorCompanyGroupTechnician
|
||||
{
|
||||
Id = 7,
|
||||
ContactName = "Primary Tech",
|
||||
Email = "primary@example.com",
|
||||
TotalJobs = 4,
|
||||
IsActive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}.AsEnumerable().ToList(), 1));
|
||||
|
||||
await NewService(data).GetVendorDirectoryPagedAsync(
|
||||
var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync(
|
||||
1,
|
||||
12,
|
||||
"clayton",
|
||||
true,
|
||||
cancellationToken: token);
|
||||
|
||||
data.Verify(x => x.GetDirectoryPagedAsync(
|
||||
var group = result.Items.Single();
|
||||
group.Email.Should().Be("company@gatewayplumbing.example");
|
||||
group.Technicians.Should().ContainSingle()
|
||||
.Which.Email.Should().Be("primary@example.com");
|
||||
|
||||
data.Verify(x => x.GetCompanyDirectoryPagedAsync(
|
||||
1,
|
||||
12,
|
||||
"clayton",
|
||||
|
|
@ -188,6 +215,78 @@ public class VendorServiceTests
|
|||
token), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorCompanyDirectoryPaged_BoundsPageInputsBeforeBuildingTheGroupKeyPredicate()
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
|
||||
1,
|
||||
100,
|
||||
null,
|
||||
true,
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
|
||||
|
||||
var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync(0, 500);
|
||||
|
||||
result.Page.Should().Be(1);
|
||||
result.PageSize.Should().Be(100);
|
||||
data.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorTechnicianDirectoryPaged_ForwardsRawPaginationAndCancellationToken()
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var token = cancellation.Token;
|
||||
data.Setup(x => x.GetDirectoryPagedAsync(
|
||||
2,
|
||||
500,
|
||||
"clayton",
|
||||
true,
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
token))
|
||||
.ReturnsAsync((new[]
|
||||
{
|
||||
new VendorDirectoryRow { VendorId = 7, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech A" },
|
||||
new VendorDirectoryRow { VendorId = 9, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech B" }
|
||||
}.AsEnumerable().ToList(), 2));
|
||||
|
||||
var result = await NewService(data).GetVendorTechnicianDirectoryPagedAsync(
|
||||
2,
|
||||
500,
|
||||
"clayton",
|
||||
true,
|
||||
cancellationToken: token);
|
||||
|
||||
// Legacy pagination semantics: page and pageSize (including 500) pass through
|
||||
// unclamped, and same-company technicians stay as distinct rows.
|
||||
result.Page.Should().Be(2);
|
||||
result.PageSize.Should().Be(500);
|
||||
result.Items.Should().HaveCount(2);
|
||||
result.Items.Select(v => v.Id).Should().BeEquivalentTo(new[] { 7, 9 });
|
||||
result.Items.Should().OnlyContain(v => v.Technicians.Count == 0);
|
||||
|
||||
data.Verify(x => x.GetDirectoryPagedAsync(
|
||||
2,
|
||||
500,
|
||||
"clayton",
|
||||
true,
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
token), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorById_CountsDistinctHistoricalWorkOrders()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
_logger = logger;
|
||||
}
|
||||
|
||||
// SH-198 legacy per-technician directory contract. Existing consumers depend
|
||||
// on one row per technician (no grouped Technicians) and on raw pagination
|
||||
// semantics, including pageSize values above the grouped-directory bound.
|
||||
[HttpGet("GetVendorList")]
|
||||
public async Task<IActionResult> GetVendorList(
|
||||
string? search = "",
|
||||
|
|
@ -38,7 +41,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[FromQuery] string[]? jobBuckets = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pagedResult = await _vendorService.GetVendorDirectoryPagedAsync(
|
||||
var pagedResult = await _vendorService.GetVendorTechnicianDirectoryPagedAsync(
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
|
|
@ -80,6 +83,75 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
});
|
||||
}
|
||||
|
||||
// SH-281 company-grouped directory: one row per company with the full
|
||||
// status-eligible technician roster nested under Technicians and bounded
|
||||
// page sizes. Top-level Email is the company email; technician emails stay
|
||||
// on the nested rows.
|
||||
[HttpGet("GetVendorDirectoryList")]
|
||||
public async Task<IActionResult> GetVendorDirectoryList(
|
||||
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,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pagedResult = await _vendorService.GetVendorCompanyDirectoryPagedAsync(
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
isActive,
|
||||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets,
|
||||
cancellationToken);
|
||||
|
||||
var data = pagedResult.Items.Select(v => new
|
||||
{
|
||||
v.Id,
|
||||
CompanyName = v.CompanyName,
|
||||
v.CompanyId,
|
||||
v.ContactName,
|
||||
v.Email,
|
||||
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,
|
||||
Technicians = v.Technicians.Select(t => new
|
||||
{
|
||||
t.Id,
|
||||
t.ContactName,
|
||||
t.Email,
|
||||
t.Phone,
|
||||
t.PreferredContact,
|
||||
t.TradeSpecialties,
|
||||
t.IsActive,
|
||||
t.TotalJobs
|
||||
})
|
||||
});
|
||||
|
||||
return Ok(new Pagination_DTO
|
||||
{
|
||||
Data = data,
|
||||
PageNumber = pagedResult.Page,
|
||||
PageSize = pagedResult.PageSize,
|
||||
TotalCount = pagedResult.TotalCount,
|
||||
TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pagedResult.PageSize)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
[HttpGet("GetById")]
|
||||
public async Task<IActionResult> GetVendorById([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId)
|
||||
|
|
|
|||
|
|
@ -168,7 +168,188 @@ namespace SeaHaven.DataServices.Implementation
|
|||
return (items, totalCount);
|
||||
}
|
||||
|
||||
internal IQueryable<VendorDirectoryRow> BuildDirectoryUnionQuery(
|
||||
public async Task<(IReadOnlyList<VendorCompanyGroup> Items, int TotalCount)> GetCompanyDirectoryPagedAsync(
|
||||
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,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Phase 1: select and paginate bounded company group keys in SQL.
|
||||
var keyQuery = BuildCompanyDirectoryKeysQuery(
|
||||
search,
|
||||
isActive,
|
||||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets);
|
||||
|
||||
var totalCount = await keyQuery.CountAsync(cancellationToken);
|
||||
|
||||
var pageKeys = await keyQuery
|
||||
.OrderByDescending(key => key.MaxVendorId ?? -1)
|
||||
.ThenByDescending(key => key.CompanyId)
|
||||
.ThenBy(key => key.NameKey)
|
||||
.ThenBy(key => key.VendorKey)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (pageKeys.Count == 0)
|
||||
return (Array.Empty<VendorCompanyGroup>(), totalCount);
|
||||
|
||||
// Phase 2: fetch the full status-eligible roster only for the page keys,
|
||||
// without reapplying search/trade/location filters.
|
||||
var rosterRows = await BuildCompanyDirectoryRosterQuery(isActive, pageKeys)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var rosterByKey = rosterRows
|
||||
.GroupBy(row => (CompanyId: row.CompanyId ?? 0, row.NameKey, row.VendorKey))
|
||||
.ToDictionary(group => group.Key, group => group.OrderByDescending(row => row.VendorId).ToList());
|
||||
|
||||
var missingCompanyIds = pageKeys
|
||||
.Where(key => key.CompanyId != 0)
|
||||
.Where(key => !rosterByKey.ContainsKey((key.CompanyId, key.NameKey, key.VendorKey)))
|
||||
.Select(key => key.CompanyId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var companiesById = missingCompanyIds.Count == 0
|
||||
? new Dictionary<int, VendorCompany>()
|
||||
: await _context.VendorCompanies
|
||||
.AsNoTracking()
|
||||
.Where(c => missingCompanyIds.Contains(c.Id))
|
||||
.ToDictionaryAsync(c => c.Id, cancellationToken);
|
||||
|
||||
// Assemble groups in the page key order so the response preserves the
|
||||
// SQL-selected page order.
|
||||
var groups = new List<VendorCompanyGroup>(pageKeys.Count);
|
||||
foreach (var key in pageKeys)
|
||||
{
|
||||
if (rosterByKey.TryGetValue((key.CompanyId, key.NameKey, key.VendorKey), out var technicians))
|
||||
{
|
||||
groups.Add(BuildTechnicianGroup(technicians, search));
|
||||
}
|
||||
else if (companiesById.TryGetValue(key.CompanyId, out var company))
|
||||
{
|
||||
groups.Add(BuildCompanyOnlyGroup(company));
|
||||
}
|
||||
}
|
||||
|
||||
return (groups, totalCount);
|
||||
}
|
||||
|
||||
private static VendorCompanyGroup BuildTechnicianGroup(
|
||||
IReadOnlyList<VendorDirectoryRosterRow> techniciansNewestFirst,
|
||||
string? search)
|
||||
{
|
||||
var primary = techniciansNewestFirst[0];
|
||||
|
||||
// Primary is the newest VendorId by default; a nonblank search that matches
|
||||
// a non-primary technician's ContactName, email, Phone, or
|
||||
// TradeSpecialties promotes the newest matching technician. Company-level
|
||||
// matches (e.g. company name or location) never reorder.
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var normalizedSearch = search.ToLower();
|
||||
primary = techniciansNewestFirst.FirstOrDefault(row =>
|
||||
((row.ContactName ?? "").ToLower().Contains(normalizedSearch)) ||
|
||||
((row.Email ?? "").ToLower().Contains(normalizedSearch)) ||
|
||||
((row.Phone ?? "").ToLower().Contains(normalizedSearch)) ||
|
||||
((row.TradeSpecialties ?? "").ToLower().Contains(normalizedSearch)))
|
||||
?? techniciansNewestFirst[0];
|
||||
}
|
||||
|
||||
var linked = primary.CompanyId != null;
|
||||
var hasCompanyLocation = linked && (
|
||||
!string.IsNullOrWhiteSpace(primary.CompanyCity) ||
|
||||
!string.IsNullOrWhiteSpace(primary.CompanyState) ||
|
||||
!string.IsNullOrWhiteSpace(primary.CompanyAddress));
|
||||
|
||||
return new VendorCompanyGroup
|
||||
{
|
||||
Id = primary.VendorId,
|
||||
CompanyId = primary.CompanyId,
|
||||
CompanyName = linked ? primary.CanonicalCompanyName : primary.CompanyName,
|
||||
ContactName = primary.ContactName,
|
||||
Email = linked ? primary.CompanyEmail : primary.Email,
|
||||
Phone = primary.Phone,
|
||||
CompanyPhone = linked && !string.IsNullOrWhiteSpace(primary.CompanyPhone)
|
||||
? primary.CompanyPhone
|
||||
: primary.VendorCompanyPhone,
|
||||
PreferredContact = primary.PreferredContact,
|
||||
Address = linked && !string.IsNullOrWhiteSpace(primary.CompanyAddress)
|
||||
? primary.CompanyAddress
|
||||
: primary.Address,
|
||||
City = hasCompanyLocation ? primary.CompanyCity : primary.City,
|
||||
State = hasCompanyLocation ? primary.CompanyState : primary.State,
|
||||
Zip = primary.Zip,
|
||||
TradeSpecialties = AggregateTradeTokens(techniciansNewestFirst),
|
||||
GoogleMapsUrl = primary.GoogleMapsUrl,
|
||||
Notes = linked ? primary.CompanyNotes : null,
|
||||
IsActive = primary.IsActive,
|
||||
TotalJobs = techniciansNewestFirst.Sum(row => row.TotalJobs),
|
||||
Technicians = techniciansNewestFirst.Select(row => new VendorCompanyGroupTechnician
|
||||
{
|
||||
Id = row.VendorId,
|
||||
ContactName = row.ContactName,
|
||||
Email = row.Email,
|
||||
Phone = row.Phone,
|
||||
PreferredContact = row.PreferredContact,
|
||||
TradeSpecialties = row.TradeSpecialties,
|
||||
IsActive = row.IsActive,
|
||||
TotalJobs = row.TotalJobs
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
// Distinct trade CSV tokens case-insensitively, preserving the first displayed
|
||||
// spelling and order (technicians are listed newest VendorId first).
|
||||
private static string? AggregateTradeTokens(IEnumerable<VendorDirectoryRosterRow> techniciansNewestFirst)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var tokens = new List<string>();
|
||||
foreach (var row in techniciansNewestFirst)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.TradeSpecialties))
|
||||
continue;
|
||||
foreach (var token in row.TradeSpecialties.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (seen.Add(token))
|
||||
tokens.Add(token);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens.Count == 0 ? null : string.Join(", ", tokens);
|
||||
}
|
||||
|
||||
private static VendorCompanyGroup BuildCompanyOnlyGroup(VendorCompany company) => new()
|
||||
{
|
||||
Id = null,
|
||||
CompanyId = company.Id,
|
||||
CompanyName = company.Name,
|
||||
ContactName = null,
|
||||
Email = company.Email,
|
||||
Phone = null,
|
||||
CompanyPhone = company.CompanyPhone,
|
||||
PreferredContact = null,
|
||||
Address = company.Address,
|
||||
City = company.City,
|
||||
State = company.State,
|
||||
Zip = company.Zip,
|
||||
TradeSpecialties = null,
|
||||
GoogleMapsUrl = company.GoogleMapsUrl,
|
||||
Notes = company.Notes,
|
||||
IsActive = true,
|
||||
TotalJobs = 0,
|
||||
Technicians = Array.Empty<VendorCompanyGroupTechnician>()
|
||||
};
|
||||
|
||||
internal IQueryable<VendorCompanyGroupKeyRow> BuildCompanyDirectoryKeysQuery(
|
||||
string? search = null,
|
||||
bool? isActive = true,
|
||||
IReadOnlyCollection<string>? companies = null,
|
||||
|
|
@ -176,52 +357,195 @@ namespace SeaHaven.DataServices.Implementation
|
|||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null)
|
||||
{
|
||||
// Arm A: existing per-technician rows (every non-deleted vendor, including
|
||||
// those with no company). Notes come from the linked VendorCompany.
|
||||
var perTechnician = _context.Vendors
|
||||
var sourceRows = BuildDirectoryUnionBaseQuery(isActive)
|
||||
.Select(BuildGroupSourceRowSelector(search, companies, trades, locations));
|
||||
|
||||
var grouped = sourceRows
|
||||
.GroupBy(row => new { row.CompanyId, row.NameKey, row.VendorKey })
|
||||
.Select(group => new VendorCompanyGroupKeyRow
|
||||
{
|
||||
CompanyId = group.Key.CompanyId,
|
||||
NameKey = group.Key.NameKey,
|
||||
VendorKey = group.Key.VendorKey,
|
||||
MaxVendorId = group.Max(row => row.VendorId),
|
||||
TotalJobs = group.Sum(row => row.TotalJobs),
|
||||
SearchMatch = group.Max(row => row.SearchFlag),
|
||||
CompanyMatch = group.Max(row => row.CompanyFlag),
|
||||
TradeMatch = group.Max(row => row.TradeFlag),
|
||||
LocationMatch = group.Max(row => row.LocationFlag)
|
||||
});
|
||||
|
||||
// Each facet selects the group when any status-eligible row matches it
|
||||
// (OR within a facet, AND across facets); job buckets apply to the summed
|
||||
// per-technician TotalJobs of the whole company.
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
grouped = grouped.Where(key => key.SearchMatch == 1);
|
||||
|
||||
var selectedCompanies = companies?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLower())
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
if (selectedCompanies is { Length: > 0 })
|
||||
grouped = grouped.Where(key => key.CompanyMatch == 1);
|
||||
|
||||
var selectedTrades = trades?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLower())
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
if (selectedTrades is { Length: > 0 })
|
||||
grouped = grouped.Where(key => key.TradeMatch == 1);
|
||||
|
||||
var selectedLocations = locations?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLower())
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
if (selectedLocations is { Length: > 0 })
|
||||
grouped = grouped.Where(key => key.LocationMatch == 1);
|
||||
|
||||
var selectedJobBuckets = jobBuckets?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLower())
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
if (selectedJobBuckets is not { Length: > 0 })
|
||||
return grouped;
|
||||
|
||||
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 grouped.Where(key =>
|
||||
(under50 && key.TotalJobs < 50) ||
|
||||
(from50To99 && key.TotalJobs >= 50 && key.TotalJobs <= 99) ||
|
||||
(from100To149 && key.TotalJobs >= 100 && key.TotalJobs <= 149) ||
|
||||
(from150 && key.TotalJobs >= 150));
|
||||
}
|
||||
|
||||
private static Expression<Func<VendorDirectoryRow, VendorDirectoryGroupSourceRow>> BuildGroupSourceRowSelector(
|
||||
string? search,
|
||||
IReadOnlyCollection<string>? companies,
|
||||
IReadOnlyCollection<string>? trades,
|
||||
IReadOnlyCollection<string>? locations)
|
||||
{
|
||||
var row = Expression.Parameter(typeof(VendorDirectoryRow), "row");
|
||||
|
||||
var selectedCompanies = companies?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLower())
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var selectedTrades = trades?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLower())
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var selectedLocations = locations?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLower())
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
static Expression Flag(Expression? matchBody) =>
|
||||
matchBody is null
|
||||
? Expression.Constant(1)
|
||||
: Expression.Condition(matchBody, Expression.Constant(1), Expression.Constant(0));
|
||||
|
||||
var searchBody = string.IsNullOrWhiteSpace(search)
|
||||
? null
|
||||
: BuildSearchMatchBody(row, search.ToLower());
|
||||
var companyBody = selectedCompanies is { Length: > 0 }
|
||||
? BuildCompanyNameMatchBody(row, selectedCompanies)
|
||||
: null;
|
||||
var tradeBody = selectedTrades is { Length: > 0 }
|
||||
? BuildTradeMatchBody<VendorDirectoryRow>(row, selectedTrades)
|
||||
: null;
|
||||
var locationBody = selectedLocations is { Length: > 0 }
|
||||
? BuildLocationMatchBody<VendorDirectoryRow>(row, selectedLocations)
|
||||
: null;
|
||||
|
||||
var nameKey = Expression.Condition(
|
||||
Expression.NotEqual(
|
||||
Expression.Property(row, nameof(VendorDirectoryRow.CompanyId)),
|
||||
Expression.Constant(0)),
|
||||
Expression.Constant(string.Empty),
|
||||
LowerTrimmed(row, nameof(VendorDirectoryRow.CompanyName)));
|
||||
|
||||
var nameMissing = Expression.Equal(
|
||||
Expression.Call(
|
||||
CoalescedOrEmpty(row, nameof(VendorDirectoryRow.CompanyName)),
|
||||
nameof(string.Trim),
|
||||
Type.EmptyTypes),
|
||||
Expression.Constant(string.Empty));
|
||||
var vendorKey = Expression.Condition(
|
||||
Expression.AndAlso(
|
||||
Expression.Equal(
|
||||
Expression.Property(row, nameof(VendorDirectoryRow.CompanyId)),
|
||||
Expression.Constant(0)),
|
||||
nameMissing),
|
||||
Expression.Coalesce(
|
||||
Expression.Property(row, nameof(VendorDirectoryRow.VendorId)),
|
||||
Expression.Constant(0)),
|
||||
Expression.Constant(0));
|
||||
|
||||
MemberBinding Bind(string target, Expression value) =>
|
||||
Expression.Bind(typeof(VendorDirectoryGroupSourceRow).GetProperty(target)!, value);
|
||||
|
||||
return Expression.Lambda<Func<VendorDirectoryRow, VendorDirectoryGroupSourceRow>>(
|
||||
Expression.MemberInit(
|
||||
Expression.New(typeof(VendorDirectoryGroupSourceRow)),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.VendorId),
|
||||
Expression.Property(row, nameof(VendorDirectoryRow.VendorId))),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.CompanyId),
|
||||
Expression.Property(row, nameof(VendorDirectoryRow.CompanyId))),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.NameKey), nameKey),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.VendorKey), vendorKey),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.TotalJobs),
|
||||
Expression.Property(row, nameof(VendorDirectoryRow.TotalJobs))),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.SearchFlag), Flag(searchBody)),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.CompanyFlag), Flag(companyBody)),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.TradeFlag), Flag(tradeBody)),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody))),
|
||||
row);
|
||||
}
|
||||
|
||||
internal IQueryable<VendorDirectoryRosterRow> BuildCompanyDirectoryRosterQuery(
|
||||
bool? isActive,
|
||||
IReadOnlyList<VendorCompanyGroupKeyRow> keys)
|
||||
{
|
||||
var vendors = _context.Vendors
|
||||
.AsNoTracking()
|
||||
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
||||
|
||||
if (isActive.HasValue)
|
||||
perTechnician = perTechnician.Where(v => v.IsActive == isActive.Value);
|
||||
vendors = vendors.Where(v => v.IsActive == isActive.Value);
|
||||
|
||||
var armA = perTechnician.Select(v => new VendorDirectoryRow
|
||||
var rows = vendors.Select(v => new VendorDirectoryRosterRow
|
||||
{
|
||||
VendorId = v.Id,
|
||||
CompanyId = v.CompanyId ?? 0,
|
||||
CompanyName = v.CompanyName,
|
||||
CompanyId = v.CompanyId,
|
||||
CompanyName = v.Company != null ? v.Company.Name : v.CompanyName,
|
||||
CanonicalCompanyName = v.Company != null ? v.Company.Name : null,
|
||||
CompanyEmail = v.Company != null ? v.Company.Email : null,
|
||||
CompanyPhone = v.Company != null ? v.Company.CompanyPhone : null,
|
||||
CompanyAddress = v.Company != null ? v.Company.Address : null,
|
||||
CompanyCity = v.Company != null ? v.Company.City : null,
|
||||
CompanyState = v.Company != null ? v.Company.State : null,
|
||||
CompanyNotes = v.Company != null ? v.Company.Notes : null,
|
||||
ContactName = v.ContactName,
|
||||
Email = v.Email != null && v.Email.Trim() != ""
|
||||
? v.Email
|
||||
: v.Company != null
|
||||
? v.Company.Email
|
||||
: v.Email,
|
||||
Email = v.Email,
|
||||
Phone = v.Phone,
|
||||
CompanyPhone = v.Company != null &&
|
||||
v.Company.CompanyPhone != null && v.Company.CompanyPhone.Trim() != ""
|
||||
? v.Company.CompanyPhone
|
||||
: v.CompanyPhone,
|
||||
PreferredContact = v.PreferredContact,
|
||||
Address = v.Company != null &&
|
||||
v.Company.Address != null && v.Company.Address.Trim() != ""
|
||||
? v.Company.Address
|
||||
: v.Address,
|
||||
City = v.Company != null &&
|
||||
((v.Company.City != null && v.Company.City.Trim() != "") ||
|
||||
(v.Company.State != null && v.Company.State.Trim() != "") ||
|
||||
(v.Company.Address != null && v.Company.Address.Trim() != ""))
|
||||
? v.Company.City
|
||||
: v.City,
|
||||
State = v.Company != null &&
|
||||
((v.Company.City != null && v.Company.City.Trim() != "") ||
|
||||
(v.Company.State != null && v.Company.State.Trim() != "") ||
|
||||
(v.Company.Address != null && v.Company.Address.Trim() != ""))
|
||||
? v.Company.State
|
||||
: v.State,
|
||||
VendorCompanyPhone = v.CompanyPhone,
|
||||
Address = v.Address,
|
||||
City = v.City,
|
||||
State = v.State,
|
||||
Zip = v.Zip,
|
||||
TradeSpecialties = v.TradeSpecialties,
|
||||
GoogleMapsUrl = v.GoogleMapsUrl,
|
||||
Notes = v.Company != null ? v.Company.Notes : null,
|
||||
IsActive = v.IsActive,
|
||||
TotalJobs = _context.Dispatches
|
||||
.Where(dispatch =>
|
||||
|
|
@ -232,9 +556,156 @@ namespace SeaHaven.DataServices.Implementation
|
|||
_context.DispatchWorkOrders
|
||||
.Where(link => link.Dispatch != null && link.Dispatch.VendorId == v.Id)
|
||||
.Select(link => link.WorkOrderId))
|
||||
.Count()
|
||||
.Count(),
|
||||
NameKey = v.CompanyId != null
|
||||
? ""
|
||||
: (v.CompanyName ?? "").Trim().ToLower(),
|
||||
VendorKey = v.CompanyId == null && (v.CompanyName ?? "").Trim() == ""
|
||||
? v.Id
|
||||
: 0
|
||||
});
|
||||
|
||||
return rows.Where(BuildRosterKeyPredicate(keys));
|
||||
}
|
||||
|
||||
private static Expression<Func<VendorDirectoryRosterRow, bool>> BuildRosterKeyPredicate(
|
||||
IReadOnlyList<VendorCompanyGroupKeyRow> keys)
|
||||
{
|
||||
var row = Expression.Parameter(typeof(VendorDirectoryRosterRow), "row");
|
||||
|
||||
Expression? body = null;
|
||||
foreach (var key in keys)
|
||||
{
|
||||
Expression keyMatch;
|
||||
if (key.CompanyId != 0)
|
||||
{
|
||||
keyMatch = Expression.Equal(
|
||||
Expression.Property(row, nameof(VendorDirectoryRosterRow.CompanyId)),
|
||||
Expression.Constant(key.CompanyId, typeof(int?)));
|
||||
}
|
||||
else if (key.NameKey.Length > 0)
|
||||
{
|
||||
keyMatch = Expression.AndAlso(
|
||||
Expression.Equal(
|
||||
Expression.Property(row, nameof(VendorDirectoryRosterRow.CompanyId)),
|
||||
Expression.Constant(null, typeof(int?))),
|
||||
Expression.Equal(
|
||||
Expression.Property(row, nameof(VendorDirectoryRosterRow.NameKey)),
|
||||
Expression.Constant(key.NameKey)));
|
||||
}
|
||||
else
|
||||
{
|
||||
keyMatch = Expression.Equal(
|
||||
Expression.Property(row, nameof(VendorDirectoryRosterRow.VendorId)),
|
||||
Expression.Constant(key.VendorKey));
|
||||
}
|
||||
|
||||
body = OrElse(body, keyMatch);
|
||||
}
|
||||
|
||||
return Expression.Lambda<Func<VendorDirectoryRosterRow, bool>>(
|
||||
body ?? Expression.Constant(false),
|
||||
row);
|
||||
}
|
||||
|
||||
internal IQueryable<VendorDirectoryRow> BuildDirectoryUnionQuery(
|
||||
string? search = null,
|
||||
bool? isActive = true,
|
||||
IReadOnlyCollection<string>? companies = null,
|
||||
IReadOnlyCollection<string>? trades = null,
|
||||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null) =>
|
||||
ApplyDirectoryRowFilters(
|
||||
BuildDirectoryUnionBaseQuery(isActive),
|
||||
search,
|
||||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets);
|
||||
|
||||
private IQueryable<VendorDirectoryRow> BuildDirectoryUnionBaseQuery(bool? isActive = true)
|
||||
{
|
||||
// Arm A: existing per-technician rows (every non-deleted vendor, including
|
||||
// those with no company). Notes come from the linked VendorCompany.
|
||||
var perTechnician = _context.Vendors
|
||||
.AsNoTracking()
|
||||
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
||||
|
||||
if (isActive.HasValue)
|
||||
perTechnician = perTechnician.Where(v => v.IsActive == isActive.Value);
|
||||
|
||||
// Pre-aggregate distinct work-order assignments per technician before
|
||||
// grouping companies. This keeps the outer company SUM over a plain
|
||||
// joined scalar instead of SUM(COUNT(correlated subquery)), which SQL
|
||||
// Server rejects at execution time.
|
||||
var directWorkOrders = _context.Dispatches
|
||||
.Where(dispatch => dispatch.WorkOrderId.HasValue)
|
||||
.Select(dispatch => new
|
||||
{
|
||||
dispatch.VendorId,
|
||||
WorkOrderId = dispatch.WorkOrderId!.Value
|
||||
});
|
||||
var linkedWorkOrders = _context.DispatchWorkOrders
|
||||
.Where(link => link.Dispatch != null)
|
||||
.Select(link => new
|
||||
{
|
||||
VendorId = link.Dispatch!.VendorId,
|
||||
link.WorkOrderId
|
||||
});
|
||||
var jobCounts = directWorkOrders
|
||||
.Union(linkedWorkOrders)
|
||||
.GroupBy(assignment => assignment.VendorId)
|
||||
.Select(group => new
|
||||
{
|
||||
VendorId = group.Key,
|
||||
TotalJobs = group.Count()
|
||||
});
|
||||
|
||||
var armA =
|
||||
from v in perTechnician
|
||||
join jobCount in jobCounts on v.Id equals jobCount.VendorId into vendorJobCounts
|
||||
from jobCount in vendorJobCounts.DefaultIfEmpty()
|
||||
select new VendorDirectoryRow
|
||||
{
|
||||
VendorId = v.Id,
|
||||
CompanyId = v.CompanyId ?? 0,
|
||||
CompanyName = v.Company != null ? v.Company.Name : v.CompanyName,
|
||||
ContactName = v.ContactName,
|
||||
Email = v.Email != null && v.Email.Trim() != ""
|
||||
? v.Email
|
||||
: v.Company != null
|
||||
? v.Company.Email
|
||||
: v.Email,
|
||||
Phone = v.Phone,
|
||||
CompanyPhone = v.Company != null &&
|
||||
v.Company.CompanyPhone != null && v.Company.CompanyPhone.Trim() != ""
|
||||
? v.Company.CompanyPhone
|
||||
: v.CompanyPhone,
|
||||
PreferredContact = v.PreferredContact,
|
||||
Address = v.Company != null &&
|
||||
v.Company.Address != null && v.Company.Address.Trim() != ""
|
||||
? v.Company.Address
|
||||
: v.Address,
|
||||
City = v.Company != null &&
|
||||
((v.Company.City != null && v.Company.City.Trim() != "") ||
|
||||
(v.Company.State != null && v.Company.State.Trim() != "") ||
|
||||
(v.Company.Address != null && v.Company.Address.Trim() != ""))
|
||||
? v.Company.City
|
||||
: v.City,
|
||||
State = v.Company != null &&
|
||||
((v.Company.City != null && v.Company.City.Trim() != "") ||
|
||||
(v.Company.State != null && v.Company.State.Trim() != "") ||
|
||||
(v.Company.Address != null && v.Company.Address.Trim() != ""))
|
||||
? v.Company.State
|
||||
: v.State,
|
||||
Zip = v.Zip,
|
||||
TradeSpecialties = v.TradeSpecialties,
|
||||
GoogleMapsUrl = v.GoogleMapsUrl,
|
||||
Notes = v.Company != null ? v.Company.Notes : null,
|
||||
IsActive = v.IsActive,
|
||||
TotalJobs = (int?)jobCount.TotalJobs ?? 0
|
||||
};
|
||||
|
||||
// Arm B: company-only rows for companies with zero non-deleted technicians.
|
||||
// These carry a null VendorId and a populated CompanyId. A company has no
|
||||
// active/inactive flag of its own, so it is shown unless the caller explicitly
|
||||
|
|
@ -272,15 +743,70 @@ namespace SeaHaven.DataServices.Implementation
|
|||
union = armA.Concat(armB);
|
||||
}
|
||||
|
||||
return ApplyDirectoryRowFilters(
|
||||
union,
|
||||
search,
|
||||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets);
|
||||
return union;
|
||||
}
|
||||
|
||||
// Same matching rules the directory has always applied to a projected row:
|
||||
// company name, contact name, email, trades, canonical city/state, the
|
||||
// canonical address only when city/state are both absent, and zip.
|
||||
private static Expression BuildSearchMatchBody(ParameterExpression row, string normalizedSearch)
|
||||
{
|
||||
Expression ContainsLower(string propertyName)
|
||||
{
|
||||
var lowered = Expression.Call(
|
||||
CoalescedOrEmpty(row, propertyName),
|
||||
nameof(string.ToLower),
|
||||
Type.EmptyTypes);
|
||||
return Contains(lowered, Expression.Constant(normalizedSearch));
|
||||
}
|
||||
|
||||
var cityBlank = IsBlank(Expression.Property(row, nameof(VendorDirectoryRow.City)));
|
||||
var stateBlank = IsBlank(Expression.Property(row, nameof(VendorDirectoryRow.State)));
|
||||
|
||||
Expression? body = null;
|
||||
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.CompanyName)));
|
||||
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.ContactName)));
|
||||
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.Email)));
|
||||
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.TradeSpecialties)));
|
||||
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.City)));
|
||||
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.State)));
|
||||
body = OrElse(body, Expression.AndAlso(
|
||||
Expression.AndAlso(cityBlank, stateBlank),
|
||||
ContainsLower(nameof(VendorDirectoryRow.Address))));
|
||||
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.Zip)));
|
||||
return body!;
|
||||
}
|
||||
|
||||
private static Expression BuildCompanyNameMatchBody(
|
||||
ParameterExpression row,
|
||||
IReadOnlyCollection<string> selectedCompanies)
|
||||
{
|
||||
var lowered = Expression.Call(
|
||||
CoalescedOrEmpty(row, nameof(VendorDirectoryRow.CompanyName)),
|
||||
nameof(string.ToLower),
|
||||
Type.EmptyTypes);
|
||||
return Expression.Call(
|
||||
typeof(Enumerable),
|
||||
nameof(Enumerable.Contains),
|
||||
new[] { typeof(string) },
|
||||
Expression.Constant(selectedCompanies),
|
||||
lowered);
|
||||
}
|
||||
|
||||
private static Expression CoalescedOrEmpty(ParameterExpression row, string propertyName) =>
|
||||
Expression.Coalesce(
|
||||
Expression.Property(row, propertyName),
|
||||
Expression.Constant(string.Empty));
|
||||
|
||||
private static Expression LowerTrimmed(ParameterExpression row, string propertyName) =>
|
||||
Expression.Call(
|
||||
Expression.Call(
|
||||
CoalescedOrEmpty(row, propertyName),
|
||||
nameof(string.Trim),
|
||||
Type.EmptyTypes),
|
||||
nameof(string.ToLower),
|
||||
Type.EmptyTypes);
|
||||
|
||||
private static IQueryable<VendorDirectoryRow> ApplyDirectoryRowFilters(
|
||||
IQueryable<VendorDirectoryRow> query,
|
||||
string? search,
|
||||
|
|
@ -463,6 +989,15 @@ namespace SeaHaven.DataServices.Implementation
|
|||
IReadOnlyCollection<string> selectedTrades)
|
||||
{
|
||||
var vendor = Expression.Parameter(typeof(T), "vendor");
|
||||
return Expression.Lambda<Func<T, bool>>(
|
||||
BuildTradeMatchBody<T>(vendor, selectedTrades),
|
||||
vendor);
|
||||
}
|
||||
|
||||
private static Expression BuildTradeMatchBody<T>(
|
||||
ParameterExpression vendor,
|
||||
IReadOnlyCollection<string> selectedTrades)
|
||||
{
|
||||
var specialties = Expression.Coalesce(
|
||||
Expression.Property(vendor, nameof(Vendor.TradeSpecialties)),
|
||||
Expression.Constant(string.Empty));
|
||||
|
|
@ -490,13 +1025,22 @@ namespace SeaHaven.DataServices.Implementation
|
|||
}
|
||||
}
|
||||
|
||||
return Expression.Lambda<Func<T, bool>>(body ?? Expression.Constant(false), vendor);
|
||||
return body ?? Expression.Constant(false);
|
||||
}
|
||||
|
||||
private static Expression<Func<T, bool>> BuildLocationPredicate<T>(
|
||||
IReadOnlyCollection<string> selectedLocations)
|
||||
{
|
||||
var vendor = Expression.Parameter(typeof(T), "vendor");
|
||||
return Expression.Lambda<Func<T, bool>>(
|
||||
BuildLocationMatchBody<T>(vendor, selectedLocations),
|
||||
vendor);
|
||||
}
|
||||
|
||||
private static Expression BuildLocationMatchBody<T>(
|
||||
ParameterExpression vendor,
|
||||
IReadOnlyCollection<string> selectedLocations)
|
||||
{
|
||||
var label = BuildLocationLabelBody(vendor);
|
||||
var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes);
|
||||
|
||||
|
|
@ -504,7 +1048,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
foreach (var location in selectedLocations)
|
||||
body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location)));
|
||||
|
||||
return Expression.Lambda<Func<T, bool>>(body ?? Expression.Constant(false), vendor);
|
||||
return body ?? Expression.Constant(false);
|
||||
}
|
||||
|
||||
private static Expression BuildLocationLabelBody(ParameterExpression vendor)
|
||||
|
|
|
|||
|
|
@ -35,9 +35,9 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null);
|
||||
|
||||
// SH-198 vendor directory: per-technician rows UNION company-only rows
|
||||
// (companies with zero non-deleted technicians). Company-only rows carry a
|
||||
// null VendorId and a populated CompanyId. Notes come from VendorCompany.Notes.
|
||||
// Compatibility path for existing per-technician consumers (GetVendorList)
|
||||
// and behavior tests. GetVendorDirectoryList uses the company-grouped path
|
||||
// below.
|
||||
Task<(IReadOnlyList<VendorDirectoryRow> Items, int TotalCount)> GetDirectoryPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
|
|
@ -48,6 +48,25 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// SH-281 company directory: linked vendors group by real CompanyId; unlinked
|
||||
// legacy vendors group by trimmed case-insensitive CompanyName (missing names
|
||||
// isolated by vendor identity); company-only groups remain. Facet filters
|
||||
// select a group when any status-eligible row matches; job buckets apply to
|
||||
// the summed per-technician TotalJobs. Bounded group keys are selected and
|
||||
// paginated in SQL, then the full status-eligible roster is fetched only for
|
||||
// those keys (search/trade/location are not reapplied). TotalCount counts
|
||||
// company groups.
|
||||
Task<(IReadOnlyList<VendorCompanyGroup> Items, int TotalCount)> GetCompanyDirectoryPagedAsync(
|
||||
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,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<Vendor> AddAsync(Vendor vendor);
|
||||
Task UpdateAsync(Vendor vendor);
|
||||
Task DeleteAsync(int id);
|
||||
|
|
|
|||
|
|
@ -20,4 +20,112 @@ namespace SeaHaven.DataServices.Models
|
|||
public bool IsActive { get; set; }
|
||||
public int TotalJobs { get; set; }
|
||||
}
|
||||
|
||||
// SH-281 company directory: per-row source for the grouped key query. Carries the
|
||||
// company group key parts (CompanyId 0 = unlinked; NameKey groups unlinked legacy
|
||||
// vendors by trimmed case-insensitive CompanyName; VendorKey isolates vendors with
|
||||
// a missing name by vendor identity) plus one 0/1 match flag per facet filter so a
|
||||
// group can be selected when any status-eligible row matches that facet.
|
||||
internal sealed class VendorDirectoryGroupSourceRow
|
||||
{
|
||||
public int? VendorId { get; set; }
|
||||
public int CompanyId { get; set; }
|
||||
public string NameKey { get; set; } = string.Empty;
|
||||
public int VendorKey { get; set; }
|
||||
public int TotalJobs { get; set; }
|
||||
public int SearchFlag { get; set; }
|
||||
public int CompanyFlag { get; set; }
|
||||
public int TradeFlag { get; set; }
|
||||
public int LocationFlag { get; set; }
|
||||
}
|
||||
|
||||
// SH-281: one row per company group after SQL grouping — the bounded key page.
|
||||
internal sealed class VendorCompanyGroupKeyRow
|
||||
{
|
||||
public int CompanyId { get; set; }
|
||||
public string NameKey { get; set; } = string.Empty;
|
||||
public int VendorKey { get; set; }
|
||||
public int? MaxVendorId { get; set; }
|
||||
public int TotalJobs { get; set; }
|
||||
public int SearchMatch { get; set; }
|
||||
public int CompanyMatch { get; set; }
|
||||
public int TradeMatch { get; set; }
|
||||
public int LocationMatch { get; set; }
|
||||
}
|
||||
|
||||
// SH-281: full status-eligible roster row (phase 2) carrying both the vendor's own
|
||||
// technician fields and the linked VendorCompany canonical fields, so group
|
||||
// assembly can apply the existing company-canonical projections in memory without
|
||||
// reapplying search/trade/location filters.
|
||||
internal sealed class VendorDirectoryRosterRow
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public int? CompanyId { get; set; }
|
||||
public string? CompanyName { get; set; }
|
||||
public string? CanonicalCompanyName { get; set; }
|
||||
public string? CompanyEmail { get; set; }
|
||||
public string? CompanyPhone { get; set; }
|
||||
public string? CompanyAddress { get; set; }
|
||||
public string? CompanyCity { get; set; }
|
||||
public string? CompanyState { get; set; }
|
||||
public string? CompanyNotes { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? PreferredContact { get; set; }
|
||||
public string? VendorCompanyPhone { 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 bool IsActive { get; set; }
|
||||
public int TotalJobs { get; set; }
|
||||
public string NameKey { get; set; } = string.Empty;
|
||||
public int VendorKey { get; set; }
|
||||
}
|
||||
|
||||
// SH-281: one technician inside a company group.
|
||||
public sealed class VendorCompanyGroupTechnician
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? PreferredContact { get; set; }
|
||||
public string? TradeSpecialties { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int TotalJobs { get; set; }
|
||||
}
|
||||
|
||||
// SH-281 company directory group: linked vendors collapse by real CompanyId;
|
||||
// unlinked legacy vendors group by trimmed case-insensitive CompanyName (vendors
|
||||
// with a missing name stay isolated by vendor identity); companies with zero
|
||||
// non-deleted technicians remain as company-only groups. Top-level technician
|
||||
// fields come from the primary technician (newest VendorId, promoted when a
|
||||
// nonblank search matches a non-primary technician); company fields are
|
||||
// company-canonical as the existing projections intend; TotalJobs is the sum of
|
||||
// every status-eligible technician's distinct work-order count.
|
||||
public sealed class VendorCompanyGroup
|
||||
{
|
||||
public int? Id { get; set; }
|
||||
public int? CompanyId { get; set; }
|
||||
public string? CompanyName { 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 string? GoogleMapsUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int TotalJobs { get; set; }
|
||||
public IReadOnlyList<VendorCompanyGroupTechnician> Technicians { get; set; } = Array.Empty<VendorCompanyGroupTechnician>();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,13 @@ namespace SeaHaven.Services.DTOs
|
|||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
// SH-198 vendor directory row. Id is the nullable vendor id (null for company-only
|
||||
// rows that have zero technicians); CompanyId is always populated.
|
||||
// Shared directory row. On the SH-198 legacy per-technician path (GetVendorList)
|
||||
// Id is the technician's own vendor id and Technicians stays empty — the legacy
|
||||
// response shape does not project it. On the SH-281 company-grouped path
|
||||
// (GetVendorDirectoryList) Id is the primary technician's vendor id (null for
|
||||
// company-only groups that have zero technicians); CompanyId is populated for
|
||||
// linked and company-only groups. Technicians carries the full status-eligible
|
||||
// roster of the company group.
|
||||
public class VendorDirectoryItemDTO
|
||||
{
|
||||
public int? Id { get; set; }
|
||||
|
|
@ -42,6 +47,20 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? Notes { get; set; }
|
||||
public int TotalJobs { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public List<VendorDirectoryTechnicianDTO> Technicians { get; set; } = new();
|
||||
}
|
||||
|
||||
// SH-281: one technician inside a company group; Email/Phone stay technician-owned.
|
||||
public class VendorDirectoryTechnicianDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? PreferredContact { get; set; }
|
||||
public string? TradeSpecialties { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int TotalJobs { get; set; }
|
||||
}
|
||||
|
||||
public class CreateVendorDTO
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ namespace SeaHaven.Services.Implementation
|
|||
{
|
||||
public class VendorService : IVendorService
|
||||
{
|
||||
private const int MaxDirectoryPageSize = 100;
|
||||
private static readonly string[] TerminalLegacyStatuses =
|
||||
{ "completed", "cancelled", "canceled" };
|
||||
|
||||
|
|
@ -94,7 +95,10 @@ namespace SeaHaven.Services.Implementation
|
|||
};
|
||||
}
|
||||
|
||||
public async Task<PagedResult<VendorDirectoryItemDTO>> GetVendorDirectoryPagedAsync(
|
||||
// SH-198 legacy per-technician directory contract (GetVendorList): raw
|
||||
// pagination semantics are part of the compatibility surface, so pageSize
|
||||
// is forwarded unclamped.
|
||||
public async Task<PagedResult<VendorDirectoryItemDTO>> GetVendorTechnicianDirectoryPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search = null,
|
||||
|
|
@ -146,6 +150,74 @@ namespace SeaHaven.Services.Implementation
|
|||
TotalJobs = row.TotalJobs
|
||||
};
|
||||
|
||||
// SH-281 company-grouped directory (GetVendorDirectoryList).
|
||||
public async Task<PagedResult<VendorDirectoryItemDTO>> GetVendorCompanyDirectoryPagedAsync(
|
||||
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,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedPage = Math.Max(1, page);
|
||||
var normalizedPageSize = Math.Clamp(pageSize, 1, MaxDirectoryPageSize);
|
||||
var (items, totalCount) = await _vendorDataService.GetCompanyDirectoryPagedAsync(
|
||||
normalizedPage,
|
||||
normalizedPageSize,
|
||||
search,
|
||||
isActive,
|
||||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets,
|
||||
cancellationToken);
|
||||
|
||||
return new PagedResult<VendorDirectoryItemDTO>
|
||||
{
|
||||
Items = items.Select(MapToCompanyGroup).ToList(),
|
||||
TotalCount = totalCount,
|
||||
Page = normalizedPage,
|
||||
PageSize = normalizedPageSize
|
||||
};
|
||||
}
|
||||
|
||||
private static VendorDirectoryItemDTO MapToCompanyGroup(VendorCompanyGroup group) => new()
|
||||
{
|
||||
Id = group.Id,
|
||||
CompanyId = group.CompanyId,
|
||||
CompanyName = group.CompanyName,
|
||||
ContactName = group.ContactName,
|
||||
Email = group.Email,
|
||||
Phone = group.Phone,
|
||||
CompanyPhone = group.CompanyPhone,
|
||||
PreferredContact = group.PreferredContact,
|
||||
Address = group.Address,
|
||||
City = group.City,
|
||||
State = group.State,
|
||||
Zipcode = group.Zip,
|
||||
TradeSpecialties = group.TradeSpecialties,
|
||||
GoogleMapsUrl = group.GoogleMapsUrl,
|
||||
Notes = group.Notes,
|
||||
IsActive = group.IsActive,
|
||||
TotalJobs = group.TotalJobs,
|
||||
Technicians = group.Technicians.Select(MapToTechnician).ToList()
|
||||
};
|
||||
|
||||
private static VendorDirectoryTechnicianDTO MapToTechnician(VendorCompanyGroupTechnician technician) => new()
|
||||
{
|
||||
Id = technician.Id,
|
||||
ContactName = technician.ContactName,
|
||||
Email = technician.Email,
|
||||
Phone = technician.Phone,
|
||||
PreferredContact = technician.PreferredContact,
|
||||
TradeSpecialties = technician.TradeSpecialties,
|
||||
IsActive = technician.IsActive,
|
||||
TotalJobs = technician.TotalJobs
|
||||
};
|
||||
|
||||
public async Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId)
|
||||
{
|
||||
NormalizePhoneFields(dto);
|
||||
|
|
|
|||
|
|
@ -17,8 +17,22 @@ namespace SeaHaven.Services.Interfaces
|
|||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null);
|
||||
|
||||
// SH-198 vendor directory: per-technician rows UNION company-only rows.
|
||||
Task<PagedResult<VendorDirectoryItemDTO>> GetVendorDirectoryPagedAsync(
|
||||
// SH-198 legacy vendor directory contract backing GetVendorList: one row per
|
||||
// technician (per-technician rows UNION company-only rows), raw pagination.
|
||||
Task<PagedResult<VendorDirectoryItemDTO>> GetVendorTechnicianDirectoryPagedAsync(
|
||||
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,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// SH-281 company directory backing GetVendorDirectoryList: company-grouped
|
||||
// rows with nested technicians and bounded page sizes.
|
||||
Task<PagedResult<VendorDirectoryItemDTO>> GetVendorCompanyDirectoryPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search = null,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue