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:
Alexandre Brandizzi 2026-09-15 16:02:44 -03:00 • committed by GitHub
parent eb9b7eb50e
commit 67089c2135
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1329 additions and 60 deletions

View file

@ -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]

View file

@ -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()
{

View file

@ -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()
{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,