diff --git a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs index e0df2a9..99b7d1d 100644 --- a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs @@ -39,7 +39,7 @@ public class VendorControllerTests var service = new Mock(); 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?>(), It.IsAny?>(), token), Times.Once); + service.Verify(x => x.GetVendorCompanyDirectoryPagedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetVendorList_ReturnsIndividualTechnicianRowsWithoutGroupedTechnicians() + { + var service = new Mock(); + service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(new PagedResult + { + Items = new List + { + 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().Subject; + var paged = ok.Value.Should().BeOfType().Subject; + var rows = ((System.Collections.IEnumerable)paged.Data!).Cast().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(); + service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync( + 1, + 500, + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(new PagedResult { TotalCount = 420, Page = 1, PageSize = 500 }); + + var result = await NewController(service).GetVendorList(page: 1, pageSize: 500); + + var ok = result.Should().BeOfType().Subject; + var paged = ok.Value.Should().BeOfType().Subject; + paged.PageSize.Should().Be(500); + paged.TotalPages.Should().Be(1); + service.VerifyAll(); + } + + [Fact] + public async Task GetVendorDirectoryList_DelegatesToCompanyDirectoryServiceWithNestedTechnicians() + { + var service = new Mock(); + using var cancellation = new CancellationTokenSource(); + var token = cancellation.Token; + service.Setup(x => x.GetVendorCompanyDirectoryPagedAsync( + 1, + 10, + "clayton", + true, + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + token)) + .ReturnsAsync(new PagedResult + { + Items = new List + { + new() + { + Id = 9, + CompanyId = 3, + CompanyName = "Gateway Plumbing", + Email = "company@gatewayplumbing.example", + TotalJobs = 6, + Technicians = new List + { + 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().Subject; + var paged = ok.Value.Should().BeOfType().Subject; + var company = ((System.Collections.IEnumerable)paged.Data!).Cast().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().Select(t => t.GetType().GetProperty("Id")!.GetValue(t)) + .Should().BeEquivalentTo(new[] { 7, 9 }); + technicians.Cast().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?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + token), Times.Once); + service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny()), Times.Never); + } + + [Fact] + public void VendorDirectoryEndpoints_AdvertiseDistinctCompatibilityAndCompanyRoutes() + { + var legacyRoutes = typeof(VendorController) + .GetMethod(nameof(VendorController.GetVendorList))! + .GetCustomAttributes(typeof(HttpGetAttribute), inherit: false) + .Cast() + .Select(attribute => attribute.Template); + var companyRoutes = typeof(VendorController) + .GetMethod(nameof(VendorController.GetVendorDirectoryList))! + .GetCustomAttributes(typeof(HttpGetAttribute), inherit: false) + .Cast() + .Select(attribute => attribute.Template); + + legacyRoutes.Should().BeEquivalentTo("GetVendorList"); + companyRoutes.Should().BeEquivalentTo("GetVendorDirectoryList"); } [Fact] 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..a8e996d 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -152,12 +152,12 @@ public class VendorServiceTests } [Fact] - public async Task GetVendorDirectoryPaged_ForwardsCancellationToken() + public async Task GetVendorCompanyDirectoryPaged_ForwardsCancellationTokenAndMapsNestedTechnicians() { 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,43 @@ 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", + 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(); + 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).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(); + using var cancellation = new CancellationTokenSource(); + var token = cancellation.Token; + data.Setup(x => x.GetDirectoryPagedAsync( + 2, + 500, + "clayton", + true, + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + 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?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + token), Times.Once); + } + [Fact] public async Task GetVendorById_CountsDistinctHistoricalWorkOrders() { diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index 1051fcf..fc42b88 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -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 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 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 GetVendorById([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId) diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index ca6eed2..3c5e70a 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -168,7 +168,188 @@ 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, @@ -176,52 +357,195 @@ namespace SeaHaven.DataServices.Implementation IReadOnlyCollection? locations = null, IReadOnlyCollection? 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> 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) - 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> 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. + 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 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..2e08912 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -35,9 +35,9 @@ 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 (GetVendorList) + // and behavior tests. GetVendorDirectoryList uses the company-grouped path + // below. Task<(IReadOnlyList Items, int TotalCount)> GetDirectoryPagedAsync( int page, int pageSize, @@ -48,6 +48,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..12fb5b8 100644 --- a/SeaHaven.Services/DTOs/VendorDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorDTOs.cs @@ -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 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..5e4ce40 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" }; @@ -94,7 +95,10 @@ namespace SeaHaven.Services.Implementation }; } - public async Task> 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> 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> GetVendorCompanyDirectoryPagedAsync( + 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) + { + 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 + { + 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 CreateVendorAsync(CreateVendorDTO dto, string userId) { NormalizePhoneFields(dto); diff --git a/SeaHaven.Services/Interfaces/IVendorService.cs b/SeaHaven.Services/Interfaces/IVendorService.cs index 68e8ae9..74782af 100644 --- a/SeaHaven.Services/Interfaces/IVendorService.cs +++ b/SeaHaven.Services/Interfaces/IVendorService.cs @@ -17,8 +17,22 @@ namespace SeaHaven.Services.Interfaces IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null); - // SH-198 vendor directory: per-technician rows UNION company-only rows. - Task> GetVendorDirectoryPagedAsync( + // SH-198 legacy vendor directory contract backing GetVendorList: one row per + // technician (per-technician rows UNION company-only rows), raw pagination. + Task> GetVendorTechnicianDirectoryPagedAsync( + 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); + + // SH-281 company directory backing GetVendorDirectoryList: company-grouped + // rows with nested technicians and bounded page sizes. + Task> GetVendorCompanyDirectoryPagedAsync( int page, int pageSize, string? search = null,