mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-03 19:03:22 +00:00
feat(vendors): group directory by company
This commit is contained in:
parent
eb9b7eb50e
commit
07c482cbc0
8 changed files with 973 additions and 60 deletions
|
|
@ -4,6 +4,7 @@ using FluentAssertions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SeaHaven.DataServices.Implementation;
|
using SeaHaven.DataServices.Implementation;
|
||||||
using SeaHaven.DataServices.Models;
|
using SeaHaven.DataServices.Models;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Tests;
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
@ -327,6 +328,158 @@ public class VendorDataServiceTests
|
||||||
result.Select(x => x.WorkOrderId).Should().OnlyHaveUniqueItems();
|
result.Select(x => x.WorkOrderId).Should().OnlyHaveUniqueItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCompanyDirectoryPagedAsync_GroupsCompaniesAndKeepsLegacyRowsDistinct()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
var linkedCompany = new VendorCompany
|
||||||
|
{
|
||||||
|
Name = "Linked Co",
|
||||||
|
NormalizedName = "linked co",
|
||||||
|
Email = "dispatch@linked.example",
|
||||||
|
CompanyPhone = "555-0100",
|
||||||
|
Address = "100 Company Way",
|
||||||
|
City = "Chicago",
|
||||||
|
State = "IL"
|
||||||
|
};
|
||||||
|
var companyOnly = new VendorCompany { Name = "Company Only", NormalizedName = "company only" };
|
||||||
|
context.VendorCompanies.AddRange(linkedCompany, companyOnly);
|
||||||
|
context.Vendors.AddRange(
|
||||||
|
new Vendor
|
||||||
|
{
|
||||||
|
Company = linkedCompany,
|
||||||
|
CompanyName = "Stale Linked Name",
|
||||||
|
ContactName = "Older Tech",
|
||||||
|
Email = "older@linked.example",
|
||||||
|
Phone = "555-0101",
|
||||||
|
TradeSpecialties = "Plumbing",
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = false,
|
||||||
|
Dispatches = new List<Dispatch> { new() { WorkOrderId = 101 } }
|
||||||
|
},
|
||||||
|
new Vendor
|
||||||
|
{
|
||||||
|
Company = linkedCompany,
|
||||||
|
CompanyName = "Another Stale Name",
|
||||||
|
ContactName = "Newer Tech",
|
||||||
|
Email = "newer@linked.example",
|
||||||
|
Phone = "555-0102",
|
||||||
|
TradeSpecialties = "HVAC, plumbing",
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = false,
|
||||||
|
Dispatches = new List<Dispatch> { new() { WorkOrderId = 102 } }
|
||||||
|
},
|
||||||
|
new Vendor { CompanyName = " Legacy Co ", ContactName = "Legacy One", IsActive = true, IsDeleted = false },
|
||||||
|
new Vendor { CompanyName = "legacy co", ContactName = "Legacy Two", IsActive = true, IsDeleted = false },
|
||||||
|
new Vendor { CompanyName = " ", ContactName = "Unnamed One", IsActive = true, IsDeleted = false },
|
||||||
|
new Vendor { CompanyName = null, ContactName = "Unnamed Two", IsActive = true, IsDeleted = false });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var service = new VendorDataService(context);
|
||||||
|
var (items, totalCount) = await service.GetCompanyDirectoryPagedAsync(1, 50, isActive: true);
|
||||||
|
|
||||||
|
totalCount.Should().Be(5);
|
||||||
|
var linked = items.Single(item => item.CompanyId == linkedCompany.Id);
|
||||||
|
linked.CompanyName.Should().Be("Linked Co");
|
||||||
|
linked.Email.Should().Be("dispatch@linked.example");
|
||||||
|
linked.ContactName.Should().Be("Newer Tech");
|
||||||
|
linked.Technicians.Should().HaveCount(2);
|
||||||
|
linked.TotalJobs.Should().Be(2);
|
||||||
|
linked.TradeSpecialties.Should().Be("HVAC, plumbing");
|
||||||
|
|
||||||
|
var canonicalSearch = await service.GetCompanyDirectoryPagedAsync(
|
||||||
|
1, 50, search: "Linked Co", isActive: true);
|
||||||
|
canonicalSearch.Items.Should().ContainSingle(item => item.CompanyId == linkedCompany.Id);
|
||||||
|
|
||||||
|
var canonicalCompanyFilter = await service.GetCompanyDirectoryPagedAsync(
|
||||||
|
1, 50, isActive: true, companies: new[] { "Linked Co" });
|
||||||
|
canonicalCompanyFilter.Items.Should().ContainSingle(item => item.CompanyId == linkedCompany.Id);
|
||||||
|
|
||||||
|
var legacy = items.Single(item =>
|
||||||
|
item.Technicians.Any(tech => tech.ContactName == "Legacy One"));
|
||||||
|
legacy.Technicians.Select(tech => tech.ContactName)
|
||||||
|
.Should().BeEquivalentTo("Legacy One", "Legacy Two");
|
||||||
|
items.Count(item => item.Technicians.Count == 1 &&
|
||||||
|
item.Technicians.Single().ContactName?.StartsWith("Unnamed") == true).Should().Be(2);
|
||||||
|
items.Single(item => item.CompanyId == companyOnly.Id).Technicians.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCompanyDirectoryPagedAsync_FilterSelectsGroupAndSearchPromotesMatchingTechnician()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
var company = new VendorCompany { Name = "Search Co", NormalizedName = "search co" };
|
||||||
|
context.VendorCompanies.Add(company);
|
||||||
|
var older = new Vendor
|
||||||
|
{
|
||||||
|
Company = company,
|
||||||
|
CompanyName = company.Name,
|
||||||
|
ContactName = "Alice Plumber",
|
||||||
|
TradeSpecialties = "Plumbing",
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = false,
|
||||||
|
Dispatches = new List<Dispatch> { new() { WorkOrderId = 201 } }
|
||||||
|
};
|
||||||
|
var newer = new Vendor
|
||||||
|
{
|
||||||
|
Company = company,
|
||||||
|
CompanyName = company.Name,
|
||||||
|
ContactName = "Zed HVAC",
|
||||||
|
TradeSpecialties = "HVAC",
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = false,
|
||||||
|
Dispatches = new List<Dispatch> { new() { WorkOrderId = 202 } }
|
||||||
|
};
|
||||||
|
var inactive = new Vendor
|
||||||
|
{
|
||||||
|
Company = company,
|
||||||
|
CompanyName = company.Name,
|
||||||
|
ContactName = "Inactive Tech",
|
||||||
|
TradeSpecialties = "Roofing",
|
||||||
|
IsActive = false,
|
||||||
|
IsDeleted = false,
|
||||||
|
Dispatches = new List<Dispatch> { new() { WorkOrderId = 203 } }
|
||||||
|
};
|
||||||
|
context.Vendors.AddRange(older, newer, inactive);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var service = new VendorDataService(context);
|
||||||
|
var search = await service.GetCompanyDirectoryPagedAsync(1, 10, search: "Alice", isActive: true);
|
||||||
|
var trade = await service.GetCompanyDirectoryPagedAsync(
|
||||||
|
1, 10, isActive: true, trades: new[] { "Plumbing" });
|
||||||
|
|
||||||
|
search.TotalCount.Should().Be(1);
|
||||||
|
search.Items.Single().Id.Should().Be(older.Id);
|
||||||
|
search.Items.Single().ContactName.Should().Be("Alice Plumber");
|
||||||
|
search.Items.Single().Technicians.Should().HaveCount(2);
|
||||||
|
search.Items.Single().TotalJobs.Should().Be(2);
|
||||||
|
|
||||||
|
trade.TotalCount.Should().Be(1);
|
||||||
|
trade.Items.Single().Id.Should().Be(newer.Id);
|
||||||
|
trade.Items.Single().Technicians.Should().HaveCount(2);
|
||||||
|
trade.Items.Single().Technicians.Should().NotContain(tech => tech.Id == inactive.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCompanyDirectoryKeysQuery_TranslatesGroupingAndFacetFiltersToSqlServer()
|
||||||
|
{
|
||||||
|
using var context = NewSqlServerContext();
|
||||||
|
var service = new VendorDataService(context);
|
||||||
|
|
||||||
|
var sql = service.BuildCompanyDirectoryKeysQuery(
|
||||||
|
search: "alice",
|
||||||
|
trades: new[] { "Plumbing" },
|
||||||
|
locations: new[] { "Chicago, IL" },
|
||||||
|
jobBuckets: new[] { "under-50" }).ToQueryString();
|
||||||
|
|
||||||
|
sql.Should().Contain("GROUP BY");
|
||||||
|
sql.Should().Contain("LEFT JOIN");
|
||||||
|
sql.Should().Contain("TradeSpecialties");
|
||||||
|
sql.Should().Contain("TotalJobs");
|
||||||
|
Regex.IsMatch(sql, @"SUM\s*\(\s*\(\s*SELECT", RegexOptions.IgnoreCase)
|
||||||
|
.Should().BeFalse("company totals must sum pre-aggregated scalar job counts");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetDirectoryPagedAsync_IncludesCompanyOnlyRowsAndPreservesTechnicianRows()
|
public async Task GetDirectoryPagedAsync_IncludesCompanyOnlyRowsAndPreservesTechnicianRows()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ public class VendorServiceTests
|
||||||
var data = new Mock<IVendorDataService>();
|
var data = new Mock<IVendorDataService>();
|
||||||
using var cancellation = new CancellationTokenSource();
|
using var cancellation = new CancellationTokenSource();
|
||||||
var token = cancellation.Token;
|
var token = cancellation.Token;
|
||||||
data.Setup(x => x.GetDirectoryPagedAsync(
|
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
|
||||||
1,
|
1,
|
||||||
12,
|
12,
|
||||||
"clayton",
|
"clayton",
|
||||||
|
|
@ -167,16 +167,40 @@ public class VendorServiceTests
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
token))
|
token))
|
||||||
.ReturnsAsync((Array.Empty<VendorDirectoryRow>(), 0));
|
.ReturnsAsync((new[]
|
||||||
|
{
|
||||||
|
new VendorCompanyGroup
|
||||||
|
{
|
||||||
|
Id = 7,
|
||||||
|
CompanyId = 3,
|
||||||
|
CompanyName = "Gateway Plumbing",
|
||||||
|
ContactName = "Primary Tech",
|
||||||
|
TotalJobs = 4,
|
||||||
|
Technicians = new[]
|
||||||
|
{
|
||||||
|
new VendorCompanyGroupTechnician
|
||||||
|
{
|
||||||
|
Id = 7,
|
||||||
|
ContactName = "Primary Tech",
|
||||||
|
Email = "primary@example.com",
|
||||||
|
TotalJobs = 4,
|
||||||
|
IsActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.AsEnumerable().ToList(), 1));
|
||||||
|
|
||||||
await NewService(data).GetVendorDirectoryPagedAsync(
|
var result = await NewService(data).GetVendorDirectoryPagedAsync(
|
||||||
1,
|
1,
|
||||||
12,
|
12,
|
||||||
"clayton",
|
"clayton",
|
||||||
true,
|
true,
|
||||||
cancellationToken: token);
|
cancellationToken: token);
|
||||||
|
|
||||||
data.Verify(x => x.GetDirectoryPagedAsync(
|
result.Items.Single().Technicians.Should().ContainSingle()
|
||||||
|
.Which.Email.Should().Be("primary@example.com");
|
||||||
|
|
||||||
|
data.Verify(x => x.GetCompanyDirectoryPagedAsync(
|
||||||
1,
|
1,
|
||||||
12,
|
12,
|
||||||
"clayton",
|
"clayton",
|
||||||
|
|
@ -188,6 +212,29 @@ public class VendorServiceTests
|
||||||
token), Times.Once);
|
token), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVendorDirectoryPaged_BoundsPageInputsBeforeBuildingTheGroupKeyPredicate()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorDataService>();
|
||||||
|
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
|
||||||
|
1,
|
||||||
|
100,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
|
||||||
|
|
||||||
|
var result = await NewService(data).GetVendorDirectoryPagedAsync(0, 500);
|
||||||
|
|
||||||
|
result.Page.Should().Be(1);
|
||||||
|
result.PageSize.Should().Be(100);
|
||||||
|
data.VerifyAll();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetVendorById_CountsDistinctHistoricalWorkOrders()
|
public async Task GetVendorById_CountsDistinctHistoricalWorkOrders()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -67,16 +67,27 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
v.GoogleMapsUrl,
|
v.GoogleMapsUrl,
|
||||||
v.Notes,
|
v.Notes,
|
||||||
v.IsActive,
|
v.IsActive,
|
||||||
v.TotalJobs
|
v.TotalJobs,
|
||||||
|
Technicians = v.Technicians.Select(t => new
|
||||||
|
{
|
||||||
|
t.Id,
|
||||||
|
t.ContactName,
|
||||||
|
t.Email,
|
||||||
|
t.Phone,
|
||||||
|
t.PreferredContact,
|
||||||
|
t.TradeSpecialties,
|
||||||
|
t.IsActive,
|
||||||
|
t.TotalJobs
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
return Ok(new Pagination_DTO
|
return Ok(new Pagination_DTO
|
||||||
{
|
{
|
||||||
Data = data,
|
Data = data,
|
||||||
PageNumber = page,
|
PageNumber = pagedResult.Page,
|
||||||
PageSize = pageSize,
|
PageSize = pagedResult.PageSize,
|
||||||
TotalCount = pagedResult.TotalCount,
|
TotalCount = pagedResult.TotalCount,
|
||||||
TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize)
|
TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pagedResult.PageSize)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -168,13 +168,462 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return (items, totalCount);
|
return (items, totalCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal IQueryable<VendorDirectoryRow> BuildDirectoryUnionQuery(
|
public async Task<(IReadOnlyList<VendorCompanyGroup> Items, int TotalCount)> GetCompanyDirectoryPagedAsync(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
string? search = null,
|
||||||
|
bool? isActive = true,
|
||||||
|
IReadOnlyCollection<string>? companies = null,
|
||||||
|
IReadOnlyCollection<string>? trades = null,
|
||||||
|
IReadOnlyCollection<string>? locations = null,
|
||||||
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Phase 1: select and paginate bounded company group keys in SQL.
|
||||||
|
var keyQuery = BuildCompanyDirectoryKeysQuery(
|
||||||
|
search,
|
||||||
|
isActive,
|
||||||
|
companies,
|
||||||
|
trades,
|
||||||
|
locations,
|
||||||
|
jobBuckets);
|
||||||
|
|
||||||
|
var totalCount = await keyQuery.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
var pageKeys = await keyQuery
|
||||||
|
.OrderByDescending(key => key.MaxVendorId ?? -1)
|
||||||
|
.ThenByDescending(key => key.CompanyId)
|
||||||
|
.ThenBy(key => key.NameKey)
|
||||||
|
.ThenBy(key => key.VendorKey)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (pageKeys.Count == 0)
|
||||||
|
return (Array.Empty<VendorCompanyGroup>(), totalCount);
|
||||||
|
|
||||||
|
// Phase 2: fetch the full status-eligible roster only for the page keys,
|
||||||
|
// without reapplying search/trade/location filters.
|
||||||
|
var rosterRows = await BuildCompanyDirectoryRosterQuery(isActive, pageKeys)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rosterByKey = rosterRows
|
||||||
|
.GroupBy(row => (CompanyId: row.CompanyId ?? 0, row.NameKey, row.VendorKey))
|
||||||
|
.ToDictionary(group => group.Key, group => group.OrderByDescending(row => row.VendorId).ToList());
|
||||||
|
|
||||||
|
var missingCompanyIds = pageKeys
|
||||||
|
.Where(key => key.CompanyId != 0)
|
||||||
|
.Where(key => !rosterByKey.ContainsKey((key.CompanyId, key.NameKey, key.VendorKey)))
|
||||||
|
.Select(key => key.CompanyId)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var companiesById = missingCompanyIds.Count == 0
|
||||||
|
? new Dictionary<int, VendorCompany>()
|
||||||
|
: await _context.VendorCompanies
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(c => missingCompanyIds.Contains(c.Id))
|
||||||
|
.ToDictionaryAsync(c => c.Id, cancellationToken);
|
||||||
|
|
||||||
|
// Assemble groups in the page key order so the response preserves the
|
||||||
|
// SQL-selected page order.
|
||||||
|
var groups = new List<VendorCompanyGroup>(pageKeys.Count);
|
||||||
|
foreach (var key in pageKeys)
|
||||||
|
{
|
||||||
|
if (rosterByKey.TryGetValue((key.CompanyId, key.NameKey, key.VendorKey), out var technicians))
|
||||||
|
{
|
||||||
|
groups.Add(BuildTechnicianGroup(technicians, search));
|
||||||
|
}
|
||||||
|
else if (companiesById.TryGetValue(key.CompanyId, out var company))
|
||||||
|
{
|
||||||
|
groups.Add(BuildCompanyOnlyGroup(company));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (groups, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VendorCompanyGroup BuildTechnicianGroup(
|
||||||
|
IReadOnlyList<VendorDirectoryRosterRow> techniciansNewestFirst,
|
||||||
|
string? search)
|
||||||
|
{
|
||||||
|
var primary = techniciansNewestFirst[0];
|
||||||
|
|
||||||
|
// Primary is the newest VendorId by default; a nonblank search that matches
|
||||||
|
// a non-primary technician's ContactName, email, Phone, or
|
||||||
|
// TradeSpecialties promotes the newest matching technician. Company-level
|
||||||
|
// matches (e.g. company name or location) never reorder.
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
var normalizedSearch = search.ToLower();
|
||||||
|
primary = techniciansNewestFirst.FirstOrDefault(row =>
|
||||||
|
((row.ContactName ?? "").ToLower().Contains(normalizedSearch)) ||
|
||||||
|
((row.Email ?? "").ToLower().Contains(normalizedSearch)) ||
|
||||||
|
((row.Phone ?? "").ToLower().Contains(normalizedSearch)) ||
|
||||||
|
((row.TradeSpecialties ?? "").ToLower().Contains(normalizedSearch)))
|
||||||
|
?? techniciansNewestFirst[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
var linked = primary.CompanyId != null;
|
||||||
|
var hasCompanyLocation = linked && (
|
||||||
|
!string.IsNullOrWhiteSpace(primary.CompanyCity) ||
|
||||||
|
!string.IsNullOrWhiteSpace(primary.CompanyState) ||
|
||||||
|
!string.IsNullOrWhiteSpace(primary.CompanyAddress));
|
||||||
|
|
||||||
|
return new VendorCompanyGroup
|
||||||
|
{
|
||||||
|
Id = primary.VendorId,
|
||||||
|
CompanyId = primary.CompanyId,
|
||||||
|
CompanyName = linked ? primary.CanonicalCompanyName : primary.CompanyName,
|
||||||
|
ContactName = primary.ContactName,
|
||||||
|
Email = linked ? primary.CompanyEmail : primary.Email,
|
||||||
|
Phone = primary.Phone,
|
||||||
|
CompanyPhone = linked && !string.IsNullOrWhiteSpace(primary.CompanyPhone)
|
||||||
|
? primary.CompanyPhone
|
||||||
|
: primary.VendorCompanyPhone,
|
||||||
|
PreferredContact = primary.PreferredContact,
|
||||||
|
Address = linked && !string.IsNullOrWhiteSpace(primary.CompanyAddress)
|
||||||
|
? primary.CompanyAddress
|
||||||
|
: primary.Address,
|
||||||
|
City = hasCompanyLocation ? primary.CompanyCity : primary.City,
|
||||||
|
State = hasCompanyLocation ? primary.CompanyState : primary.State,
|
||||||
|
Zip = primary.Zip,
|
||||||
|
TradeSpecialties = AggregateTradeTokens(techniciansNewestFirst),
|
||||||
|
GoogleMapsUrl = primary.GoogleMapsUrl,
|
||||||
|
Notes = linked ? primary.CompanyNotes : null,
|
||||||
|
IsActive = primary.IsActive,
|
||||||
|
TotalJobs = techniciansNewestFirst.Sum(row => row.TotalJobs),
|
||||||
|
Technicians = techniciansNewestFirst.Select(row => new VendorCompanyGroupTechnician
|
||||||
|
{
|
||||||
|
Id = row.VendorId,
|
||||||
|
ContactName = row.ContactName,
|
||||||
|
Email = row.Email,
|
||||||
|
Phone = row.Phone,
|
||||||
|
PreferredContact = row.PreferredContact,
|
||||||
|
TradeSpecialties = row.TradeSpecialties,
|
||||||
|
IsActive = row.IsActive,
|
||||||
|
TotalJobs = row.TotalJobs
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct trade CSV tokens case-insensitively, preserving the first displayed
|
||||||
|
// spelling and order (technicians are listed newest VendorId first).
|
||||||
|
private static string? AggregateTradeTokens(IEnumerable<VendorDirectoryRosterRow> techniciansNewestFirst)
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var tokens = new List<string>();
|
||||||
|
foreach (var row in techniciansNewestFirst)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(row.TradeSpecialties))
|
||||||
|
continue;
|
||||||
|
foreach (var token in row.TradeSpecialties.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
if (seen.Add(token))
|
||||||
|
tokens.Add(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens.Count == 0 ? null : string.Join(", ", tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VendorCompanyGroup BuildCompanyOnlyGroup(VendorCompany company) => new()
|
||||||
|
{
|
||||||
|
Id = null,
|
||||||
|
CompanyId = company.Id,
|
||||||
|
CompanyName = company.Name,
|
||||||
|
ContactName = null,
|
||||||
|
Email = company.Email,
|
||||||
|
Phone = null,
|
||||||
|
CompanyPhone = company.CompanyPhone,
|
||||||
|
PreferredContact = null,
|
||||||
|
Address = company.Address,
|
||||||
|
City = company.City,
|
||||||
|
State = company.State,
|
||||||
|
Zip = company.Zip,
|
||||||
|
TradeSpecialties = null,
|
||||||
|
GoogleMapsUrl = company.GoogleMapsUrl,
|
||||||
|
Notes = company.Notes,
|
||||||
|
IsActive = true,
|
||||||
|
TotalJobs = 0,
|
||||||
|
Technicians = Array.Empty<VendorCompanyGroupTechnician>()
|
||||||
|
};
|
||||||
|
|
||||||
|
internal IQueryable<VendorCompanyGroupKeyRow> BuildCompanyDirectoryKeysQuery(
|
||||||
string? search = null,
|
string? search = null,
|
||||||
bool? isActive = true,
|
bool? isActive = true,
|
||||||
IReadOnlyCollection<string>? companies = null,
|
IReadOnlyCollection<string>? companies = null,
|
||||||
IReadOnlyCollection<string>? trades = null,
|
IReadOnlyCollection<string>? trades = null,
|
||||||
IReadOnlyCollection<string>? locations = null,
|
IReadOnlyCollection<string>? locations = null,
|
||||||
IReadOnlyCollection<string>? jobBuckets = null)
|
IReadOnlyCollection<string>? jobBuckets = null)
|
||||||
|
{
|
||||||
|
var sourceRows = BuildDirectoryUnionBaseQuery(isActive)
|
||||||
|
.Select(BuildGroupSourceRowSelector(search, companies, trades, locations));
|
||||||
|
|
||||||
|
var grouped = sourceRows
|
||||||
|
.GroupBy(row => new { row.CompanyId, row.NameKey, row.VendorKey })
|
||||||
|
.Select(group => new VendorCompanyGroupKeyRow
|
||||||
|
{
|
||||||
|
CompanyId = group.Key.CompanyId,
|
||||||
|
NameKey = group.Key.NameKey,
|
||||||
|
VendorKey = group.Key.VendorKey,
|
||||||
|
MaxVendorId = group.Max(row => row.VendorId),
|
||||||
|
TotalJobs = group.Sum(row => row.TotalJobs),
|
||||||
|
SearchMatch = group.Max(row => row.SearchFlag),
|
||||||
|
CompanyMatch = group.Max(row => row.CompanyFlag),
|
||||||
|
TradeMatch = group.Max(row => row.TradeFlag),
|
||||||
|
LocationMatch = group.Max(row => row.LocationFlag)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Each facet selects the group when any status-eligible row matches it
|
||||||
|
// (OR within a facet, AND across facets); job buckets apply to the summed
|
||||||
|
// per-technician TotalJobs of the whole company.
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
grouped = grouped.Where(key => key.SearchMatch == 1);
|
||||||
|
|
||||||
|
var selectedCompanies = companies?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
if (selectedCompanies is { Length: > 0 })
|
||||||
|
grouped = grouped.Where(key => key.CompanyMatch == 1);
|
||||||
|
|
||||||
|
var selectedTrades = trades?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
if (selectedTrades is { Length: > 0 })
|
||||||
|
grouped = grouped.Where(key => key.TradeMatch == 1);
|
||||||
|
|
||||||
|
var selectedLocations = locations?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
if (selectedLocations is { Length: > 0 })
|
||||||
|
grouped = grouped.Where(key => key.LocationMatch == 1);
|
||||||
|
|
||||||
|
var selectedJobBuckets = jobBuckets?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
if (selectedJobBuckets is not { Length: > 0 })
|
||||||
|
return grouped;
|
||||||
|
|
||||||
|
var under50 = selectedJobBuckets.Contains("under-50");
|
||||||
|
var from50To99 = selectedJobBuckets.Contains("50-99");
|
||||||
|
var from100To149 = selectedJobBuckets.Contains("100-149");
|
||||||
|
var from150 = selectedJobBuckets.Contains("150-plus");
|
||||||
|
|
||||||
|
return grouped.Where(key =>
|
||||||
|
(under50 && key.TotalJobs < 50) ||
|
||||||
|
(from50To99 && key.TotalJobs >= 50 && key.TotalJobs <= 99) ||
|
||||||
|
(from100To149 && key.TotalJobs >= 100 && key.TotalJobs <= 149) ||
|
||||||
|
(from150 && key.TotalJobs >= 150));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expression<Func<VendorDirectoryRow, VendorDirectoryGroupSourceRow>> BuildGroupSourceRowSelector(
|
||||||
|
string? search,
|
||||||
|
IReadOnlyCollection<string>? companies,
|
||||||
|
IReadOnlyCollection<string>? trades,
|
||||||
|
IReadOnlyCollection<string>? locations)
|
||||||
|
{
|
||||||
|
var row = Expression.Parameter(typeof(VendorDirectoryRow), "row");
|
||||||
|
|
||||||
|
var selectedCompanies = companies?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
var selectedTrades = trades?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
var selectedLocations = locations?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
static Expression Flag(Expression? matchBody) =>
|
||||||
|
matchBody is null
|
||||||
|
? Expression.Constant(1)
|
||||||
|
: Expression.Condition(matchBody, Expression.Constant(1), Expression.Constant(0));
|
||||||
|
|
||||||
|
var searchBody = string.IsNullOrWhiteSpace(search)
|
||||||
|
? null
|
||||||
|
: BuildSearchMatchBody(row, search.ToLower());
|
||||||
|
var companyBody = selectedCompanies is { Length: > 0 }
|
||||||
|
? BuildCompanyNameMatchBody(row, selectedCompanies)
|
||||||
|
: null;
|
||||||
|
var tradeBody = selectedTrades is { Length: > 0 }
|
||||||
|
? BuildTradeMatchBody<VendorDirectoryRow>(row, selectedTrades)
|
||||||
|
: null;
|
||||||
|
var locationBody = selectedLocations is { Length: > 0 }
|
||||||
|
? BuildLocationMatchBody<VendorDirectoryRow>(row, selectedLocations)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var nameKey = Expression.Condition(
|
||||||
|
Expression.NotEqual(
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRow.CompanyId)),
|
||||||
|
Expression.Constant(0)),
|
||||||
|
Expression.Constant(string.Empty),
|
||||||
|
LowerTrimmed(row, nameof(VendorDirectoryRow.CompanyName)));
|
||||||
|
|
||||||
|
var nameMissing = Expression.Equal(
|
||||||
|
Expression.Call(
|
||||||
|
CoalescedOrEmpty(row, nameof(VendorDirectoryRow.CompanyName)),
|
||||||
|
nameof(string.Trim),
|
||||||
|
Type.EmptyTypes),
|
||||||
|
Expression.Constant(string.Empty));
|
||||||
|
var vendorKey = Expression.Condition(
|
||||||
|
Expression.AndAlso(
|
||||||
|
Expression.Equal(
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRow.CompanyId)),
|
||||||
|
Expression.Constant(0)),
|
||||||
|
nameMissing),
|
||||||
|
Expression.Coalesce(
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRow.VendorId)),
|
||||||
|
Expression.Constant(0)),
|
||||||
|
Expression.Constant(0));
|
||||||
|
|
||||||
|
MemberBinding Bind(string target, Expression value) =>
|
||||||
|
Expression.Bind(typeof(VendorDirectoryGroupSourceRow).GetProperty(target)!, value);
|
||||||
|
|
||||||
|
return Expression.Lambda<Func<VendorDirectoryRow, VendorDirectoryGroupSourceRow>>(
|
||||||
|
Expression.MemberInit(
|
||||||
|
Expression.New(typeof(VendorDirectoryGroupSourceRow)),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.VendorId),
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRow.VendorId))),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.CompanyId),
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRow.CompanyId))),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.NameKey), nameKey),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.VendorKey), vendorKey),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.TotalJobs),
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRow.TotalJobs))),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.SearchFlag), Flag(searchBody)),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.CompanyFlag), Flag(companyBody)),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.TradeFlag), Flag(tradeBody)),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody))),
|
||||||
|
row);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal IQueryable<VendorDirectoryRosterRow> BuildCompanyDirectoryRosterQuery(
|
||||||
|
bool? isActive,
|
||||||
|
IReadOnlyList<VendorCompanyGroupKeyRow> keys)
|
||||||
|
{
|
||||||
|
var vendors = _context.Vendors
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
||||||
|
|
||||||
|
if (isActive.HasValue)
|
||||||
|
vendors = vendors.Where(v => v.IsActive == isActive.Value);
|
||||||
|
|
||||||
|
var rows = vendors.Select(v => new VendorDirectoryRosterRow
|
||||||
|
{
|
||||||
|
VendorId = v.Id,
|
||||||
|
CompanyId = v.CompanyId,
|
||||||
|
CompanyName = v.Company != null ? v.Company.Name : v.CompanyName,
|
||||||
|
CanonicalCompanyName = v.Company != null ? v.Company.Name : null,
|
||||||
|
CompanyEmail = v.Company != null ? v.Company.Email : null,
|
||||||
|
CompanyPhone = v.Company != null ? v.Company.CompanyPhone : null,
|
||||||
|
CompanyAddress = v.Company != null ? v.Company.Address : null,
|
||||||
|
CompanyCity = v.Company != null ? v.Company.City : null,
|
||||||
|
CompanyState = v.Company != null ? v.Company.State : null,
|
||||||
|
CompanyNotes = v.Company != null ? v.Company.Notes : null,
|
||||||
|
ContactName = v.ContactName,
|
||||||
|
Email = v.Email,
|
||||||
|
Phone = v.Phone,
|
||||||
|
PreferredContact = v.PreferredContact,
|
||||||
|
VendorCompanyPhone = v.CompanyPhone,
|
||||||
|
Address = v.Address,
|
||||||
|
City = v.City,
|
||||||
|
State = v.State,
|
||||||
|
Zip = v.Zip,
|
||||||
|
TradeSpecialties = v.TradeSpecialties,
|
||||||
|
GoogleMapsUrl = v.GoogleMapsUrl,
|
||||||
|
IsActive = v.IsActive,
|
||||||
|
TotalJobs = _context.Dispatches
|
||||||
|
.Where(dispatch =>
|
||||||
|
dispatch.VendorId == v.Id &&
|
||||||
|
dispatch.WorkOrderId.HasValue)
|
||||||
|
.Select(dispatch => dispatch.WorkOrderId!.Value)
|
||||||
|
.Union(
|
||||||
|
_context.DispatchWorkOrders
|
||||||
|
.Where(link => link.Dispatch != null && link.Dispatch.VendorId == v.Id)
|
||||||
|
.Select(link => link.WorkOrderId))
|
||||||
|
.Count(),
|
||||||
|
NameKey = v.CompanyId != null
|
||||||
|
? ""
|
||||||
|
: (v.CompanyName ?? "").Trim().ToLower(),
|
||||||
|
VendorKey = v.CompanyId == null && (v.CompanyName ?? "").Trim() == ""
|
||||||
|
? v.Id
|
||||||
|
: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.Where(BuildRosterKeyPredicate(keys));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expression<Func<VendorDirectoryRosterRow, bool>> BuildRosterKeyPredicate(
|
||||||
|
IReadOnlyList<VendorCompanyGroupKeyRow> keys)
|
||||||
|
{
|
||||||
|
var row = Expression.Parameter(typeof(VendorDirectoryRosterRow), "row");
|
||||||
|
|
||||||
|
Expression? body = null;
|
||||||
|
foreach (var key in keys)
|
||||||
|
{
|
||||||
|
Expression keyMatch;
|
||||||
|
if (key.CompanyId != 0)
|
||||||
|
{
|
||||||
|
keyMatch = Expression.Equal(
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRosterRow.CompanyId)),
|
||||||
|
Expression.Constant(key.CompanyId, typeof(int?)));
|
||||||
|
}
|
||||||
|
else if (key.NameKey.Length > 0)
|
||||||
|
{
|
||||||
|
keyMatch = Expression.AndAlso(
|
||||||
|
Expression.Equal(
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRosterRow.CompanyId)),
|
||||||
|
Expression.Constant(null, typeof(int?))),
|
||||||
|
Expression.Equal(
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRosterRow.NameKey)),
|
||||||
|
Expression.Constant(key.NameKey)));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
keyMatch = Expression.Equal(
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRosterRow.VendorId)),
|
||||||
|
Expression.Constant(key.VendorKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
body = OrElse(body, keyMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Expression.Lambda<Func<VendorDirectoryRosterRow, bool>>(
|
||||||
|
body ?? Expression.Constant(false),
|
||||||
|
row);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal IQueryable<VendorDirectoryRow> BuildDirectoryUnionQuery(
|
||||||
|
string? search = null,
|
||||||
|
bool? isActive = true,
|
||||||
|
IReadOnlyCollection<string>? companies = null,
|
||||||
|
IReadOnlyCollection<string>? trades = null,
|
||||||
|
IReadOnlyCollection<string>? locations = null,
|
||||||
|
IReadOnlyCollection<string>? jobBuckets = null) =>
|
||||||
|
ApplyDirectoryRowFilters(
|
||||||
|
BuildDirectoryUnionBaseQuery(isActive),
|
||||||
|
search,
|
||||||
|
companies,
|
||||||
|
trades,
|
||||||
|
locations,
|
||||||
|
jobBuckets);
|
||||||
|
|
||||||
|
private IQueryable<VendorDirectoryRow> BuildDirectoryUnionBaseQuery(bool? isActive = true)
|
||||||
{
|
{
|
||||||
// Arm A: existing per-technician rows (every non-deleted vendor, including
|
// Arm A: existing per-technician rows (every non-deleted vendor, including
|
||||||
// those with no company). Notes come from the linked VendorCompany.
|
// those with no company). Notes come from the linked VendorCompany.
|
||||||
|
|
@ -185,11 +634,42 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
if (isActive.HasValue)
|
if (isActive.HasValue)
|
||||||
perTechnician = perTechnician.Where(v => v.IsActive == isActive.Value);
|
perTechnician = perTechnician.Where(v => v.IsActive == isActive.Value);
|
||||||
|
|
||||||
var armA = perTechnician.Select(v => new VendorDirectoryRow
|
// Pre-aggregate distinct work-order assignments per technician before
|
||||||
|
// grouping companies. This keeps the outer company SUM over a plain
|
||||||
|
// joined scalar instead of SUM(COUNT(correlated subquery)), which SQL
|
||||||
|
// Server rejects at execution time.
|
||||||
|
var directWorkOrders = _context.Dispatches
|
||||||
|
.Where(dispatch => dispatch.WorkOrderId.HasValue)
|
||||||
|
.Select(dispatch => new
|
||||||
|
{
|
||||||
|
dispatch.VendorId,
|
||||||
|
WorkOrderId = dispatch.WorkOrderId!.Value
|
||||||
|
});
|
||||||
|
var linkedWorkOrders = _context.DispatchWorkOrders
|
||||||
|
.Where(link => link.Dispatch != null)
|
||||||
|
.Select(link => new
|
||||||
|
{
|
||||||
|
VendorId = link.Dispatch!.VendorId,
|
||||||
|
link.WorkOrderId
|
||||||
|
});
|
||||||
|
var jobCounts = directWorkOrders
|
||||||
|
.Union(linkedWorkOrders)
|
||||||
|
.GroupBy(assignment => assignment.VendorId)
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
VendorId = group.Key,
|
||||||
|
TotalJobs = group.Count()
|
||||||
|
});
|
||||||
|
|
||||||
|
var armA =
|
||||||
|
from v in perTechnician
|
||||||
|
join jobCount in jobCounts on v.Id equals jobCount.VendorId into vendorJobCounts
|
||||||
|
from jobCount in vendorJobCounts.DefaultIfEmpty()
|
||||||
|
select new VendorDirectoryRow
|
||||||
{
|
{
|
||||||
VendorId = v.Id,
|
VendorId = v.Id,
|
||||||
CompanyId = v.CompanyId ?? 0,
|
CompanyId = v.CompanyId ?? 0,
|
||||||
CompanyName = v.CompanyName,
|
CompanyName = v.Company != null ? v.Company.Name : v.CompanyName,
|
||||||
ContactName = v.ContactName,
|
ContactName = v.ContactName,
|
||||||
Email = v.Email != null && v.Email.Trim() != ""
|
Email = v.Email != null && v.Email.Trim() != ""
|
||||||
? v.Email
|
? v.Email
|
||||||
|
|
@ -223,17 +703,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
GoogleMapsUrl = v.GoogleMapsUrl,
|
GoogleMapsUrl = v.GoogleMapsUrl,
|
||||||
Notes = v.Company != null ? v.Company.Notes : null,
|
Notes = v.Company != null ? v.Company.Notes : null,
|
||||||
IsActive = v.IsActive,
|
IsActive = v.IsActive,
|
||||||
TotalJobs = _context.Dispatches
|
TotalJobs = (int?)jobCount.TotalJobs ?? 0
|
||||||
.Where(dispatch =>
|
};
|
||||||
dispatch.VendorId == v.Id &&
|
|
||||||
dispatch.WorkOrderId.HasValue)
|
|
||||||
.Select(dispatch => dispatch.WorkOrderId!.Value)
|
|
||||||
.Union(
|
|
||||||
_context.DispatchWorkOrders
|
|
||||||
.Where(link => link.Dispatch != null && link.Dispatch.VendorId == v.Id)
|
|
||||||
.Select(link => link.WorkOrderId))
|
|
||||||
.Count()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Arm B: company-only rows for companies with zero non-deleted technicians.
|
// Arm B: company-only rows for companies with zero non-deleted technicians.
|
||||||
// These carry a null VendorId and a populated CompanyId. A company has no
|
// These carry a null VendorId and a populated CompanyId. A company has no
|
||||||
|
|
@ -272,15 +743,70 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
union = armA.Concat(armB);
|
union = armA.Concat(armB);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ApplyDirectoryRowFilters(
|
return union;
|
||||||
union,
|
|
||||||
search,
|
|
||||||
companies,
|
|
||||||
trades,
|
|
||||||
locations,
|
|
||||||
jobBuckets);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same matching rules the directory has always applied to a projected row:
|
||||||
|
// company name, contact name, email, trades, canonical city/state, the
|
||||||
|
// canonical address only when city/state are both absent, and zip.
|
||||||
|
private static Expression BuildSearchMatchBody(ParameterExpression row, string normalizedSearch)
|
||||||
|
{
|
||||||
|
Expression ContainsLower(string propertyName)
|
||||||
|
{
|
||||||
|
var lowered = Expression.Call(
|
||||||
|
CoalescedOrEmpty(row, propertyName),
|
||||||
|
nameof(string.ToLower),
|
||||||
|
Type.EmptyTypes);
|
||||||
|
return Contains(lowered, Expression.Constant(normalizedSearch));
|
||||||
|
}
|
||||||
|
|
||||||
|
var cityBlank = IsBlank(Expression.Property(row, nameof(VendorDirectoryRow.City)));
|
||||||
|
var stateBlank = IsBlank(Expression.Property(row, nameof(VendorDirectoryRow.State)));
|
||||||
|
|
||||||
|
Expression? body = null;
|
||||||
|
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.CompanyName)));
|
||||||
|
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.ContactName)));
|
||||||
|
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.Email)));
|
||||||
|
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.TradeSpecialties)));
|
||||||
|
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.City)));
|
||||||
|
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.State)));
|
||||||
|
body = OrElse(body, Expression.AndAlso(
|
||||||
|
Expression.AndAlso(cityBlank, stateBlank),
|
||||||
|
ContainsLower(nameof(VendorDirectoryRow.Address))));
|
||||||
|
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.Zip)));
|
||||||
|
return body!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expression BuildCompanyNameMatchBody(
|
||||||
|
ParameterExpression row,
|
||||||
|
IReadOnlyCollection<string> selectedCompanies)
|
||||||
|
{
|
||||||
|
var lowered = Expression.Call(
|
||||||
|
CoalescedOrEmpty(row, nameof(VendorDirectoryRow.CompanyName)),
|
||||||
|
nameof(string.ToLower),
|
||||||
|
Type.EmptyTypes);
|
||||||
|
return Expression.Call(
|
||||||
|
typeof(Enumerable),
|
||||||
|
nameof(Enumerable.Contains),
|
||||||
|
new[] { typeof(string) },
|
||||||
|
Expression.Constant(selectedCompanies),
|
||||||
|
lowered);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expression CoalescedOrEmpty(ParameterExpression row, string propertyName) =>
|
||||||
|
Expression.Coalesce(
|
||||||
|
Expression.Property(row, propertyName),
|
||||||
|
Expression.Constant(string.Empty));
|
||||||
|
|
||||||
|
private static Expression LowerTrimmed(ParameterExpression row, string propertyName) =>
|
||||||
|
Expression.Call(
|
||||||
|
Expression.Call(
|
||||||
|
CoalescedOrEmpty(row, propertyName),
|
||||||
|
nameof(string.Trim),
|
||||||
|
Type.EmptyTypes),
|
||||||
|
nameof(string.ToLower),
|
||||||
|
Type.EmptyTypes);
|
||||||
|
|
||||||
private static IQueryable<VendorDirectoryRow> ApplyDirectoryRowFilters(
|
private static IQueryable<VendorDirectoryRow> ApplyDirectoryRowFilters(
|
||||||
IQueryable<VendorDirectoryRow> query,
|
IQueryable<VendorDirectoryRow> query,
|
||||||
string? search,
|
string? search,
|
||||||
|
|
@ -463,6 +989,15 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
IReadOnlyCollection<string> selectedTrades)
|
IReadOnlyCollection<string> selectedTrades)
|
||||||
{
|
{
|
||||||
var vendor = Expression.Parameter(typeof(T), "vendor");
|
var vendor = Expression.Parameter(typeof(T), "vendor");
|
||||||
|
return Expression.Lambda<Func<T, bool>>(
|
||||||
|
BuildTradeMatchBody<T>(vendor, selectedTrades),
|
||||||
|
vendor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expression BuildTradeMatchBody<T>(
|
||||||
|
ParameterExpression vendor,
|
||||||
|
IReadOnlyCollection<string> selectedTrades)
|
||||||
|
{
|
||||||
var specialties = Expression.Coalesce(
|
var specialties = Expression.Coalesce(
|
||||||
Expression.Property(vendor, nameof(Vendor.TradeSpecialties)),
|
Expression.Property(vendor, nameof(Vendor.TradeSpecialties)),
|
||||||
Expression.Constant(string.Empty));
|
Expression.Constant(string.Empty));
|
||||||
|
|
@ -490,13 +1025,22 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Expression.Lambda<Func<T, bool>>(body ?? Expression.Constant(false), vendor);
|
return body ?? Expression.Constant(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Expression<Func<T, bool>> BuildLocationPredicate<T>(
|
private static Expression<Func<T, bool>> BuildLocationPredicate<T>(
|
||||||
IReadOnlyCollection<string> selectedLocations)
|
IReadOnlyCollection<string> selectedLocations)
|
||||||
{
|
{
|
||||||
var vendor = Expression.Parameter(typeof(T), "vendor");
|
var vendor = Expression.Parameter(typeof(T), "vendor");
|
||||||
|
return Expression.Lambda<Func<T, bool>>(
|
||||||
|
BuildLocationMatchBody<T>(vendor, selectedLocations),
|
||||||
|
vendor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expression BuildLocationMatchBody<T>(
|
||||||
|
ParameterExpression vendor,
|
||||||
|
IReadOnlyCollection<string> selectedLocations)
|
||||||
|
{
|
||||||
var label = BuildLocationLabelBody(vendor);
|
var label = BuildLocationLabelBody(vendor);
|
||||||
var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes);
|
var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes);
|
||||||
|
|
||||||
|
|
@ -504,7 +1048,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
foreach (var location in selectedLocations)
|
foreach (var location in selectedLocations)
|
||||||
body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location)));
|
body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location)));
|
||||||
|
|
||||||
return Expression.Lambda<Func<T, bool>>(body ?? Expression.Constant(false), vendor);
|
return body ?? Expression.Constant(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Expression BuildLocationLabelBody(ParameterExpression vendor)
|
private static Expression BuildLocationLabelBody(ParameterExpression vendor)
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,8 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
IReadOnlyCollection<string>? locations = null,
|
IReadOnlyCollection<string>? locations = null,
|
||||||
IReadOnlyCollection<string>? jobBuckets = null);
|
IReadOnlyCollection<string>? jobBuckets = null);
|
||||||
|
|
||||||
// SH-198 vendor directory: per-technician rows UNION company-only rows
|
// Compatibility path for existing per-technician consumers and behavior
|
||||||
// (companies with zero non-deleted technicians). Company-only rows carry a
|
// tests. Vendor/GetVendorList uses the company-grouped path below.
|
||||||
// null VendorId and a populated CompanyId. Notes come from VendorCompany.Notes.
|
|
||||||
Task<(IReadOnlyList<VendorDirectoryRow> Items, int TotalCount)> GetDirectoryPagedAsync(
|
Task<(IReadOnlyList<VendorDirectoryRow> Items, int TotalCount)> GetDirectoryPagedAsync(
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
|
|
@ -48,6 +47,25 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
IReadOnlyCollection<string>? locations = null,
|
IReadOnlyCollection<string>? locations = null,
|
||||||
IReadOnlyCollection<string>? jobBuckets = null,
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
// SH-281 company directory: linked vendors group by real CompanyId; unlinked
|
||||||
|
// legacy vendors group by trimmed case-insensitive CompanyName (missing names
|
||||||
|
// isolated by vendor identity); company-only groups remain. Facet filters
|
||||||
|
// select a group when any status-eligible row matches; job buckets apply to
|
||||||
|
// the summed per-technician TotalJobs. Bounded group keys are selected and
|
||||||
|
// paginated in SQL, then the full status-eligible roster is fetched only for
|
||||||
|
// those keys (search/trade/location are not reapplied). TotalCount counts
|
||||||
|
// company groups.
|
||||||
|
Task<(IReadOnlyList<VendorCompanyGroup> Items, int TotalCount)> GetCompanyDirectoryPagedAsync(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
string? search = null,
|
||||||
|
bool? isActive = true,
|
||||||
|
IReadOnlyCollection<string>? companies = null,
|
||||||
|
IReadOnlyCollection<string>? trades = null,
|
||||||
|
IReadOnlyCollection<string>? locations = null,
|
||||||
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
Task<Vendor> AddAsync(Vendor vendor);
|
Task<Vendor> AddAsync(Vendor vendor);
|
||||||
Task UpdateAsync(Vendor vendor);
|
Task UpdateAsync(Vendor vendor);
|
||||||
Task DeleteAsync(int id);
|
Task DeleteAsync(int id);
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,112 @@ namespace SeaHaven.DataServices.Models
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public int TotalJobs { get; set; }
|
public int TotalJobs { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SH-281 company directory: per-row source for the grouped key query. Carries the
|
||||||
|
// company group key parts (CompanyId 0 = unlinked; NameKey groups unlinked legacy
|
||||||
|
// vendors by trimmed case-insensitive CompanyName; VendorKey isolates vendors with
|
||||||
|
// a missing name by vendor identity) plus one 0/1 match flag per facet filter so a
|
||||||
|
// group can be selected when any status-eligible row matches that facet.
|
||||||
|
internal sealed class VendorDirectoryGroupSourceRow
|
||||||
|
{
|
||||||
|
public int? VendorId { get; set; }
|
||||||
|
public int CompanyId { get; set; }
|
||||||
|
public string NameKey { get; set; } = string.Empty;
|
||||||
|
public int VendorKey { get; set; }
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
|
public int SearchFlag { get; set; }
|
||||||
|
public int CompanyFlag { get; set; }
|
||||||
|
public int TradeFlag { get; set; }
|
||||||
|
public int LocationFlag { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// SH-281: one row per company group after SQL grouping — the bounded key page.
|
||||||
|
internal sealed class VendorCompanyGroupKeyRow
|
||||||
|
{
|
||||||
|
public int CompanyId { get; set; }
|
||||||
|
public string NameKey { get; set; } = string.Empty;
|
||||||
|
public int VendorKey { get; set; }
|
||||||
|
public int? MaxVendorId { get; set; }
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
|
public int SearchMatch { get; set; }
|
||||||
|
public int CompanyMatch { get; set; }
|
||||||
|
public int TradeMatch { get; set; }
|
||||||
|
public int LocationMatch { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// SH-281: full status-eligible roster row (phase 2) carrying both the vendor's own
|
||||||
|
// technician fields and the linked VendorCompany canonical fields, so group
|
||||||
|
// assembly can apply the existing company-canonical projections in memory without
|
||||||
|
// reapplying search/trade/location filters.
|
||||||
|
internal sealed class VendorDirectoryRosterRow
|
||||||
|
{
|
||||||
|
public int VendorId { get; set; }
|
||||||
|
public int? CompanyId { get; set; }
|
||||||
|
public string? CompanyName { get; set; }
|
||||||
|
public string? CanonicalCompanyName { get; set; }
|
||||||
|
public string? CompanyEmail { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
|
public string? CompanyAddress { get; set; }
|
||||||
|
public string? CompanyCity { get; set; }
|
||||||
|
public string? CompanyState { get; set; }
|
||||||
|
public string? CompanyNotes { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? PreferredContact { get; set; }
|
||||||
|
public string? VendorCompanyPhone { get; set; }
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? State { get; set; }
|
||||||
|
public string? Zip { get; set; }
|
||||||
|
public string? TradeSpecialties { get; set; }
|
||||||
|
public string? GoogleMapsUrl { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
|
public string NameKey { get; set; } = string.Empty;
|
||||||
|
public int VendorKey { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// SH-281: one technician inside a company group.
|
||||||
|
public sealed class VendorCompanyGroupTechnician
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? PreferredContact { get; set; }
|
||||||
|
public string? TradeSpecialties { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// SH-281 company directory group: linked vendors collapse by real CompanyId;
|
||||||
|
// unlinked legacy vendors group by trimmed case-insensitive CompanyName (vendors
|
||||||
|
// with a missing name stay isolated by vendor identity); companies with zero
|
||||||
|
// non-deleted technicians remain as company-only groups. Top-level technician
|
||||||
|
// fields come from the primary technician (newest VendorId, promoted when a
|
||||||
|
// nonblank search matches a non-primary technician); company fields are
|
||||||
|
// company-canonical as the existing projections intend; TotalJobs is the sum of
|
||||||
|
// every status-eligible technician's distinct work-order count.
|
||||||
|
public sealed class VendorCompanyGroup
|
||||||
|
{
|
||||||
|
public int? Id { get; set; }
|
||||||
|
public int? CompanyId { get; set; }
|
||||||
|
public string? CompanyName { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
|
public string? PreferredContact { get; set; }
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? State { get; set; }
|
||||||
|
public string? Zip { get; set; }
|
||||||
|
public string? TradeSpecialties { get; set; }
|
||||||
|
public string? GoogleMapsUrl { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
|
public IReadOnlyList<VendorCompanyGroupTechnician> Technicians { get; set; } = Array.Empty<VendorCompanyGroupTechnician>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,10 @@ namespace SeaHaven.Services.DTOs
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// SH-198 vendor directory row. Id is the nullable vendor id (null for company-only
|
// SH-281 company directory row. Id is the primary technician's vendor id (null
|
||||||
// rows that have zero technicians); CompanyId is always populated.
|
// for company-only groups that have zero technicians); CompanyId is populated for
|
||||||
|
// linked and company-only groups. Technicians carries the full status-eligible
|
||||||
|
// roster of the company group.
|
||||||
public class VendorDirectoryItemDTO
|
public class VendorDirectoryItemDTO
|
||||||
{
|
{
|
||||||
public int? Id { get; set; }
|
public int? Id { get; set; }
|
||||||
|
|
@ -42,6 +44,20 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public int TotalJobs { get; set; }
|
public int TotalJobs { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
|
public List<VendorDirectoryTechnicianDTO> Technicians { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// SH-281: one technician inside a company group; Email/Phone stay technician-owned.
|
||||||
|
public class VendorDirectoryTechnicianDTO
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? PreferredContact { get; set; }
|
||||||
|
public string? TradeSpecialties { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CreateVendorDTO
|
public class CreateVendorDTO
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
public class VendorService : IVendorService
|
public class VendorService : IVendorService
|
||||||
{
|
{
|
||||||
|
private const int MaxDirectoryPageSize = 100;
|
||||||
private static readonly string[] TerminalLegacyStatuses =
|
private static readonly string[] TerminalLegacyStatuses =
|
||||||
{ "completed", "cancelled", "canceled" };
|
{ "completed", "cancelled", "canceled" };
|
||||||
|
|
||||||
|
|
@ -105,9 +106,11 @@ namespace SeaHaven.Services.Implementation
|
||||||
IReadOnlyCollection<string>? jobBuckets = null,
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var (items, totalCount) = await _vendorDataService.GetDirectoryPagedAsync(
|
var normalizedPage = Math.Max(1, page);
|
||||||
page,
|
var normalizedPageSize = Math.Clamp(pageSize, 1, MaxDirectoryPageSize);
|
||||||
pageSize,
|
var (items, totalCount) = await _vendorDataService.GetCompanyDirectoryPagedAsync(
|
||||||
|
normalizedPage,
|
||||||
|
normalizedPageSize,
|
||||||
search,
|
search,
|
||||||
isActive,
|
isActive,
|
||||||
companies,
|
companies,
|
||||||
|
|
@ -118,32 +121,45 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
return new PagedResult<VendorDirectoryItemDTO>
|
return new PagedResult<VendorDirectoryItemDTO>
|
||||||
{
|
{
|
||||||
Items = items.Select(MapToDirectoryItem).ToList(),
|
Items = items.Select(MapToCompanyGroup).ToList(),
|
||||||
TotalCount = totalCount,
|
TotalCount = totalCount,
|
||||||
Page = page,
|
Page = normalizedPage,
|
||||||
PageSize = pageSize
|
PageSize = normalizedPageSize
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static VendorDirectoryItemDTO MapToDirectoryItem(VendorDirectoryRow row) => new()
|
private static VendorDirectoryItemDTO MapToCompanyGroup(VendorCompanyGroup group) => new()
|
||||||
{
|
{
|
||||||
Id = row.VendorId,
|
Id = group.Id,
|
||||||
CompanyId = row.CompanyId == 0 ? null : row.CompanyId,
|
CompanyId = group.CompanyId,
|
||||||
CompanyName = row.CompanyName,
|
CompanyName = group.CompanyName,
|
||||||
ContactName = row.ContactName,
|
ContactName = group.ContactName,
|
||||||
Email = row.Email,
|
Email = group.Email,
|
||||||
Phone = row.Phone,
|
Phone = group.Phone,
|
||||||
CompanyPhone = row.CompanyPhone,
|
CompanyPhone = group.CompanyPhone,
|
||||||
PreferredContact = row.PreferredContact,
|
PreferredContact = group.PreferredContact,
|
||||||
Address = row.Address,
|
Address = group.Address,
|
||||||
City = row.City,
|
City = group.City,
|
||||||
State = row.State,
|
State = group.State,
|
||||||
Zipcode = row.Zip,
|
Zipcode = group.Zip,
|
||||||
TradeSpecialties = row.TradeSpecialties,
|
TradeSpecialties = group.TradeSpecialties,
|
||||||
GoogleMapsUrl = row.GoogleMapsUrl,
|
GoogleMapsUrl = group.GoogleMapsUrl,
|
||||||
Notes = row.Notes,
|
Notes = group.Notes,
|
||||||
IsActive = row.IsActive,
|
IsActive = group.IsActive,
|
||||||
TotalJobs = row.TotalJobs
|
TotalJobs = group.TotalJobs,
|
||||||
|
Technicians = group.Technicians.Select(MapToTechnician).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
private static VendorDirectoryTechnicianDTO MapToTechnician(VendorCompanyGroupTechnician technician) => new()
|
||||||
|
{
|
||||||
|
Id = technician.Id,
|
||||||
|
ContactName = technician.ContactName,
|
||||||
|
Email = technician.Email,
|
||||||
|
Phone = technician.Phone,
|
||||||
|
PreferredContact = technician.PreferredContact,
|
||||||
|
TradeSpecialties = technician.TradeSpecialties,
|
||||||
|
IsActive = technician.IsActive,
|
||||||
|
TotalJobs = technician.TotalJobs
|
||||||
};
|
};
|
||||||
|
|
||||||
public async Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId)
|
public async Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue