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.
This commit is contained in:
Alexandre Brandizzi 2026-08-25 13:30:01 -03:00
parent 8e749047a3
commit 8086b6c85f
4 changed files with 183 additions and 4 deletions

View file

@ -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")]

View file

@ -682,6 +682,45 @@ public class VendorServiceTests
facets.Trades.Should().BeEmpty();
}
[Fact]
public async Task GetFacetsAsync_LocationFallsBackToAddressWhenCityAndStateAreBlank()
{
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetCompaniesForFacetsAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VendorCompany>
{
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<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
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()
{

View file

@ -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)

View file

@ -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);