From fc2870869fda8883fd5896d4acd7056cf1b320a7 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Mon, 3 Aug 2026 14:41:49 -0300 Subject: [PATCH] fix(vendors): source facets from companies --- .../VendorDataServiceTests.cs | 103 ++++++++++++++++++ .../VendorServiceTests.cs | 98 +++++++++++++++++ .../Implementation/VendorDataService.cs | 33 ++++++ .../Interfaces/IVendorDataService.cs | 7 ++ .../Implementation/VendorService.cs | 48 ++++---- 5 files changed, 267 insertions(+), 22 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs index 3193a77..634443a 100644 --- a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -391,4 +391,107 @@ public class VendorDataServiceTests if (filter == "job-bucket") sql.Should().Contain("TotalJobs"); } + + [Fact] + public async Task GetCompaniesForFacetsAsync_IncludesZeroTechnicianCompanyForActiveAndNullStatus() + { + await using var context = NewContext(); + var rosterService = new VendorCompanyRosterDataService(context); + await rosterService.CreateRosterAsync(new VendorCompanyRosterWriteModel + { + Name = "Solo Co", + Email = "solo@example.com", + ActorUserId = "42", + Technicians = new List() + }, CancellationToken.None); + + var techCompany = new VendorCompany { Name = "Tech Co", NormalizedName = "tech co" }; + context.VendorCompanies.Add(techCompany); + context.Vendors.Add(new Vendor + { + CompanyName = "Tech Co", + ContactName = "Pat", + IsActive = true, + IsDeleted = false, + Company = techCompany + }); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + + var active = await service.GetCompaniesForFacetsAsync(true, CancellationToken.None); + active.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Solo Co", "Tech Co" }); + + var noStatus = await service.GetCompaniesForFacetsAsync(null, CancellationToken.None); + noStatus.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Solo Co", "Tech Co" }); + + var inactive = await service.GetCompaniesForFacetsAsync(false, CancellationToken.None); + inactive.Should().BeEmpty(); + } + + [Fact] + public async Task GetCompaniesForFacetsAsync_InactiveStatusReturnsOnlyCompaniesWithInactiveTechnicians() + { + await using var context = NewContext(); + var activeOnlyCo = new VendorCompany { Name = "Active Only Co", NormalizedName = "active only co" }; + var inactiveOnlyCo = new VendorCompany { Name = "Inactive Only Co", NormalizedName = "inactive only co" }; + var mixedCo = new VendorCompany { Name = "Mixed Co", NormalizedName = "mixed co" }; + context.VendorCompanies.AddRange(activeOnlyCo, inactiveOnlyCo, mixedCo); + context.Vendors.AddRange( + new Vendor { CompanyName = "Active Only Co", IsActive = true, IsDeleted = false, Company = activeOnlyCo }, + new Vendor { CompanyName = "Inactive Only Co", IsActive = false, IsDeleted = false, Company = inactiveOnlyCo }, + new Vendor { CompanyName = "Mixed Co", IsActive = true, IsDeleted = false, Company = mixedCo }, + new Vendor { CompanyName = "Mixed Co", IsActive = false, IsDeleted = false, Company = mixedCo }); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + + var inactive = await service.GetCompaniesForFacetsAsync(false, CancellationToken.None); + inactive.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Inactive Only Co", "Mixed Co" }); + + var active = await service.GetCompaniesForFacetsAsync(true, CancellationToken.None); + active.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Active Only Co", "Mixed Co" }); + + var noStatus = await service.GetCompaniesForFacetsAsync(null, CancellationToken.None); + noStatus.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Active Only Co", "Inactive Only Co", "Mixed Co" }); + } + + [Fact] + public async Task GetCompaniesForFacetsAsync_TreatsCompanyWithOnlyDeletedTechniciansAsZeroTechnician() + { + await using var context = NewContext(); + var company = new VendorCompany { Name = "Ghost Co", NormalizedName = "ghost co" }; + context.VendorCompanies.Add(company); + context.Vendors.Add(new Vendor + { + CompanyName = "Ghost Co", + IsActive = true, + IsDeleted = true, + Company = company + }); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + + var active = await service.GetCompaniesForFacetsAsync(true, CancellationToken.None); + active.Single().Name.Should().Be("Ghost Co"); + + var inactive = await service.GetCompaniesForFacetsAsync(false, CancellationToken.None); + inactive.Should().BeEmpty(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [InlineData(null)] + public void BuildCompaniesForFacetsQuery_TranslatesToSqlServerWithoutClientEvaluation(bool? isActive) + { + using var context = NewSqlServerContext(); + var service = new VendorDataService(context); + + var sql = service.BuildCompaniesForFacetsQuery(isActive).ToQueryString(); + + sql.Should().Contain("SELECT"); + sql.Should().Contain("VendorCompanies"); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index 4ecb88c..2823fa6 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -552,4 +552,102 @@ public class VendorServiceTests .WithMessage("*404*not found"); data.Verify(x => x.GetLinkedWorkOrdersAsync(It.IsAny()), Times.Never); } + + [Fact] + public async Task GetFacetsAsync_CompaniesAndLocationsUseCompanyOwnedFieldsOverTechnicianFields() + { + var data = new Mock(); + data.Setup(x => x.GetCompaniesForFacetsAsync(true, It.IsAny())) + .ReturnsAsync(new List + { + new() + { + Id = 1, + Name = "Owner Co", + CompanyPhone = "(555) 000-0001", + Email = "owner@example.com", + Address = "Owner Addr", + City = "OwnerCity", + State = "OS", + Zip = "00001", + GoogleMapsUrl = "https://owner.example/map" + } + }); + data.Setup(x => x.GetVendorsForFacetsAsync(true, It.IsAny())) + .ReturnsAsync(new List + { + new() + { + Id = 10, + CompanyName = "Owner Co", + ContactName = "Pat", + City = "TechCity", + State = "TS", + Email = "tech@example.com", + CompanyPhone = "(555) 000-9999", + TradeSpecialties = "Plumbing" + } + }); + + var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None); + + var company = facets.Companies.Single(); + company.Name.Should().Be("Owner Co"); + company.CompanyId.Should().Be(1); + company.CompanyPhone.Should().Be("(555) 000-0001"); + company.Email.Should().Be("owner@example.com"); + company.Address.Should().Be("Owner Addr"); + company.City.Should().Be("OwnerCity"); + company.State.Should().Be("OS"); + company.Zip.Should().Be("00001"); + company.GoogleMapsUrl.Should().Be("https://owner.example/map"); + company.PreferredContact.Should().BeNull(); + + var location = facets.Locations.Single(); + location.City.Should().Be("OwnerCity"); + location.State.Should().Be("OS"); + location.Label.Should().Be("OwnerCity, OS"); + + facets.Trades.Should().BeEquivalentTo(new[] { "Plumbing" }); + } + + [Fact] + public async Task GetFacetsAsync_CompaniesAreUniqueAndOrderedCaseInsensitively() + { + var data = new Mock(); + data.Setup(x => x.GetCompaniesForFacetsAsync(null, It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = 1, Name = "Beta Co" }, + new() { Id = 2, Name = "alpha co" }, + new() { Id = 3, Name = "Alpha Co" } + }); + data.Setup(x => x.GetVendorsForFacetsAsync(null, It.IsAny())) + .ReturnsAsync(new List()); + + var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None); + + // Case-insensitive dedup collapses "alpha co"/"Alpha Co" to one entry, and + // ordering places "alpha co" before "Beta Co" (case-insensitive, not Ordinal). + facets.Companies.Select(c => c.Name).Should().BeEquivalentTo( + new[] { "alpha co", "Beta Co" }, + opts => opts.WithStrictOrdering()); + } + + [Fact] + public async Task GetFacetsAsync_TradesRemainDerivedFromTechniciansAndIgnoreCompanies() + { + var data = new Mock(); + data.Setup(x => x.GetCompaniesForFacetsAsync(true, It.IsAny())) + .ReturnsAsync(new List { new() { Id = 1, Name = "No Trade Co" } }); + data.Setup(x => x.GetVendorsForFacetsAsync(true, It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = 20, CompanyName = "Trade Co", TradeSpecialties = "HVAC, Roofing" } + }); + + var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None); + + facets.Trades.Should().BeEquivalentTo(new[] { "HVAC", "Roofing" }, opts => opts.WithStrictOrdering()); + } } diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index 999447a..670ce1c 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -80,6 +80,39 @@ namespace SeaHaven.DataServices.Implementation .ToListAsync(cancellationToken); } + internal IQueryable BuildCompaniesForFacetsQuery(bool? isActive) + { + var query = _context.VendorCompanies + .AsNoTracking(); + + if (isActive == true) + { + // Active: companies with zero non-deleted technicians (company-only rows) + // or at least one active non-deleted technician. + query = query.Where(c => + !_context.Vendors.Any(v => + v.CompanyId == c.Id && (v.IsDeleted == null || v.IsDeleted == false)) + || _context.Vendors.Any(v => + v.CompanyId == c.Id + && (v.IsDeleted == null || v.IsDeleted == false) + && v.IsActive)); + } + else if (isActive == false) + { + // Inactive: companies with at least one inactive non-deleted technician. + query = query.Where(c => + _context.Vendors.Any(v => + v.CompanyId == c.Id + && (v.IsDeleted == null || v.IsDeleted == false) + && !v.IsActive)); + } + + return query.OrderBy(c => c.Id); + } + + public async Task> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken) + => await BuildCompaniesForFacetsQuery(isActive).ToListAsync(cancellationToken); + public async Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( int page, int pageSize, diff --git a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index 1fef788..c632c75 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -14,6 +14,13 @@ namespace SeaHaven.DataServices.Interfaces Task> GetAllAsync(); Task> GetActiveVendorsAsync(CancellationToken cancellationToken); Task> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken); + + // SH-198 facet fix: company-owned facet profiles sourced from VendorCompanies, + // with status semantics aligned to BuildDirectoryUnionQuery. Null returns all + // companies; active includes companies with zero non-deleted technicians or at + // least one active non-deleted technician; inactive includes companies with at + // least one inactive non-deleted technician. + Task> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken); Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( int page, int pageSize, diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index 20036c5..fe82c09 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -622,25 +622,29 @@ namespace SeaHaven.Services.Implementation public async Task GetFacetsAsync(bool? isActive, CancellationToken cancellationToken) { + // Companies and Locations are sourced from company-owned VendorCompany fields + // (status semantics aligned to the directory union); Trades remain derived from + // non-deleted technician Vendors. + var companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken); var vendors = await _vendorDataService.GetVendorsForFacetsAsync(isActive, cancellationToken); - var companies = vendors - .Where(v => !string.IsNullOrWhiteSpace(v.CompanyName)) - .GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase) + var companyFacets = companies + .Where(c => !string.IsNullOrWhiteSpace(c.Name)) + .GroupBy(c => c.Name!.Trim(), StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) - .OrderBy(v => v.CompanyName) - .Select(v => new VendorFacetCompanyDTO + .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .Select(c => new VendorFacetCompanyDTO { - Name = v.CompanyName, - CompanyId = v.CompanyId, - CompanyPhone = v.CompanyPhone, - Email = v.Email, - PreferredContact = v.PreferredContact, - Address = v.Address, - City = v.City, - State = v.State, - Zip = v.Zip, - GoogleMapsUrl = v.GoogleMapsUrl + Name = c.Name, + CompanyId = c.Id, + CompanyPhone = c.CompanyPhone, + Email = c.Email, + PreferredContact = null, + Address = c.Address, + City = c.City, + State = c.State, + Zip = c.Zip, + GoogleMapsUrl = c.GoogleMapsUrl }); var trades = vendors @@ -649,20 +653,20 @@ namespace SeaHaven.Services.Implementation .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(value => value); - var locations = vendors - .Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State)) - .Select(v => new VendorFacetLocationDTO + var locations = companies + .Where(c => !string.IsNullOrWhiteSpace(c.City) || !string.IsNullOrWhiteSpace(c.State)) + .Select(c => new VendorFacetLocationDTO { - City = v.City, - State = v.State, - Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value))) + City = c.City, + State = c.State, + Label = string.Join(", ", new[] { c.City, c.State }.Where(value => !string.IsNullOrWhiteSpace(value))) }) .DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase) .OrderBy(location => location.Label); return new VendorFacetsDTO { - Companies = companies, + Companies = companyFacets, Trades = trades, Locations = locations, JobBuckets = new[]