2026-04-28 18:55:14 -05:00
|
|
|
using Data.SeaHavenIndustries;
|
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
2026-07-23 12:55:47 -03:00
|
|
|
using System.Linq.Expressions;
|
|
|
|
|
using System.Reflection;
|
2026-04-28 18:55:14 -05:00
|
|
|
using SeaHaven.DataServices.Interfaces;
|
2026-08-03 17:53:24 -03:00
|
|
|
using SeaHaven.DataServices.Models;
|
2026-04-28 18:55:14 -05:00
|
|
|
|
|
|
|
|
namespace SeaHaven.DataServices.Implementation
|
|
|
|
|
{
|
|
|
|
|
public class VendorDataService : IVendorDataService
|
|
|
|
|
{
|
|
|
|
|
private readonly ApplicationDbContext _context;
|
|
|
|
|
|
|
|
|
|
public VendorDataService(ApplicationDbContext context)
|
|
|
|
|
{
|
|
|
|
|
_context = context;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<Vendor?> GetByIdAsync(int id)
|
|
|
|
|
{
|
2026-07-23 12:55:47 -03:00
|
|
|
return await _context.Vendors.FirstOrDefaultAsync(v => v.Id == id);
|
2026-04-28 18:55:14 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<Vendor?> GetByIdWithDetailsAsync(int id)
|
|
|
|
|
{
|
2026-07-23 12:55:47 -03:00
|
|
|
return await ProjectWithTotalJobs(
|
|
|
|
|
_context.Vendors
|
|
|
|
|
.AsNoTracking()
|
|
|
|
|
.Where(v => v.Id == id))
|
|
|
|
|
.FirstOrDefaultAsync();
|
2026-04-28 18:55:14 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-24 17:35:34 -03:00
|
|
|
public async Task<IReadOnlyList<Vendor>> GetByIdsOrderedAsync(IReadOnlyCollection<int> ids, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var orderedIds = ids.Distinct().ToList();
|
|
|
|
|
if (orderedIds.Count == 0)
|
|
|
|
|
return Array.Empty<Vendor>();
|
|
|
|
|
|
|
|
|
|
var loaded = await _context.Vendors
|
|
|
|
|
.AsNoTracking()
|
|
|
|
|
.Where(v => orderedIds.Contains(v.Id))
|
|
|
|
|
.ToListAsync(cancellationToken);
|
|
|
|
|
|
|
|
|
|
var byId = loaded.ToDictionary(v => v.Id);
|
|
|
|
|
var ordered = new List<Vendor>(orderedIds.Count);
|
|
|
|
|
foreach (var id in orderedIds)
|
|
|
|
|
{
|
|
|
|
|
if (byId.TryGetValue(id, out var vendor))
|
|
|
|
|
ordered.Add(vendor);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ordered;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 18:55:14 -05:00
|
|
|
public async Task<IEnumerable<Vendor>> GetAllAsync()
|
|
|
|
|
{
|
|
|
|
|
return await _context.Vendors.ToListAsync();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 17:35:34 -03:00
|
|
|
public async Task<IReadOnlyList<Vendor>> GetActiveVendorsAsync(CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
return await _context.Vendors
|
|
|
|
|
.AsNoTracking()
|
|
|
|
|
.Where(v => v.IsActive)
|
|
|
|
|
.OrderBy(v => v.CompanyName)
|
|
|
|
|
.ToListAsync(cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 12:11:13 -03:00
|
|
|
public async Task<IReadOnlyList<Trade>> GetActiveTradesAsync(CancellationToken cancellationToken)
|
2026-07-24 17:35:34 -03:00
|
|
|
{
|
2026-08-18 12:11:13 -03:00
|
|
|
return await _context.Trades
|
2026-07-24 17:35:34 -03:00
|
|
|
.AsNoTracking()
|
2026-08-18 12:11:13 -03:00
|
|
|
.Where(t => t.IsActive)
|
|
|
|
|
.OrderBy(t => t.SortOrder)
|
|
|
|
|
.ThenBy(t => t.Name)
|
2026-07-24 17:35:34 -03:00
|
|
|
.ToListAsync(cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 17:53:24 -03:00
|
|
|
internal IQueryable<VendorCompany> BuildCompaniesForFacetsQuery(bool? isActive)
|
|
|
|
|
{
|
|
|
|
|
var query = _context.VendorCompanies
|
|
|
|
|
.AsNoTracking();
|
|
|
|
|
|
|
|
|
|
if (isActive == true)
|
|
|
|
|
{
|
|
|
|
|
// Active: companies with zero non-deleted technicians (company-only rows)
|
|
|
|
|
// or at least one active non-deleted technician.
|
|
|
|
|
query = query.Where(c =>
|
|
|
|
|
!_context.Vendors.Any(v =>
|
|
|
|
|
v.CompanyId == c.Id && (v.IsDeleted == null || v.IsDeleted == false))
|
|
|
|
|
|| _context.Vendors.Any(v =>
|
|
|
|
|
v.CompanyId == c.Id
|
|
|
|
|
&& (v.IsDeleted == null || v.IsDeleted == false)
|
|
|
|
|
&& v.IsActive));
|
|
|
|
|
}
|
|
|
|
|
else if (isActive == false)
|
|
|
|
|
{
|
|
|
|
|
// Inactive: companies with at least one inactive non-deleted technician.
|
|
|
|
|
query = query.Where(c =>
|
|
|
|
|
_context.Vendors.Any(v =>
|
|
|
|
|
v.CompanyId == c.Id
|
|
|
|
|
&& (v.IsDeleted == null || v.IsDeleted == false)
|
|
|
|
|
&& !v.IsActive));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return query.OrderBy(c => c.Id);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 11:34:45 -03:00
|
|
|
public async Task<IReadOnlyList<Area>> GetActiveAreasAsync(CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
return await _context.Areas
|
|
|
|
|
.AsNoTracking()
|
|
|
|
|
.Where(a => a.IsActive)
|
|
|
|
|
.OrderBy(a => a.Id)
|
|
|
|
|
.ToListAsync(cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 17:53:24 -03:00
|
|
|
public async Task<IReadOnlyList<VendorCompany>> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken)
|
|
|
|
|
=> await BuildCompaniesForFacetsQuery(isActive).ToListAsync(cancellationToken);
|
|
|
|
|
|
2026-04-28 18:55:14 -05:00
|
|
|
public async Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
2026-05-06 10:49:33 -05:00
|
|
|
int page,
|
|
|
|
|
int pageSize,
|
2026-07-23 12:55:47 -03:00
|
|
|
string? search = null,
|
|
|
|
|
bool? isActive = true,
|
|
|
|
|
IReadOnlyCollection<string>? companies = null,
|
|
|
|
|
IReadOnlyCollection<string>? trades = null,
|
|
|
|
|
IReadOnlyCollection<string>? locations = null,
|
|
|
|
|
IReadOnlyCollection<string>? jobBuckets = null)
|
2026-04-28 18:55:14 -05:00
|
|
|
{
|
2026-07-23 12:55:47 -03:00
|
|
|
var projectedQuery = BuildDirectoryQuery(
|
|
|
|
|
search,
|
|
|
|
|
isActive,
|
|
|
|
|
companies,
|
|
|
|
|
trades,
|
|
|
|
|
locations,
|
|
|
|
|
jobBuckets);
|
2026-05-06 10:49:33 -05:00
|
|
|
|
2026-07-23 12:55:47 -03:00
|
|
|
var totalCount = await projectedQuery.CountAsync();
|
|
|
|
|
var items = await projectedQuery
|
|
|
|
|
.OrderByDescending(v => v.Id)
|
|
|
|
|
.Skip((page - 1) * pageSize)
|
|
|
|
|
.Take(pageSize)
|
|
|
|
|
.ToListAsync();
|
|
|
|
|
|
|
|
|
|
return (items, totalCount);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 17:53:24 -03:00
|
|
|
public async Task<(IReadOnlyList<VendorDirectoryRow> Items, int TotalCount)> GetDirectoryPagedAsync(
|
|
|
|
|
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)
|
|
|
|
|
{
|
|
|
|
|
var filtered = BuildDirectoryUnionQuery(
|
|
|
|
|
search,
|
|
|
|
|
isActive,
|
|
|
|
|
companies,
|
|
|
|
|
trades,
|
|
|
|
|
locations,
|
|
|
|
|
jobBuckets);
|
|
|
|
|
|
|
|
|
|
var totalCount = await filtered.CountAsync(cancellationToken);
|
|
|
|
|
var items = await filtered
|
|
|
|
|
.OrderByDescending(row => row.VendorId.HasValue)
|
|
|
|
|
.ThenByDescending(row => row.VendorId ?? 0)
|
|
|
|
|
.ThenByDescending(row => row.CompanyId)
|
|
|
|
|
.Skip((page - 1) * pageSize)
|
|
|
|
|
.Take(pageSize)
|
|
|
|
|
.ToListAsync(cancellationToken);
|
|
|
|
|
|
|
|
|
|
return (items, totalCount);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 16:02:44 -03:00
|
|
|
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,
|
2026-09-16 11:34:45 -03:00
|
|
|
VendorAreaFilter? areaFilter = null,
|
2026-09-15 16:02:44 -03:00
|
|
|
CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
// Phase 1: select and paginate bounded company group keys in SQL.
|
|
|
|
|
var keyQuery = BuildCompanyDirectoryKeysQuery(
|
|
|
|
|
search,
|
|
|
|
|
isActive,
|
|
|
|
|
companies,
|
|
|
|
|
trades,
|
|
|
|
|
locations,
|
2026-09-16 11:34:45 -03:00
|
|
|
jobBuckets,
|
|
|
|
|
areaFilter);
|
2026-09-15 16:02:44 -03:00
|
|
|
|
|
|
|
|
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()
|
2026-09-16 11:34:45 -03:00
|
|
|
.Include(c => c.Area)
|
2026-09-15 16:02:44 -03:00
|
|
|
.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,
|
2026-09-16 11:34:45 -03:00
|
|
|
AreaId = linked ? primary.CompanyAreaId : null,
|
|
|
|
|
AreaName = linked ? primary.CompanyAreaName : null,
|
2026-09-15 16:02:44 -03:00
|
|
|
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,
|
2026-09-16 11:34:45 -03:00
|
|
|
AreaId = company.AreaId,
|
|
|
|
|
AreaName = company.Area?.Name,
|
2026-09-15 16:02:44 -03:00
|
|
|
IsActive = true,
|
|
|
|
|
TotalJobs = 0,
|
|
|
|
|
Technicians = Array.Empty<VendorCompanyGroupTechnician>()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
internal IQueryable<VendorCompanyGroupKeyRow> BuildCompanyDirectoryKeysQuery(
|
2026-08-03 17:53:24 -03:00
|
|
|
string? search = null,
|
|
|
|
|
bool? isActive = true,
|
|
|
|
|
IReadOnlyCollection<string>? companies = null,
|
|
|
|
|
IReadOnlyCollection<string>? trades = null,
|
|
|
|
|
IReadOnlyCollection<string>? locations = null,
|
2026-09-16 11:34:45 -03:00
|
|
|
IReadOnlyCollection<string>? jobBuckets = null,
|
|
|
|
|
VendorAreaFilter? areaFilter = null)
|
2026-09-15 16:02:44 -03:00
|
|
|
{
|
|
|
|
|
var sourceRows = BuildDirectoryUnionBaseQuery(isActive)
|
2026-09-16 11:34:45 -03:00
|
|
|
.Select(BuildGroupSourceRowSelector(search, companies, trades, locations, areaFilter));
|
2026-09-15 16:02:44 -03:00
|
|
|
|
|
|
|
|
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),
|
2026-09-16 11:34:45 -03:00
|
|
|
LocationMatch = group.Max(row => row.LocationFlag),
|
|
|
|
|
AreaMatch = group.Max(row => row.AreaFlag)
|
2026-09-15 16:02:44 -03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
2026-09-16 11:34:45 -03:00
|
|
|
if (areaFilter != null)
|
|
|
|
|
grouped = grouped.Where(key => key.AreaMatch == 1);
|
|
|
|
|
|
2026-09-15 16:02:44 -03:00
|
|
|
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,
|
2026-09-16 11:34:45 -03:00
|
|
|
IReadOnlyCollection<string>? locations,
|
|
|
|
|
VendorAreaFilter? areaFilter)
|
2026-09-15 16:02:44 -03:00
|
|
|
{
|
|
|
|
|
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;
|
2026-09-16 11:34:45 -03:00
|
|
|
var areaBody = areaFilter == null
|
|
|
|
|
? null
|
|
|
|
|
: BuildAreaMatchBody(row, areaFilter);
|
2026-09-15 16:02:44 -03:00
|
|
|
|
|
|
|
|
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)),
|
2026-09-16 11:34:45 -03:00
|
|
|
Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody)),
|
|
|
|
|
Bind(nameof(VendorDirectoryGroupSourceRow.AreaFlag), Flag(areaBody))),
|
2026-09-15 16:02:44 -03:00
|
|
|
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,
|
2026-09-16 11:34:45 -03:00
|
|
|
CompanyAreaId = v.Company != null ? v.Company.AreaId : null,
|
|
|
|
|
CompanyAreaName = v.Company != null && v.Company.Area != null ? v.Company.Area.Name : null,
|
2026-09-15 16:02:44 -03:00
|
|
|
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)
|
2026-08-03 17:53:24 -03:00
|
|
|
{
|
|
|
|
|
// Arm A: existing per-technician rows (every non-deleted vendor, including
|
|
|
|
|
// those with no company). Notes come from the linked VendorCompany.
|
|
|
|
|
var perTechnician = _context.Vendors
|
|
|
|
|
.AsNoTracking()
|
|
|
|
|
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
|
|
|
|
|
|
|
|
|
if (isActive.HasValue)
|
|
|
|
|
perTechnician = perTechnician.Where(v => v.IsActive == isActive.Value);
|
|
|
|
|
|
2026-09-15 16:02:44 -03:00
|
|
|
// 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,
|
|
|
|
|
CompanyId = v.CompanyId ?? 0,
|
|
|
|
|
CompanyName = v.Company != null ? v.Company.Name : v.CompanyName,
|
|
|
|
|
ContactName = v.ContactName,
|
|
|
|
|
Email = v.Email != null && v.Email.Trim() != ""
|
2026-08-21 09:32:58 -03:00
|
|
|
? v.Email
|
|
|
|
|
: v.Company != null
|
|
|
|
|
? v.Company.Email
|
|
|
|
|
: v.Email,
|
2026-09-15 16:02:44 -03:00
|
|
|
Phone = v.Phone,
|
|
|
|
|
CompanyPhone = v.Company != null &&
|
2026-08-25 20:33:47 -03:00
|
|
|
v.Company.CompanyPhone != null && v.Company.CompanyPhone.Trim() != ""
|
|
|
|
|
? v.Company.CompanyPhone
|
|
|
|
|
: v.CompanyPhone,
|
2026-09-15 16:02:44 -03:00
|
|
|
PreferredContact = v.PreferredContact,
|
|
|
|
|
Address = v.Company != null &&
|
2026-08-25 20:33:47 -03:00
|
|
|
v.Company.Address != null && v.Company.Address.Trim() != ""
|
|
|
|
|
? v.Company.Address
|
|
|
|
|
: v.Address,
|
2026-09-15 16:02:44 -03:00
|
|
|
City = v.Company != null &&
|
2026-08-21 09:40:51 -03:00
|
|
|
((v.Company.City != null && v.Company.City.Trim() != "") ||
|
2026-08-25 20:33:47 -03:00
|
|
|
(v.Company.State != null && v.Company.State.Trim() != "") ||
|
|
|
|
|
(v.Company.Address != null && v.Company.Address.Trim() != ""))
|
2026-08-21 09:32:58 -03:00
|
|
|
? v.Company.City
|
|
|
|
|
: v.City,
|
2026-09-15 16:02:44 -03:00
|
|
|
State = v.Company != null &&
|
2026-08-21 09:40:51 -03:00
|
|
|
((v.Company.City != null && v.Company.City.Trim() != "") ||
|
2026-08-25 20:33:47 -03:00
|
|
|
(v.Company.State != null && v.Company.State.Trim() != "") ||
|
|
|
|
|
(v.Company.Address != null && v.Company.Address.Trim() != ""))
|
2026-08-21 09:32:58 -03:00
|
|
|
? v.Company.State
|
|
|
|
|
: v.State,
|
2026-09-15 16:02:44 -03:00
|
|
|
Zip = v.Zip,
|
|
|
|
|
TradeSpecialties = v.TradeSpecialties,
|
|
|
|
|
GoogleMapsUrl = v.GoogleMapsUrl,
|
|
|
|
|
Notes = v.Company != null ? v.Company.Notes : null,
|
2026-09-16 11:34:45 -03:00
|
|
|
AreaId = v.Company != null ? v.Company.AreaId : null,
|
2026-09-15 16:02:44 -03:00
|
|
|
IsActive = v.IsActive,
|
|
|
|
|
TotalJobs = (int?)jobCount.TotalJobs ?? 0
|
|
|
|
|
};
|
2026-08-03 17:53:24 -03:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
// active/inactive flag of its own, so it is shown unless the caller explicitly
|
|
|
|
|
// asks for inactive vendors only.
|
|
|
|
|
var includeCompanyRows = !isActive.HasValue || isActive.Value;
|
|
|
|
|
var union = armA;
|
|
|
|
|
|
|
|
|
|
if (includeCompanyRows)
|
|
|
|
|
{
|
|
|
|
|
var armB = _context.VendorCompanies
|
|
|
|
|
.AsNoTracking()
|
|
|
|
|
.Where(c => !_context.Vendors
|
|
|
|
|
.Any(v => v.CompanyId == c.Id && (v.IsDeleted == null || v.IsDeleted == false)))
|
|
|
|
|
.Select(c => new VendorDirectoryRow
|
|
|
|
|
{
|
|
|
|
|
VendorId = (int?)null,
|
|
|
|
|
CompanyId = c.Id,
|
|
|
|
|
CompanyName = c.Name,
|
|
|
|
|
ContactName = null,
|
|
|
|
|
Email = c.Email,
|
|
|
|
|
Phone = null,
|
|
|
|
|
CompanyPhone = c.CompanyPhone,
|
|
|
|
|
PreferredContact = null,
|
|
|
|
|
Address = c.Address,
|
|
|
|
|
City = c.City,
|
|
|
|
|
State = c.State,
|
|
|
|
|
Zip = c.Zip,
|
|
|
|
|
TradeSpecialties = null,
|
|
|
|
|
GoogleMapsUrl = c.GoogleMapsUrl,
|
|
|
|
|
Notes = c.Notes,
|
2026-09-16 11:34:45 -03:00
|
|
|
AreaId = c.AreaId,
|
2026-08-03 17:53:24 -03:00
|
|
|
IsActive = true,
|
|
|
|
|
TotalJobs = 0
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
union = armA.Concat(armB);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 16:02:44 -03:00
|
|
|
return union;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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!;
|
2026-08-03 17:53:24 -03:00
|
|
|
}
|
|
|
|
|
|
2026-09-16 11:34:45 -03:00
|
|
|
// SH-278: OR within the Area facet. Selected ids match the company's stable
|
|
|
|
|
// AreaId; the Unassigned sentinel matches rows without an Area, which includes
|
|
|
|
|
// unlinked legacy vendors that have no VendorCompany at all. A filter with no
|
|
|
|
|
// resolvable ids and no Unassigned matches nobody.
|
|
|
|
|
private static Expression BuildAreaMatchBody(ParameterExpression row, VendorAreaFilter areaFilter)
|
|
|
|
|
{
|
|
|
|
|
var areaId = Expression.Property(row, nameof(VendorDirectoryRow.AreaId));
|
|
|
|
|
Expression? body = null;
|
|
|
|
|
|
|
|
|
|
if (areaFilter.AreaIds.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
var ids = areaFilter.AreaIds.Distinct().Select(id => (int?)id).ToArray();
|
|
|
|
|
body = OrElse(body, Expression.Call(
|
|
|
|
|
typeof(Enumerable),
|
|
|
|
|
nameof(Enumerable.Contains),
|
|
|
|
|
new[] { typeof(int?) },
|
|
|
|
|
Expression.Constant(ids),
|
|
|
|
|
areaId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (areaFilter.IncludeUnassigned)
|
|
|
|
|
body = OrElse(body, Expression.Equal(areaId, Expression.Constant(null, typeof(int?))));
|
|
|
|
|
|
|
|
|
|
return body ?? Expression.Constant(false);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 16:02:44 -03:00
|
|
|
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);
|
|
|
|
|
|
2026-08-03 17:53:24 -03:00
|
|
|
private static IQueryable<VendorDirectoryRow> ApplyDirectoryRowFilters(
|
|
|
|
|
IQueryable<VendorDirectoryRow> query,
|
|
|
|
|
string? search,
|
|
|
|
|
IReadOnlyCollection<string>? companies,
|
|
|
|
|
IReadOnlyCollection<string>? trades,
|
|
|
|
|
IReadOnlyCollection<string>? locations,
|
|
|
|
|
IReadOnlyCollection<string>? jobBuckets)
|
|
|
|
|
{
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(search))
|
|
|
|
|
{
|
|
|
|
|
var normalizedSearch = search.ToLower();
|
|
|
|
|
query = query.Where(row =>
|
|
|
|
|
(row.CompanyName ?? "").ToLower().Contains(normalizedSearch) ||
|
|
|
|
|
(row.ContactName ?? "").ToLower().Contains(normalizedSearch) ||
|
|
|
|
|
(row.Email ?? "").ToLower().Contains(normalizedSearch) ||
|
|
|
|
|
(row.TradeSpecialties ?? "").ToLower().Contains(normalizedSearch) ||
|
|
|
|
|
(row.City ?? "").ToLower().Contains(normalizedSearch) ||
|
|
|
|
|
(row.State ?? "").ToLower().Contains(normalizedSearch) ||
|
2026-08-25 20:33:47 -03:00
|
|
|
(((row.City == null || row.City.Trim() == "") &&
|
|
|
|
|
(row.State == null || row.State.Trim() == "")) &&
|
|
|
|
|
(row.Address ?? "").ToLower().Contains(normalizedSearch)) ||
|
2026-08-03 17:53:24 -03:00
|
|
|
(row.Zip ?? "").ToLower().Contains(normalizedSearch));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var selectedCompanies = companies?
|
|
|
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
|
|
|
.Select(value => value.Trim().ToLower())
|
|
|
|
|
.Distinct()
|
|
|
|
|
.ToArray() ?? Array.Empty<string>();
|
|
|
|
|
if (selectedCompanies.Length > 0)
|
|
|
|
|
query = query.Where(row =>
|
|
|
|
|
selectedCompanies.Contains((row.CompanyName ?? "").ToLower()));
|
|
|
|
|
|
|
|
|
|
var selectedTrades = trades?
|
|
|
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
|
|
|
.Select(value => value.Trim().ToLower())
|
|
|
|
|
.Distinct()
|
|
|
|
|
.ToArray() ?? Array.Empty<string>();
|
|
|
|
|
if (selectedTrades.Length > 0)
|
|
|
|
|
query = query.Where(BuildTradePredicate<VendorDirectoryRow>(selectedTrades));
|
|
|
|
|
|
|
|
|
|
var selectedLocations = locations?
|
|
|
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
|
|
|
.Select(value => value.Trim().ToLower())
|
|
|
|
|
.Distinct()
|
|
|
|
|
.ToArray() ?? Array.Empty<string>();
|
|
|
|
|
if (selectedLocations.Length > 0)
|
|
|
|
|
query = query.Where(BuildLocationPredicate<VendorDirectoryRow>(selectedLocations));
|
|
|
|
|
|
|
|
|
|
var selectedJobBuckets = jobBuckets?
|
|
|
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
|
|
|
.Select(value => value.Trim().ToLower())
|
|
|
|
|
.Distinct()
|
|
|
|
|
.ToArray() ?? Array.Empty<string>();
|
|
|
|
|
if (selectedJobBuckets.Length == 0)
|
|
|
|
|
return query;
|
|
|
|
|
|
|
|
|
|
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 query.Where(row =>
|
|
|
|
|
(under50 && row.TotalJobs < 50) ||
|
|
|
|
|
(from50To99 && row.TotalJobs >= 50 && row.TotalJobs <= 99) ||
|
|
|
|
|
(from100To149 && row.TotalJobs >= 100 && row.TotalJobs <= 149) ||
|
|
|
|
|
(from150 && row.TotalJobs >= 150));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 12:55:47 -03:00
|
|
|
internal IQueryable<Vendor> BuildDirectoryQuery(
|
|
|
|
|
string? search = null,
|
|
|
|
|
bool? isActive = true,
|
|
|
|
|
IReadOnlyCollection<string>? companies = null,
|
|
|
|
|
IReadOnlyCollection<string>? trades = null,
|
|
|
|
|
IReadOnlyCollection<string>? locations = null,
|
|
|
|
|
IReadOnlyCollection<string>? jobBuckets = null)
|
|
|
|
|
{
|
|
|
|
|
var query = _context.Vendors
|
|
|
|
|
.AsNoTracking()
|
|
|
|
|
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
|
|
|
|
|
|
|
|
|
if (isActive.HasValue)
|
|
|
|
|
query = query.Where(v => v.IsActive == isActive.Value);
|
2026-05-06 10:49:33 -05:00
|
|
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(search))
|
|
|
|
|
{
|
2026-07-23 12:55:47 -03:00
|
|
|
var normalizedSearch = search.ToLower();
|
2026-05-06 10:49:33 -05:00
|
|
|
query = query.Where(v =>
|
2026-07-23 12:55:47 -03:00
|
|
|
(v.CompanyName ?? "").ToLower().Contains(normalizedSearch) ||
|
|
|
|
|
(v.ContactName ?? "").ToLower().Contains(normalizedSearch) ||
|
|
|
|
|
(v.Email ?? "").ToLower().Contains(normalizedSearch) ||
|
|
|
|
|
(v.TradeSpecialties ?? "").ToLower().Contains(normalizedSearch) ||
|
|
|
|
|
(v.City ?? "").ToLower().Contains(normalizedSearch) ||
|
|
|
|
|
(v.State ?? "").ToLower().Contains(normalizedSearch) ||
|
|
|
|
|
(v.Zip ?? "").ToLower().Contains(normalizedSearch));
|
2026-05-06 10:49:33 -05:00
|
|
|
}
|
2026-04-28 18:55:14 -05:00
|
|
|
|
2026-07-23 12:55:47 -03:00
|
|
|
var selectedCompanies = companies?
|
|
|
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
|
|
|
.Select(value => value.Trim().ToLower())
|
|
|
|
|
.Distinct()
|
|
|
|
|
.ToArray() ?? Array.Empty<string>();
|
|
|
|
|
if (selectedCompanies.Length > 0)
|
|
|
|
|
query = query.Where(v =>
|
|
|
|
|
selectedCompanies.Contains((v.CompanyName ?? "").ToLower()));
|
2026-04-28 18:55:14 -05:00
|
|
|
|
2026-07-23 12:55:47 -03:00
|
|
|
var selectedTrades = trades?
|
|
|
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
|
|
|
.Select(value => value.Trim().ToLower())
|
|
|
|
|
.Distinct()
|
|
|
|
|
.ToArray() ?? Array.Empty<string>();
|
|
|
|
|
if (selectedTrades.Length > 0)
|
2026-08-03 17:53:24 -03:00
|
|
|
query = query.Where(BuildTradePredicate<Vendor>(selectedTrades));
|
2026-07-23 12:55:47 -03:00
|
|
|
|
|
|
|
|
var selectedLocations = locations?
|
|
|
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
|
|
|
.Select(value => value.Trim().ToLower())
|
|
|
|
|
.Distinct()
|
|
|
|
|
.ToArray() ?? Array.Empty<string>();
|
|
|
|
|
if (selectedLocations.Length > 0)
|
2026-08-03 17:53:24 -03:00
|
|
|
query = query.Where(BuildLocationPredicate<Vendor>(selectedLocations));
|
2026-07-23 12:55:47 -03:00
|
|
|
|
|
|
|
|
var result = ProjectWithTotalJobs(query);
|
|
|
|
|
var selectedJobBuckets = jobBuckets?
|
|
|
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
|
|
|
.Select(value => value.Trim().ToLower())
|
|
|
|
|
.Distinct()
|
|
|
|
|
.ToArray() ?? Array.Empty<string>();
|
|
|
|
|
if (selectedJobBuckets.Length == 0)
|
|
|
|
|
return result;
|
|
|
|
|
|
|
|
|
|
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 result.Where(v =>
|
|
|
|
|
(under50 && v.TotalJobs < 50) ||
|
|
|
|
|
(from50To99 && v.TotalJobs >= 50 && v.TotalJobs <= 99) ||
|
|
|
|
|
(from100To149 && v.TotalJobs >= 100 && v.TotalJobs <= 149) ||
|
|
|
|
|
(from150 && v.TotalJobs >= 150));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private IQueryable<Vendor> ProjectWithTotalJobs(IQueryable<Vendor> query) =>
|
|
|
|
|
query.Select(v => new Vendor
|
|
|
|
|
{
|
|
|
|
|
Id = v.Id,
|
|
|
|
|
CompanyName = v.CompanyName,
|
2026-07-23 17:02:40 -03:00
|
|
|
CompanyId = v.CompanyId,
|
2026-07-23 12:55:47 -03:00
|
|
|
ContactName = v.ContactName,
|
|
|
|
|
Email = v.Email,
|
|
|
|
|
Phone = v.Phone,
|
|
|
|
|
CompanyPhone = v.CompanyPhone,
|
2026-07-23 17:02:40 -03:00
|
|
|
PreferredContact = v.PreferredContact,
|
2026-07-23 12:55:47 -03:00
|
|
|
Address = v.Address,
|
|
|
|
|
City = v.City,
|
|
|
|
|
State = v.State,
|
|
|
|
|
Zip = v.Zip,
|
|
|
|
|
TradeSpecialties = v.TradeSpecialties,
|
|
|
|
|
GoogleMapsUrl = v.GoogleMapsUrl,
|
|
|
|
|
Notes = v.Notes,
|
|
|
|
|
IsActive = v.IsActive,
|
|
|
|
|
IsDeleted = v.IsDeleted,
|
|
|
|
|
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()
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
private static readonly MethodInfo StringConcatMethod =
|
|
|
|
|
typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!;
|
|
|
|
|
|
2026-08-03 17:53:24 -03:00
|
|
|
private static Expression<Func<T, bool>> BuildTradePredicate<T>(
|
2026-07-23 12:55:47 -03:00
|
|
|
IReadOnlyCollection<string> selectedTrades)
|
|
|
|
|
{
|
2026-08-03 17:53:24 -03:00
|
|
|
var vendor = Expression.Parameter(typeof(T), "vendor");
|
2026-09-15 16:02:44 -03:00
|
|
|
return Expression.Lambda<Func<T, bool>>(
|
|
|
|
|
BuildTradeMatchBody<T>(vendor, selectedTrades),
|
|
|
|
|
vendor);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static Expression BuildTradeMatchBody<T>(
|
|
|
|
|
ParameterExpression vendor,
|
|
|
|
|
IReadOnlyCollection<string> selectedTrades)
|
|
|
|
|
{
|
2026-07-23 12:55:47 -03:00
|
|
|
var specialties = Expression.Coalesce(
|
|
|
|
|
Expression.Property(vendor, nameof(Vendor.TradeSpecialties)),
|
|
|
|
|
Expression.Constant(string.Empty));
|
|
|
|
|
var lowered = Expression.Call(specialties, nameof(string.ToLower), Type.EmptyTypes);
|
|
|
|
|
|
|
|
|
|
var delimiters = new[] { ", ", "," };
|
|
|
|
|
Expression? body = null;
|
|
|
|
|
|
|
|
|
|
foreach (var trade in selectedTrades)
|
|
|
|
|
{
|
|
|
|
|
var token = Expression.Constant(trade);
|
|
|
|
|
|
|
|
|
|
body = OrElse(body, Expression.Equal(lowered, token));
|
|
|
|
|
|
|
|
|
|
foreach (var delimiter in delimiters)
|
|
|
|
|
{
|
|
|
|
|
var delim = Expression.Constant(delimiter);
|
|
|
|
|
var tokenWithTrailing = Concat(token, delim);
|
|
|
|
|
var leadingToken = Concat(delim, token);
|
|
|
|
|
var wrappedToken = Concat(leadingToken, delim);
|
|
|
|
|
|
|
|
|
|
body = OrElse(body, StartsWith(lowered, tokenWithTrailing));
|
|
|
|
|
body = OrElse(body, EndsWith(lowered, leadingToken));
|
|
|
|
|
body = OrElse(body, Contains(lowered, wrappedToken));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 16:02:44 -03:00
|
|
|
return body ?? Expression.Constant(false);
|
2026-07-23 12:55:47 -03:00
|
|
|
}
|
|
|
|
|
|
2026-08-03 17:53:24 -03:00
|
|
|
private static Expression<Func<T, bool>> BuildLocationPredicate<T>(
|
2026-07-23 12:55:47 -03:00
|
|
|
IReadOnlyCollection<string> selectedLocations)
|
|
|
|
|
{
|
2026-08-03 17:53:24 -03:00
|
|
|
var vendor = Expression.Parameter(typeof(T), "vendor");
|
2026-09-15 16:02:44 -03:00
|
|
|
return Expression.Lambda<Func<T, bool>>(
|
|
|
|
|
BuildLocationMatchBody<T>(vendor, selectedLocations),
|
|
|
|
|
vendor);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static Expression BuildLocationMatchBody<T>(
|
|
|
|
|
ParameterExpression vendor,
|
|
|
|
|
IReadOnlyCollection<string> selectedLocations)
|
|
|
|
|
{
|
2026-07-23 12:55:47 -03:00
|
|
|
var label = BuildLocationLabelBody(vendor);
|
|
|
|
|
var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes);
|
|
|
|
|
|
|
|
|
|
Expression? body = null;
|
|
|
|
|
foreach (var location in selectedLocations)
|
|
|
|
|
body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location)));
|
|
|
|
|
|
2026-09-15 16:02:44 -03:00
|
|
|
return body ?? Expression.Constant(false);
|
2026-04-28 18:55:14 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-23 12:55:47 -03:00
|
|
|
private static Expression BuildLocationLabelBody(ParameterExpression vendor)
|
|
|
|
|
{
|
|
|
|
|
var city = Expression.Property(vendor, nameof(Vendor.City));
|
|
|
|
|
var state = Expression.Property(vendor, nameof(Vendor.State));
|
2026-08-25 20:33:47 -03:00
|
|
|
var address = Expression.Property(vendor, nameof(Vendor.Address));
|
2026-07-23 12:55:47 -03:00
|
|
|
var cityBlank = IsBlank(city);
|
|
|
|
|
var stateBlank = IsBlank(state);
|
2026-08-25 20:33:47 -03:00
|
|
|
var addressBlank = IsBlank(address);
|
|
|
|
|
|
|
|
|
|
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), 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);
|
2026-07-23 12:55:47 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static Expression IsBlank(Expression value)
|
|
|
|
|
{
|
|
|
|
|
var isNull = Expression.Equal(value, Expression.Constant(null, value.Type));
|
|
|
|
|
var trimmed = Expression.Call(value, nameof(string.Trim), Type.EmptyTypes);
|
|
|
|
|
var isEmpty = Expression.Equal(trimmed, Expression.Constant(string.Empty));
|
|
|
|
|
return Expression.Condition(isNull, Expression.Constant(true), isEmpty);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static Expression Concat(Expression left, Expression right) =>
|
|
|
|
|
Expression.Call(StringConcatMethod, left, right);
|
|
|
|
|
|
|
|
|
|
private static Expression StartsWith(Expression instance, Expression value) =>
|
|
|
|
|
Expression.Call(instance, nameof(string.StartsWith), Type.EmptyTypes, value);
|
|
|
|
|
|
|
|
|
|
private static Expression EndsWith(Expression instance, Expression value) =>
|
|
|
|
|
Expression.Call(instance, nameof(string.EndsWith), Type.EmptyTypes, value);
|
|
|
|
|
|
|
|
|
|
private static Expression Contains(Expression instance, Expression value) =>
|
|
|
|
|
Expression.Call(instance, nameof(string.Contains), Type.EmptyTypes, value);
|
|
|
|
|
|
|
|
|
|
private static Expression OrElse(Expression? accumulated, Expression next) =>
|
|
|
|
|
accumulated is null ? next : Expression.OrElse(accumulated, next);
|
|
|
|
|
|
2026-04-28 18:55:14 -05:00
|
|
|
public async Task<Vendor> AddAsync(Vendor vendor)
|
|
|
|
|
{
|
|
|
|
|
await _context.Vendors.AddAsync(vendor);
|
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
|
return vendor;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task UpdateAsync(Vendor vendor)
|
|
|
|
|
{
|
|
|
|
|
_context.Vendors.Update(vendor);
|
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task DeleteAsync(int id)
|
|
|
|
|
{
|
|
|
|
|
var entity = await GetByIdAsync(id);
|
|
|
|
|
if (entity != null)
|
|
|
|
|
{
|
|
|
|
|
_context.Vendors.Remove(entity);
|
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<bool> ExistsAsync(int id)
|
|
|
|
|
{
|
|
|
|
|
return await _context.Vendors.AnyAsync(v => v.Id == id);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 17:35:34 -03:00
|
|
|
public async Task<VendorAccessToken?> GetActiveTokenForVendorAsync(int vendorId, CancellationToken cancellationToken)
|
2026-04-28 18:55:14 -05:00
|
|
|
{
|
2026-07-24 17:35:34 -03:00
|
|
|
var now = DateTime.UtcNow;
|
2026-04-28 18:55:14 -05:00
|
|
|
return await _context.VendorAccessTokens
|
2026-07-24 17:35:34 -03:00
|
|
|
.AsNoTracking()
|
|
|
|
|
.Where(t => t.VendorId == vendorId
|
|
|
|
|
&& t.RevokedAt == null
|
|
|
|
|
&& t.ExpiresAt > now
|
|
|
|
|
&& (t.IsDeleted == null || t.IsDeleted == false))
|
|
|
|
|
.OrderByDescending(t => t.IssuedAt)
|
|
|
|
|
.FirstOrDefaultAsync(cancellationToken);
|
2026-04-28 18:55:14 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-24 17:35:34 -03:00
|
|
|
public async Task<VendorAccessToken?> GetTokenWithVendorAsync(string token, CancellationToken cancellationToken)
|
2026-04-28 18:55:14 -05:00
|
|
|
{
|
2026-07-24 17:35:34 -03:00
|
|
|
var now = DateTime.UtcNow;
|
2026-04-28 18:55:14 -05:00
|
|
|
return await _context.VendorAccessTokens
|
2026-07-24 17:35:34 -03:00
|
|
|
.Include(t => t.Vendor)
|
|
|
|
|
.FirstOrDefaultAsync(t => t.Token == token
|
|
|
|
|
&& t.RevokedAt == null
|
|
|
|
|
&& t.ExpiresAt > now
|
|
|
|
|
&& (t.IsDeleted == null || t.IsDeleted == false), cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<VendorAccessToken> AddTokenAsync(VendorAccessToken token, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
await _context.VendorAccessTokens.AddAsync(token, cancellationToken);
|
|
|
|
|
await _context.SaveChangesAsync(cancellationToken);
|
|
|
|
|
return token;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<IReadOnlyList<VendorAccessToken>> GetRevocableTokensAsync(int vendorId, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
return await _context.VendorAccessTokens
|
|
|
|
|
.Where(t => t.VendorId == vendorId && t.RevokedAt == null)
|
|
|
|
|
.ToListAsync(cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task SaveChangesAsync(CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
await _context.SaveChangesAsync(cancellationToken);
|
2026-04-28 18:55:14 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<int> CountAsync()
|
|
|
|
|
{
|
|
|
|
|
return await _context.Vendors.CountAsync();
|
|
|
|
|
}
|
2026-07-23 17:02:40 -03:00
|
|
|
|
|
|
|
|
public async Task<VendorCompany?> GetCompanyByIdAsync(int id)
|
|
|
|
|
{
|
|
|
|
|
return await _context.VendorCompanies
|
|
|
|
|
.AsNoTracking()
|
|
|
|
|
.FirstOrDefaultAsync(c => c.Id == id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<VendorCompany?> GetCompanyByNormalizedNameAsync(string normalizedName)
|
|
|
|
|
{
|
|
|
|
|
return await _context.VendorCompanies
|
|
|
|
|
.AsNoTracking()
|
|
|
|
|
.FirstOrDefaultAsync(c => c.NormalizedName == normalizedName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<VendorCompany> AddCompanyAsync(VendorCompany company)
|
|
|
|
|
{
|
|
|
|
|
await _context.VendorCompanies.AddAsync(company);
|
2026-07-23 19:17:14 -03:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
|
return company;
|
|
|
|
|
}
|
|
|
|
|
catch (DbUpdateException)
|
|
|
|
|
{
|
|
|
|
|
_context.Entry(company).State = EntityState.Detached;
|
|
|
|
|
var existing = await _context.VendorCompanies
|
|
|
|
|
.AsNoTracking()
|
|
|
|
|
.SingleOrDefaultAsync(c => c.NormalizedName == company.NormalizedName);
|
|
|
|
|
if (existing != null)
|
|
|
|
|
return existing;
|
|
|
|
|
throw;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task UpdateCompanyAsync(VendorCompany company)
|
|
|
|
|
{
|
|
|
|
|
_context.VendorCompanies.Update(company);
|
2026-07-23 17:02:40 -03:00
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 19:17:14 -03:00
|
|
|
internal IQueryable<LinkedWorkOrderInfo> BuildLinkedWorkOrdersQuery(int vendorId)
|
2026-07-23 17:02:40 -03:00
|
|
|
{
|
|
|
|
|
var directLinks = _context.Dispatches
|
|
|
|
|
.Where(d => d.VendorId == vendorId && d.WorkOrderId.HasValue)
|
|
|
|
|
.Select(d => new { DispatchId = d.Id, WorkOrderId = d.WorkOrderId!.Value });
|
|
|
|
|
|
|
|
|
|
var multiLinks = _context.DispatchWorkOrders
|
|
|
|
|
.Where(link => link.Dispatch != null && link.Dispatch.VendorId == vendorId)
|
|
|
|
|
.Select(link => new { DispatchId = link.DispatchId, WorkOrderId = link.WorkOrderId });
|
|
|
|
|
|
|
|
|
|
var linkedIds = directLinks.Concat(multiLinks);
|
|
|
|
|
|
|
|
|
|
return from link in linkedIds
|
|
|
|
|
join wo in _context.workOrders on link.WorkOrderId equals wo.Id
|
|
|
|
|
orderby wo.Id, link.DispatchId
|
|
|
|
|
select new LinkedWorkOrderInfo
|
|
|
|
|
{
|
|
|
|
|
WorkOrderId = wo.Id,
|
|
|
|
|
WorkOrderNumber = wo.WorkerOrderNumber,
|
|
|
|
|
WorkOrderTitle = wo.WorkerOrderTitle,
|
|
|
|
|
Status = wo.Status,
|
|
|
|
|
LifecycleStatus = wo.LifecycleStatus,
|
|
|
|
|
ScheduledDate = wo.ScheduledDate,
|
|
|
|
|
DispatchId = link.DispatchId
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 19:17:14 -03:00
|
|
|
public async Task<List<LinkedWorkOrderInfo>> GetLinkedWorkOrdersAsync(int vendorId)
|
2026-07-23 17:02:40 -03:00
|
|
|
{
|
2026-07-23 19:17:14 -03:00
|
|
|
var raw = await BuildLinkedWorkOrdersQuery(vendorId).ToListAsync();
|
2026-07-23 17:02:40 -03:00
|
|
|
return raw.DistinctBy(x => x.WorkOrderId).ToList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<bool> IsVendorAssignedToWorkOrderAsync(int vendorId, int workOrderId)
|
|
|
|
|
{
|
|
|
|
|
var hasDirect = await _context.Dispatches
|
|
|
|
|
.AnyAsync(d => d.VendorId == vendorId && d.WorkOrderId == workOrderId);
|
|
|
|
|
|
|
|
|
|
if (hasDirect)
|
|
|
|
|
return true;
|
|
|
|
|
|
|
|
|
|
return await _context.DispatchWorkOrders
|
|
|
|
|
.AnyAsync(link => link.WorkOrderId == workOrderId
|
|
|
|
|
&& link.Dispatch != null
|
|
|
|
|
&& link.Dispatch.VendorId == vendorId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task UpdateWithAuditLogsAsync(
|
|
|
|
|
Vendor vendor,
|
|
|
|
|
IReadOnlyCollection<VendorAuditLog> auditLogs)
|
|
|
|
|
{
|
|
|
|
|
_context.Vendors.Update(vendor);
|
|
|
|
|
if (auditLogs.Count > 0)
|
|
|
|
|
await _context.VendorAuditLogs.AddRangeAsync(auditLogs);
|
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
|
}
|
2026-04-28 18:55:14 -05:00
|
|
|
}
|
|
|
|
|
}
|