diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs index 3473085..7e7716a 100644 --- a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -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 { 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 { 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 { new() { WorkOrderId = 201 } } + }; + var newer = new Vendor + { + Company = company, + CompanyName = company.Name, + ContactName = "Zed HVAC", + TradeSpecialties = "HVAC", + IsActive = true, + IsDeleted = false, + Dispatches = new List { new() { WorkOrderId = 202 } } + }; + var inactive = new Vendor + { + Company = company, + CompanyName = company.Name, + ContactName = "Inactive Tech", + TradeSpecialties = "Roofing", + IsActive = false, + IsDeleted = false, + Dispatches = new List { 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() { diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index 42c22bf..f769c5e 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -157,7 +157,7 @@ public class VendorServiceTests var data = new Mock(); 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,40 @@ public class VendorServiceTests It.IsAny?>(), It.IsAny?>(), token)) - .ReturnsAsync((Array.Empty(), 0)); + .ReturnsAsync((new[] + { + new VendorCompanyGroup + { + Id = 7, + CompanyId = 3, + CompanyName = "Gateway Plumbing", + ContactName = "Primary Tech", + 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).GetVendorDirectoryPagedAsync( 1, 12, "clayton", true, cancellationToken: token); - data.Verify(x => x.GetDirectoryPagedAsync( + result.Items.Single().Technicians.Should().ContainSingle() + .Which.Email.Should().Be("primary@example.com"); + + data.Verify(x => x.GetCompanyDirectoryPagedAsync( 1, 12, "clayton", @@ -188,6 +212,29 @@ public class VendorServiceTests token), Times.Once); } + [Fact] + public async Task GetVendorDirectoryPaged_BoundsPageInputsBeforeBuildingTheGroupKeyPredicate() + { + var data = new Mock(); + data.Setup(x => x.GetCompanyDirectoryPagedAsync( + 1, + 100, + null, + true, + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync((new List(), 0)); + + var result = await NewService(data).GetVendorDirectoryPagedAsync(0, 500); + + result.Page.Should().Be(1); + result.PageSize.Should().Be(100); + data.VerifyAll(); + } + [Fact] public async Task GetVendorById_CountsDistinctHistoricalWorkOrders() { diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index 1051fcf..628c25c 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -67,16 +67,27 @@ namespace Api.SeaHavenIndustries.Controllers v.GoogleMapsUrl, v.Notes, v.IsActive, - v.TotalJobs + 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 = page, - PageSize = pageSize, + PageNumber = pagedResult.Page, + PageSize = pagedResult.PageSize, TotalCount = pagedResult.TotalCount, - TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize) + TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pagedResult.PageSize) }); } diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index ca6eed2..2e1b202 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -168,13 +168,462 @@ namespace SeaHaven.DataServices.Implementation return (items, totalCount); } - internal IQueryable BuildDirectoryUnionQuery( + public async Task<(IReadOnlyList Items, int TotalCount)> GetCompanyDirectoryPagedAsync( + int page, + int pageSize, + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? 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(), 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() + : 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(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 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 techniciansNewestFirst) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var tokens = new List(); + 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() + }; + + internal IQueryable BuildCompanyDirectoryKeysQuery( string? search = null, bool? isActive = true, IReadOnlyCollection? companies = null, IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null) + { + 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> BuildGroupSourceRowSelector( + string? search, + IReadOnlyCollection? companies, + IReadOnlyCollection? trades, + IReadOnlyCollection? 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(row, selectedTrades) + : null; + var locationBody = selectedLocations is { Length: > 0 } + ? BuildLocationMatchBody(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>( + 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 BuildCompanyDirectoryRosterQuery( + bool? isActive, + IReadOnlyList keys) + { + var vendors = _context.Vendors + .AsNoTracking() + .Where(v => v.IsDeleted == null || v.IsDeleted == false); + + if (isActive.HasValue) + vendors = vendors.Where(v => v.IsActive == isActive.Value); + + var rows = vendors.Select(v => new VendorDirectoryRosterRow + { + VendorId = v.Id, + 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, + Phone = v.Phone, + PreferredContact = v.PreferredContact, + VendorCompanyPhone = v.CompanyPhone, + Address = v.Address, + City = v.City, + State = v.State, + Zip = v.Zip, + TradeSpecialties = v.TradeSpecialties, + GoogleMapsUrl = v.GoogleMapsUrl, + IsActive = v.IsActive, + TotalJobs = _context.Dispatches + .Where(dispatch => + dispatch.VendorId == v.Id && + dispatch.WorkOrderId.HasValue) + .Select(dispatch => dispatch.WorkOrderId!.Value) + .Union( + _context.DispatchWorkOrders + .Where(link => link.Dispatch != null && link.Dispatch.VendorId == v.Id) + .Select(link => link.WorkOrderId)) + .Count(), + 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> BuildRosterKeyPredicate( + IReadOnlyList 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>( + body ?? Expression.Constant(false), + row); + } + + internal IQueryable BuildDirectoryUnionQuery( + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null) => + ApplyDirectoryRowFilters( + BuildDirectoryUnionBaseQuery(isActive), + search, + companies, + trades, + locations, + jobBuckets); + + private IQueryable 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. @@ -185,11 +634,42 @@ namespace SeaHaven.DataServices.Implementation if (isActive.HasValue) perTechnician = perTechnician.Where(v => v.IsActive == isActive.Value); - var armA = perTechnician.Select(v => new VendorDirectoryRow + // 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.CompanyName, + CompanyName = v.Company != null ? v.Company.Name : v.CompanyName, ContactName = v.ContactName, Email = v.Email != null && v.Email.Trim() != "" ? v.Email @@ -223,17 +703,8 @@ namespace SeaHaven.DataServices.Implementation GoogleMapsUrl = v.GoogleMapsUrl, Notes = v.Company != null ? v.Company.Notes : null, IsActive = v.IsActive, - TotalJobs = _context.Dispatches - .Where(dispatch => - dispatch.VendorId == v.Id && - dispatch.WorkOrderId.HasValue) - .Select(dispatch => dispatch.WorkOrderId!.Value) - .Union( - _context.DispatchWorkOrders - .Where(link => link.Dispatch != null && link.Dispatch.VendorId == v.Id) - .Select(link => link.WorkOrderId)) - .Count() - }); + 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 @@ -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 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 ApplyDirectoryRowFilters( IQueryable query, string? search, @@ -463,6 +989,15 @@ namespace SeaHaven.DataServices.Implementation IReadOnlyCollection selectedTrades) { var vendor = Expression.Parameter(typeof(T), "vendor"); + return Expression.Lambda>( + BuildTradeMatchBody(vendor, selectedTrades), + vendor); + } + + private static Expression BuildTradeMatchBody( + ParameterExpression vendor, + IReadOnlyCollection 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>(body ?? Expression.Constant(false), vendor); + return body ?? Expression.Constant(false); } private static Expression> BuildLocationPredicate( IReadOnlyCollection selectedLocations) { var vendor = Expression.Parameter(typeof(T), "vendor"); + return Expression.Lambda>( + BuildLocationMatchBody(vendor, selectedLocations), + vendor); + } + + private static Expression BuildLocationMatchBody( + ParameterExpression vendor, + IReadOnlyCollection 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>(body ?? Expression.Constant(false), vendor); + return body ?? Expression.Constant(false); } private static Expression BuildLocationLabelBody(ParameterExpression vendor) diff --git a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index 069519a..7725b6d 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -35,9 +35,8 @@ namespace SeaHaven.DataServices.Interfaces IReadOnlyCollection? locations = null, IReadOnlyCollection? 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 and behavior + // tests. Vendor/GetVendorList uses the company-grouped path below. Task<(IReadOnlyList Items, int TotalCount)> GetDirectoryPagedAsync( int page, int pageSize, @@ -48,6 +47,25 @@ namespace SeaHaven.DataServices.Interfaces IReadOnlyCollection? locations = null, IReadOnlyCollection? 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 Items, int TotalCount)> GetCompanyDirectoryPagedAsync( + int page, + int pageSize, + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null, + CancellationToken cancellationToken = default); Task AddAsync(Vendor vendor); Task UpdateAsync(Vendor vendor); Task DeleteAsync(int id); diff --git a/SeaHaven.DataServices/Models/VendorDirectoryReadModels.cs b/SeaHaven.DataServices/Models/VendorDirectoryReadModels.cs index 763f020..dff7be9 100644 --- a/SeaHaven.DataServices/Models/VendorDirectoryReadModels.cs +++ b/SeaHaven.DataServices/Models/VendorDirectoryReadModels.cs @@ -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 Technicians { get; set; } = Array.Empty(); + } } diff --git a/SeaHaven.Services/DTOs/VendorDTOs.cs b/SeaHaven.Services/DTOs/VendorDTOs.cs index bf30e27..afdd03b 100644 --- a/SeaHaven.Services/DTOs/VendorDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorDTOs.cs @@ -21,8 +21,10 @@ 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. + // SH-281 company directory row. 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 +44,20 @@ namespace SeaHaven.Services.DTOs public string? Notes { get; set; } public int TotalJobs { get; set; } public bool IsActive { get; set; } + public List 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 diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index c2ac206..d1b14e2 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -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" }; @@ -105,9 +106,11 @@ namespace SeaHaven.Services.Implementation IReadOnlyCollection? jobBuckets = null, CancellationToken cancellationToken = default) { - var (items, totalCount) = await _vendorDataService.GetDirectoryPagedAsync( - page, - pageSize, + var normalizedPage = Math.Max(1, page); + var normalizedPageSize = Math.Clamp(pageSize, 1, MaxDirectoryPageSize); + var (items, totalCount) = await _vendorDataService.GetCompanyDirectoryPagedAsync( + normalizedPage, + normalizedPageSize, search, isActive, companies, @@ -118,32 +121,45 @@ namespace SeaHaven.Services.Implementation return new PagedResult { - Items = items.Select(MapToDirectoryItem).ToList(), + Items = items.Select(MapToCompanyGroup).ToList(), TotalCount = totalCount, - Page = page, - PageSize = pageSize + Page = normalizedPage, + PageSize = normalizedPageSize }; } - private static VendorDirectoryItemDTO MapToDirectoryItem(VendorDirectoryRow row) => new() + private static VendorDirectoryItemDTO MapToCompanyGroup(VendorCompanyGroup group) => new() { - Id = row.VendorId, - CompanyId = row.CompanyId == 0 ? null : row.CompanyId, - CompanyName = row.CompanyName, - ContactName = row.ContactName, - Email = row.Email, - Phone = row.Phone, - CompanyPhone = row.CompanyPhone, - PreferredContact = row.PreferredContact, - Address = row.Address, - City = row.City, - State = row.State, - Zipcode = row.Zip, - TradeSpecialties = row.TradeSpecialties, - GoogleMapsUrl = row.GoogleMapsUrl, - Notes = row.Notes, - IsActive = row.IsActive, - TotalJobs = row.TotalJobs + 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 CreateVendorAsync(CreateVendorDTO dto, string userId)