From bf7d1fcdf5485af77045cea934dc9f78f416cf3c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 25 Aug 2026 13:50:49 -0300 Subject: [PATCH] fix(vendors): complete directory contact fallbacks --- .../VendorDataServiceTests.cs | 208 ++++++++++++++++++ .../VendorServiceTests.cs | 28 +++ .../Implementation/VendorDataService.cs | 23 +- .../Implementation/VendorService.cs | 11 +- 4 files changed, 265 insertions(+), 5 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs index a7d91f1..edbdcbf 100644 --- a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -245,6 +245,7 @@ public class VendorDataServiceTests [Theory] [InlineData("trade")] [InlineData("location")] + [InlineData("address-location")] [InlineData("job-bucket")] public void BuildDirectoryQuery_TranslatesRepresentativeFiltersForSqlServer(string filter) { @@ -255,6 +256,7 @@ public class VendorDataServiceTests { "trade" => service.BuildDirectoryQuery(trades: new[] { "Plumbing" }), "location" => service.BuildDirectoryQuery(locations: new[] { "Chicago, IL" }), + "address-location" => service.BuildDirectoryQuery(locations: new[] { "123 Main St" }), _ => service.BuildDirectoryQuery(jobBuckets: new[] { "50-99" }) }; @@ -265,6 +267,8 @@ public class VendorDataServiceTests sql.Should().Contain("TradeSpecialties"); if (filter == "location") sql.Should().Contain("City"); + if (filter == "address-location") + sql.Should().Contain("Address"); if (filter == "job-bucket") sql.Should().Contain("UNION"); } @@ -472,9 +476,197 @@ public class VendorDataServiceTests locationRow.State.Should().BeNull(); } + [Fact] + public async Task GetDirectoryPagedAsync_UsesCanonicalCompanyAddressAndCompanyPhoneWithVendorFallback() + { + await using var context = NewContext(); + var fullCompany = new VendorCompany + { + Name = "Full Co", + NormalizedName = "full co", + Address = "100 Company Way", + CompanyPhone = "(555) 111-2222" + }; + var blankCompany = new VendorCompany + { + Name = "Blank Co", + NormalizedName = "blank co", + Address = " ", + CompanyPhone = "" + }; + context.VendorCompanies.AddRange(fullCompany, blankCompany); + context.Vendors.AddRange( + new Vendor + { + CompanyName = "Full Co", + ContactName = "Full Tech", + Phone = "(555) 222-3333", + CompanyPhone = "(555) 999-0000", + Address = "9 Old Street", + IsActive = true, + IsDeleted = false, + Company = fullCompany + }, + new Vendor + { + CompanyName = "Blank Co", + ContactName = "Blank Tech", + CompanyPhone = "(555) 333-4444", + Address = "200 Vendor Way", + IsActive = true, + IsDeleted = false, + Company = blankCompany + }, + new Vendor + { + CompanyName = "Legacy Co", + ContactName = "Unlinked Tech", + CompanyPhone = "(555) 777-8888", + Address = "300 Legacy Way", + IsActive = true, + IsDeleted = false, + CompanyId = null + }); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + var (items, totalCount) = await service.GetDirectoryPagedAsync(1, 50, isActive: true); + + totalCount.Should().Be(3); + var fullTech = items.Single(i => i.ContactName == "Full Tech"); + fullTech.Address.Should().Be("100 Company Way"); + fullTech.CompanyPhone.Should().Be("(555) 111-2222"); + fullTech.Phone.Should().Be("(555) 222-3333"); + + var blankTech = items.Single(i => i.ContactName == "Blank Tech"); + blankTech.Address.Should().Be("200 Vendor Way"); + blankTech.CompanyPhone.Should().Be("(555) 333-4444"); + + var legacy = items.Single(i => i.ContactName == "Unlinked Tech"); + legacy.Address.Should().Be("300 Legacy Way"); + legacy.CompanyPhone.Should().Be("(555) 777-8888"); + } + + [Fact] + public async Task GetDirectoryPagedAsync_SearchIncludesProjectedAddress() + { + await using var context = NewContext(); + var company = new VendorCompany + { + Name = "Addr Co", + NormalizedName = "addr co", + Address = "123 Main St" + }; + context.VendorCompanies.Add(company); + context.Vendors.Add(new Vendor + { + CompanyName = "Addr Co", + ContactName = "Pat", + Address = "9 Old Street", + IsActive = true, + IsDeleted = false, + Company = company + }); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + + var search = await service.GetDirectoryPagedAsync(1, 50, search: "123 main", isActive: true); + search.TotalCount.Should().Be(1); + search.Items.Single().ContactName.Should().Be("Pat"); + + var stale = await service.GetDirectoryPagedAsync(1, 50, search: "9 old street", isActive: true); + stale.TotalCount.Should().Be(0); + } + + [Fact] + public async Task GetDirectoryPagedAsync_LocationFilterFallsBackToExactAddressWhenCityStateMissing() + { + await using var context = NewContext(); + var addressOnlyCompany = new VendorCompany + { + Name = "Addr Only Co", + NormalizedName = "addr only co", + Address = "123 Main St" + }; + var soloCompany = new VendorCompany + { + Name = "Solo Co", + NormalizedName = "solo co", + Address = "500 Solo Way" + }; + context.VendorCompanies.AddRange(addressOnlyCompany, soloCompany); + context.Vendors.Add(new Vendor + { + CompanyName = "Addr Only Co", + ContactName = "Pat", + IsActive = true, + IsDeleted = false, + Company = addressOnlyCompany + }); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + + var technicianRow = await service.GetDirectoryPagedAsync( + 1, 50, isActive: true, locations: new[] { "123 Main St" }); + technicianRow.TotalCount.Should().Be(1); + technicianRow.Items.Single().ContactName.Should().Be("Pat"); + + var companyOnlyRow = await service.GetDirectoryPagedAsync( + 1, 50, isActive: true, locations: new[] { "500 Solo Way" }); + companyOnlyRow.TotalCount.Should().Be(1); + companyOnlyRow.Items.Single().CompanyName.Should().Be("Solo Co"); + + var partial = await service.GetDirectoryPagedAsync( + 1, 50, isActive: true, locations: new[] { "123 Main" }); + partial.TotalCount.Should().Be(0); + } + + [Fact] + public async Task GetDirectoryPagedAsync_LocationLabelPrefersCityStateOverAddress() + { + await using var context = NewContext(); + context.Vendors.AddRange( + new Vendor + { + CompanyName = "City Co", + ContactName = "City Tech", + City = "Austin", + Address = "999 Address Way", + IsActive = true, + IsDeleted = false + }, + new Vendor + { + CompanyName = "State Co", + ContactName = "State Tech", + State = "TX", + Address = "888 Address Way", + IsActive = true, + IsDeleted = false + }); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + + var byCity = await service.GetDirectoryPagedAsync(1, 50, isActive: true, locations: new[] { "Austin" }); + byCity.TotalCount.Should().Be(1); + byCity.Items.Single().CompanyName.Should().Be("City Co"); + + var byState = await service.GetDirectoryPagedAsync(1, 50, isActive: true, locations: new[] { "TX" }); + byState.TotalCount.Should().Be(1); + byState.Items.Single().CompanyName.Should().Be("State Co"); + + var bySuppressedAddress = await service.GetDirectoryPagedAsync( + 1, 50, isActive: true, locations: new[] { "999 Address Way" }); + bySuppressedAddress.TotalCount.Should().Be(0); + } + [Theory] [InlineData("trade")] [InlineData("location")] + [InlineData("address-location")] [InlineData("job-bucket")] public void BuildDirectoryUnionQuery_TranslatesToSqlServerUnion(string filter) { @@ -485,6 +677,7 @@ public class VendorDataServiceTests { "trade" => service.BuildDirectoryUnionQuery(trades: new[] { "Plumbing" }), "location" => service.BuildDirectoryUnionQuery(locations: new[] { "Chicago, IL" }), + "address-location" => service.BuildDirectoryUnionQuery(locations: new[] { "123 Main St" }), _ => service.BuildDirectoryUnionQuery(jobBuckets: new[] { "under-50" }) }; @@ -496,10 +689,25 @@ public class VendorDataServiceTests sql.Should().Contain("TradeSpecialties"); if (filter == "location") sql.Should().Contain("City"); + if (filter == "address-location") + sql.Should().Contain("Address"); if (filter == "job-bucket") sql.Should().Contain("TotalJobs"); } + [Fact] + public void BuildDirectoryUnionQuery_TranslatesAddressSearchForSqlServer() + { + using var context = NewSqlServerContext(); + var service = new VendorDataService(context); + + var sql = service.BuildDirectoryUnionQuery(search: "123 main").ToQueryString(); + + sql.Should().Contain("SELECT"); + sql.Should().Contain("UNION"); + sql.Should().Contain("Address"); + } + [Fact] public async Task GetCompaniesForFacetsAsync_IncludesZeroTechnicianCompanyForActiveAndNullStatus() { diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index ba42cb5..b9e0db2 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -682,6 +682,34 @@ public class VendorServiceTests facets.Trades.Should().BeEmpty(); } + [Fact] + public async Task GetFacetsAsync_LocationsIncludeAddressOnlyCompaniesWithAddressLabel() + { + var data = new Mock(); + data.Setup(x => x.GetCompaniesForFacetsAsync(null, It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = 1, Name = "Address Only Co", Address = "789 Warehouse Row" }, + new() { Id = 2, Name = "City State Co", Address = "12 Ignored Way", City = "Denver", State = "CO" }, + new() { Id = 3, Name = "Nowhere Co" } + }); + data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) + .ReturnsAsync(new List()); + + var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None); + + facets.Locations.Select(l => l.Label).Should().BeEquivalentTo( + new[] { "789 Warehouse Row", "Denver, CO" }); + + var addressOnly = facets.Locations.Single(l => l.Label == "789 Warehouse Row"); + addressOnly.City.Should().BeNull(); + addressOnly.State.Should().BeNull(); + + var cityState = facets.Locations.Single(l => l.Label == "Denver, CO"); + cityState.City.Should().Be("Denver"); + cityState.State.Should().Be("CO"); + } + [Fact] public async Task GetFacetsAsync_CompaniesAreUniqueAndOrderedCaseInsensitively() { diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index dc09d71..ec5bef3 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -197,9 +197,15 @@ namespace SeaHaven.DataServices.Implementation ? v.Company.Email : v.Email, Phone = v.Phone, - CompanyPhone = v.CompanyPhone, + CompanyPhone = v.Company != null && + v.Company.CompanyPhone != null && v.Company.CompanyPhone.Trim() != "" + ? v.Company.CompanyPhone + : v.CompanyPhone, PreferredContact = v.PreferredContact, - Address = v.Address, + 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() != "")) @@ -291,6 +297,9 @@ namespace SeaHaven.DataServices.Implementation (row.TradeSpecialties ?? "").ToLower().Contains(normalizedSearch) || (row.City ?? "").ToLower().Contains(normalizedSearch) || (row.State ?? "").ToLower().Contains(normalizedSearch) || + (((row.City == null || row.City.Trim() == "") && + (row.State == null || row.State.Trim() == "")) && + (row.Address ?? "").ToLower().Contains(normalizedSearch)) || (row.Zip ?? "").ToLower().Contains(normalizedSearch)); } @@ -500,14 +509,22 @@ namespace SeaHaven.DataServices.Implementation { var city = Expression.Property(vendor, nameof(Vendor.City)); var state = Expression.Property(vendor, nameof(Vendor.State)); + var address = Expression.Property(vendor, nameof(Vendor.Address)); var cityBlank = IsBlank(city); var stateBlank = IsBlank(state); + var addressBlank = IsBlank(address); var cityCommaState = Concat(Concat(city, Expression.Constant(", ")), state); var onlyState = Expression.Condition(stateBlank, Expression.Constant(string.Empty), state); var cityOrBoth = Expression.Condition(stateBlank, city, cityCommaState); - return Expression.Condition(cityBlank, onlyState, cityOrBoth); + var cityStateLabel = Expression.Condition(cityBlank, onlyState, cityOrBoth); + + // Exact Location label: City/State whenever either structured value is + // present, otherwise the (projected) Address. The blank guard keeps the + // label non-null so the caller's ToLower never sees a null reference. + var addressLabel = Expression.Condition(addressBlank, Expression.Constant(string.Empty), address); + return Expression.Condition(Expression.AndAlso(cityBlank, stateBlank), addressLabel, cityStateLabel); } private static Expression IsBlank(Expression value) diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index 8765fb3..f0d603f 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -681,13 +681,20 @@ namespace SeaHaven.Services.Implementation var trades = canonicalTrades.Select(t => t.Name).ToList(); + // Exact Location label rule (mirrors the directory filter): City/State + // whenever either structured value is present, otherwise Address. + // Address-only companies are therefore represented in the facets. var locations = companies - .Where(c => !string.IsNullOrWhiteSpace(c.City) || !string.IsNullOrWhiteSpace(c.State)) + .Where(c => !string.IsNullOrWhiteSpace(c.City) + || !string.IsNullOrWhiteSpace(c.State) + || !string.IsNullOrWhiteSpace(c.Address)) .Select(c => new VendorFacetLocationDTO { City = c.City, State = c.State, - Label = string.Join(", ", new[] { c.City, c.State }.Where(value => !string.IsNullOrWhiteSpace(value))) + Label = !string.IsNullOrWhiteSpace(c.City) || !string.IsNullOrWhiteSpace(c.State) + ? string.Join(", ", new[] { c.City, c.State }.Where(value => !string.IsNullOrWhiteSpace(value))) + : c.Address }) .DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase) .OrderBy(location => location.Label);