fix(vendors): complete directory contact fallbacks (#86)
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions

* fix(vendors): complete directory contact fallbacks

* fix(vendors): normalize location labels

* fix(vendors): keep company location authoritative
This commit is contained in:
Alexandre Brandizzi 2026-08-25 20:33:47 -03:00 • committed by GitHub
parent df01ded3ce
commit 5857f8483a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 295 additions and 10 deletions

View file

@ -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,208 @@ 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",
City = "Oldtown",
State = "OT",
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);
var staleCity = await service.GetDirectoryPagedAsync(1, 50, search: "oldtown", isActive: true);
staleCity.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",
City = "Oldtown",
State = "OT",
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);
var staleLocation = await service.GetDirectoryPagedAsync(
1, 50, isActive: true, locations: new[] { "Oldtown, OT" });
staleLocation.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 +688,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 +700,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()
{

View file

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

View file

@ -197,17 +197,25 @@ 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() != ""))
(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.State != null && v.Company.State.Trim() != "") ||
(v.Company.Address != null && v.Company.Address.Trim() != ""))
? v.Company.State
: v.State,
Zip = v.Zip,
@ -291,6 +299,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 +511,33 @@ 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 cityLabel = Expression.Condition(
cityBlank,
Expression.Constant(string.Empty),
Expression.Call(city, nameof(string.Trim), Type.EmptyTypes));
var stateLabel = Expression.Condition(
stateBlank,
Expression.Constant(string.Empty),
Expression.Call(state, nameof(string.Trim), Type.EmptyTypes));
var cityCommaState = Concat(Concat(cityLabel, Expression.Constant(", ")), stateLabel);
var onlyState = Expression.Condition(stateBlank, Expression.Constant(string.Empty), state);
var cityOrBoth = Expression.Condition(stateBlank, city, cityCommaState);
return Expression.Condition(cityBlank, onlyState, cityOrBoth);
var onlyState = Expression.Condition(stateBlank, Expression.Constant(string.Empty), stateLabel);
var cityOrBoth = Expression.Condition(stateBlank, cityLabel, cityCommaState);
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),
Expression.Call(address, nameof(string.Trim), Type.EmptyTypes));
return Expression.Condition(Expression.AndAlso(cityBlank, stateBlank), addressLabel, cityStateLabel);
}
private static Expression IsBlank(Expression value)

View file

@ -681,13 +681,21 @@ 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?.Trim(), c.State?.Trim() }
.Where(value => !string.IsNullOrWhiteSpace(value)))
: c.Address!.Trim()
})
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
.OrderBy(location => location.Label);