From 8086b6c85fab4ef2307f531b8576b70fb93af6f0 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 25 Aug 2026 13:30:01 -0300 Subject: [PATCH] fix(vendors): resolve list location from company address when city/state blank The Vendors directory renders Location from City/State only, but the roster form keeps its City/State inputs hidden, so vendors captured through the UI store their location in the free-text Address field and the column falls back to an em dash while the detail drawer shows the value. Resolve location through a single rule -- structured City/State when present, otherwise the free-text Address -- and apply it consistently so the column, search, the location facet and the location filter cannot disagree. - Directory rows fall back to the linked company's Address when the technician has none, matching the Email precedence already established for these rows. - BuildLocationLabelBody, which backs both the filter and the facet label, falls back to Address when City/State are blank. - Directory search matches Address so a row is findable by what it displays. - The locations facet offers address-only companies as filter options. --- .../VendorDataServiceTests.cs | 121 ++++++++++++++++++ .../VendorServiceTests.cs | 39 ++++++ .../Implementation/VendorDataService.cs | 16 ++- .../Implementation/VendorService.cs | 11 +- 4 files changed, 183 insertions(+), 4 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs index a7d91f1..5661b93 100644 --- a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -472,6 +472,127 @@ public class VendorDataServiceTests locationRow.State.Should().BeNull(); } + [Fact] + public async Task GetDirectoryPagedAsync_UsesCompanyAddressWhenTechnicianAddressBlank() + { + await using var context = NewContext(); + var company = new VendorCompany + { + Name = "Address Co", + NormalizedName = "address co", + Address = "Santa Monica, CA" + }; + context.VendorCompanies.Add(company); + context.Vendors.AddRange( + new Vendor + { + CompanyName = "Address Co", + ContactName = "Blank Address", + Address = " ", + IsActive = true, + IsDeleted = false, + Company = company + }, + new Vendor + { + CompanyName = "Address Co", + ContactName = "Own Address", + Address = "1 Technician Way", + IsActive = true, + IsDeleted = false, + Company = company + }, + new Vendor + { + CompanyName = "Unlinked Co", + ContactName = "Unlinked", + Address = "9 Legacy Road", + IsActive = true, + IsDeleted = false, + CompanyId = null + }); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + var (items, _) = await service.GetDirectoryPagedAsync(1, 50, isActive: true); + + items.Single(i => i.ContactName == "Blank Address").Address.Should().Be("Santa Monica, CA"); + items.Single(i => i.ContactName == "Own Address").Address.Should().Be("1 Technician Way"); + items.Single(i => i.ContactName == "Unlinked").Address.Should().Be("9 Legacy Road"); + } + + [Fact] + public async Task GetDirectoryPagedAsync_SearchAndLocationFilterFallBackToAddressWhenCityStateBlank() + { + await using var context = NewContext(); + var company = new VendorCompany + { + Name = "Vinewood LLC", + NormalizedName = "vinewood llc", + Address = "Santa Monica, CA" + }; + context.VendorCompanies.Add(company); + context.Vendors.Add(new Vendor + { + CompanyName = "Vinewood LLC", + ContactName = "Trevor", + IsActive = true, + IsDeleted = false, + Company = company + }); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + var search = await service.GetDirectoryPagedAsync(1, 50, search: "santa monica", isActive: true); + var location = await service.GetDirectoryPagedAsync( + 1, + 50, + isActive: true, + locations: new[] { "Santa Monica, CA" }); + + search.Items.Should().ContainSingle(i => i.ContactName == "Trevor"); + location.Items.Should().ContainSingle(i => i.ContactName == "Trevor"); + } + + [Fact] + public async Task GetDirectoryPagedAsync_StructuredCityStateStillWinsOverAddress() + { + await using var context = NewContext(); + var company = new VendorCompany + { + Name = "Both Co", + NormalizedName = "both co", + Address = "Santa Monica, CA", + City = "Chicago", + State = "IL" + }; + context.VendorCompanies.Add(company); + context.Vendors.Add(new Vendor + { + CompanyName = "Both Co", + ContactName = "Sam", + IsActive = true, + IsDeleted = false, + Company = company + }); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + var byStructured = await service.GetDirectoryPagedAsync( + 1, + 50, + isActive: true, + locations: new[] { "Chicago, IL" }); + var byAddress = await service.GetDirectoryPagedAsync( + 1, + 50, + isActive: true, + locations: new[] { "Santa Monica, CA" }); + + byStructured.Items.Should().ContainSingle(i => i.ContactName == "Sam"); + byAddress.Items.Should().BeEmpty(); + } + [Theory] [InlineData("trade")] [InlineData("location")] diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index ba42cb5..9dc5559 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -682,6 +682,45 @@ public class VendorServiceTests facets.Trades.Should().BeEmpty(); } + [Fact] + public async Task GetFacetsAsync_LocationFallsBackToAddressWhenCityAndStateAreBlank() + { + var data = new Mock(); + data.Setup(x => x.GetCompaniesForFacetsAsync(true, It.IsAny())) + .ReturnsAsync(new List + { + new() + { + Id = 1, + Name = "Vinewood LLC", + Address = " Santa Monica, CA ", + City = null, + State = " " + }, + new() + { + Id = 2, + Name = "Structured Co", + Address = "Ignored Addr", + City = "Chicago", + State = "IL" + }, + new() + { + Id = 3, + Name = "No Location Co" + } + }); + data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) + .ReturnsAsync(new List()); + + var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None); + + var labels = facets.Locations.Select(l => l.Label).ToList(); + labels.Should().BeEquivalentTo(new[] { "Chicago, IL", "Santa Monica, CA" }); + labels.Should().NotContain("Ignored Addr"); + } + [Fact] public async Task GetFacetsAsync_CompaniesAreUniqueAndOrderedCaseInsensitively() { diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index dc09d71..742efae 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -199,7 +199,11 @@ namespace SeaHaven.DataServices.Implementation Phone = v.Phone, CompanyPhone = v.CompanyPhone, PreferredContact = v.PreferredContact, - Address = v.Address, + Address = v.Address != null && v.Address.Trim() != "" + ? v.Address + : v.Company != null + ? 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 +295,7 @@ namespace SeaHaven.DataServices.Implementation (row.TradeSpecialties ?? "").ToLower().Contains(normalizedSearch) || (row.City ?? "").ToLower().Contains(normalizedSearch) || (row.State ?? "").ToLower().Contains(normalizedSearch) || + (row.Address ?? "").ToLower().Contains(normalizedSearch) || (row.Zip ?? "").ToLower().Contains(normalizedSearch)); } @@ -507,7 +512,14 @@ namespace SeaHaven.DataServices.Implementation var onlyState = Expression.Condition(stateBlank, Expression.Constant(string.Empty), state); var cityOrBoth = Expression.Condition(stateBlank, city, cityCommaState); - return Expression.Condition(cityBlank, onlyState, cityOrBoth); + var structured = Expression.Condition(cityBlank, onlyState, cityOrBoth); + + // Vendors captured through the roster form carry their location in the + // free-text Address field, so fall back to it when City/State are blank. + // Display, search, facet and filter all resolve location through here. + var address = Expression.Property(vendor, nameof(Vendor.Address)); + var addressOrEmpty = Expression.Coalesce(address, Expression.Constant(string.Empty)); + return Expression.Condition(IsBlank(structured), addressOrEmpty, structured); } private static Expression IsBlank(Expression value) diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index 8765fb3..109e5bd 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(); + // Location resolves to structured City/State when present and otherwise to the + // free-text Address, matching the directory column and the location filter, so + // a company only reachable by Address is still offered as a filter option. 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!.Trim() }) .DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase) .OrderBy(location => location.Label);