using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; using System.Linq.Expressions; using System.Reflection; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Implementation { public class VendorDataService : IVendorDataService { private readonly ApplicationDbContext _context; public VendorDataService(ApplicationDbContext context) { _context = context; } public async Task GetByIdAsync(int id) { return await _context.Vendors.FirstOrDefaultAsync(v => v.Id == id); } public async Task GetByIdWithDetailsAsync(int id) { return await ProjectWithTotalJobs( _context.Vendors .AsNoTracking() .Where(v => v.Id == id)) .FirstOrDefaultAsync(); } public async Task> GetByIdsOrderedAsync(IReadOnlyCollection ids, CancellationToken cancellationToken) { var orderedIds = ids.Distinct().ToList(); if (orderedIds.Count == 0) return Array.Empty(); 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(orderedIds.Count); foreach (var id in orderedIds) { if (byId.TryGetValue(id, out var vendor)) ordered.Add(vendor); } return ordered; } public async Task> GetAllAsync() { return await _context.Vendors.ToListAsync(); } public async Task> GetActiveVendorsAsync(CancellationToken cancellationToken) { return await _context.Vendors .AsNoTracking() .Where(v => v.IsActive) .OrderBy(v => v.CompanyName) .ToListAsync(cancellationToken); } public async Task> GetActiveTradesAsync(CancellationToken cancellationToken) { return await _context.Trades .AsNoTracking() .Where(t => t.IsActive) .OrderBy(t => t.SortOrder) .ThenBy(t => t.Name) .ToListAsync(cancellationToken); } internal IQueryable 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); } public async Task> GetActiveAreasAsync(CancellationToken cancellationToken) { return await _context.Areas .AsNoTracking() .Where(a => a.IsActive) .OrderBy(a => a.Id) .ToListAsync(cancellationToken); } public async Task> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken) => await BuildCompaniesForFacetsQuery(isActive).ToListAsync(cancellationToken); public async Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( int page, int pageSize, string? search = null, bool? isActive = true, IReadOnlyCollection? companies = null, IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null) { var projectedQuery = BuildDirectoryQuery( search, isActive, companies, trades, locations, jobBuckets); var totalCount = await projectedQuery.CountAsync(); var items = await projectedQuery .OrderByDescending(v => v.Id) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); return (items, totalCount); } public async Task<(IReadOnlyList Items, int TotalCount)> GetDirectoryPagedAsync( int page, int pageSize, string? search = null, bool? isActive = true, IReadOnlyCollection? companies = null, IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? 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); } public async Task<(IReadOnlyList Items, int TotalCount)> GetCompanyDirectoryPagedAsync( int page, int pageSize, string? search = null, bool? isActive = true, IReadOnlyCollection? companies = null, IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null, VendorAreaFilter? areaFilter = null, CancellationToken cancellationToken = default) { // Phase 1: select and paginate bounded company group keys in SQL. var keyQuery = BuildCompanyDirectoryKeysQuery( search, isActive, companies, trades, locations, jobBuckets, areaFilter); 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(), 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() : await _context.VendorCompanies .AsNoTracking() .Include(c => c.Area) .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(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 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, AreaId = linked ? primary.CompanyAreaId : null, AreaName = linked ? primary.CompanyAreaName : 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 techniciansNewestFirst) { var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var tokens = new List(); 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, AreaId = company.AreaId, AreaName = company.Area?.Name, IsActive = true, TotalJobs = 0, Technicians = Array.Empty() }; internal IQueryable BuildCompanyDirectoryKeysQuery( string? search = null, bool? isActive = true, IReadOnlyCollection? companies = null, IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null, VendorAreaFilter? areaFilter = null) { var sourceRows = BuildDirectoryUnionBaseQuery(isActive) .Select(BuildGroupSourceRowSelector(search, companies, trades, locations, areaFilter)); 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), AreaMatch = group.Max(row => row.AreaFlag) }); // 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); if (areaFilter != null) grouped = grouped.Where(key => key.AreaMatch == 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> BuildGroupSourceRowSelector( string? search, IReadOnlyCollection? companies, IReadOnlyCollection? trades, IReadOnlyCollection? locations, VendorAreaFilter? areaFilter) { 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(row, selectedTrades) : null; var locationBody = selectedLocations is { Length: > 0 } ? BuildLocationMatchBody(row, selectedLocations) : null; var areaBody = areaFilter == null ? null : BuildAreaMatchBody(row, areaFilter); 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>( 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)), Bind(nameof(VendorDirectoryGroupSourceRow.AreaFlag), Flag(areaBody))), row); } internal IQueryable BuildCompanyDirectoryRosterQuery( bool? isActive, IReadOnlyList 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, CompanyAreaId = v.Company != null ? v.Company.AreaId : null, CompanyAreaName = v.Company != null && v.Company.Area != null ? v.Company.Area.Name : 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> BuildRosterKeyPredicate( IReadOnlyList 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>( body ?? Expression.Constant(false), row); } internal IQueryable BuildDirectoryUnionQuery( string? search = null, bool? isActive = true, IReadOnlyCollection? companies = null, IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null) => ApplyDirectoryRowFilters( BuildDirectoryUnionBaseQuery(isActive), search, companies, trades, locations, jobBuckets); private IQueryable BuildDirectoryUnionBaseQuery(bool? isActive = true) { // 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); // 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() != "" ? v.Email : v.Company != null ? v.Company.Email : v.Email, Phone = v.Phone, CompanyPhone = v.Company != null && v.Company.CompanyPhone != null && v.Company.CompanyPhone.Trim() != "" ? v.Company.CompanyPhone : v.CompanyPhone, PreferredContact = v.PreferredContact, Address = v.Company != null && v.Company.Address != null && v.Company.Address.Trim() != "" ? v.Company.Address : v.Address, City = v.Company != null && ((v.Company.City != null && v.Company.City.Trim() != "") || (v.Company.State != null && v.Company.State.Trim() != "") || (v.Company.Address != null && v.Company.Address.Trim() != "")) ? v.Company.City : v.City, State = v.Company != null && ((v.Company.City != null && v.Company.City.Trim() != "") || (v.Company.State != null && v.Company.State.Trim() != "") || (v.Company.Address != null && v.Company.Address.Trim() != "")) ? v.Company.State : v.State, Zip = v.Zip, TradeSpecialties = v.TradeSpecialties, GoogleMapsUrl = v.GoogleMapsUrl, Notes = v.Company != null ? v.Company.Notes : null, AreaId = v.Company != null ? v.Company.AreaId : null, IsActive = v.IsActive, TotalJobs = (int?)jobCount.TotalJobs ?? 0 }; // 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, AreaId = c.AreaId, IsActive = true, TotalJobs = 0 }); union = armA.Concat(armB); } 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!; } // 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); } private static Expression BuildCompanyNameMatchBody( ParameterExpression row, IReadOnlyCollection 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 ApplyDirectoryRowFilters( IQueryable query, string? search, IReadOnlyCollection? companies, IReadOnlyCollection? trades, IReadOnlyCollection? locations, IReadOnlyCollection? 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) || (((row.City == null || row.City.Trim() == "") && (row.State == null || row.State.Trim() == "")) && (row.Address ?? "").ToLower().Contains(normalizedSearch)) || (row.Zip ?? "").ToLower().Contains(normalizedSearch)); } var selectedCompanies = companies? .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(value => value.Trim().ToLower()) .Distinct() .ToArray() ?? Array.Empty(); 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(); if (selectedTrades.Length > 0) query = query.Where(BuildTradePredicate(selectedTrades)); var selectedLocations = locations? .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(value => value.Trim().ToLower()) .Distinct() .ToArray() ?? Array.Empty(); if (selectedLocations.Length > 0) query = query.Where(BuildLocationPredicate(selectedLocations)); var selectedJobBuckets = jobBuckets? .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(value => value.Trim().ToLower()) .Distinct() .ToArray() ?? Array.Empty(); 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)); } internal IQueryable BuildDirectoryQuery( string? search = null, bool? isActive = true, IReadOnlyCollection? companies = null, IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? 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); if (!string.IsNullOrWhiteSpace(search)) { var normalizedSearch = search.ToLower(); query = query.Where(v => (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)); } var selectedCompanies = companies? .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(value => value.Trim().ToLower()) .Distinct() .ToArray() ?? Array.Empty(); if (selectedCompanies.Length > 0) query = query.Where(v => selectedCompanies.Contains((v.CompanyName ?? "").ToLower())); var selectedTrades = trades? .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(value => value.Trim().ToLower()) .Distinct() .ToArray() ?? Array.Empty(); if (selectedTrades.Length > 0) query = query.Where(BuildTradePredicate(selectedTrades)); var selectedLocations = locations? .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(value => value.Trim().ToLower()) .Distinct() .ToArray() ?? Array.Empty(); if (selectedLocations.Length > 0) query = query.Where(BuildLocationPredicate(selectedLocations)); var result = ProjectWithTotalJobs(query); var selectedJobBuckets = jobBuckets? .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(value => value.Trim().ToLower()) .Distinct() .ToArray() ?? Array.Empty(); 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 ProjectWithTotalJobs(IQueryable query) => query.Select(v => new Vendor { Id = v.Id, CompanyName = v.CompanyName, CompanyId = v.CompanyId, ContactName = v.ContactName, Email = v.Email, Phone = v.Phone, CompanyPhone = v.CompanyPhone, PreferredContact = v.PreferredContact, 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) })!; private static Expression> BuildTradePredicate( IReadOnlyCollection selectedTrades) { var vendor = Expression.Parameter(typeof(T), "vendor"); return Expression.Lambda>( BuildTradeMatchBody(vendor, selectedTrades), vendor); } private static Expression BuildTradeMatchBody( ParameterExpression vendor, IReadOnlyCollection selectedTrades) { 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)); } } return body ?? Expression.Constant(false); } private static Expression> BuildLocationPredicate( IReadOnlyCollection selectedLocations) { var vendor = Expression.Parameter(typeof(T), "vendor"); return Expression.Lambda>( BuildLocationMatchBody(vendor, selectedLocations), vendor); } private static Expression BuildLocationMatchBody( ParameterExpression vendor, IReadOnlyCollection selectedLocations) { 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))); return body ?? Expression.Constant(false); } private static Expression BuildLocationLabelBody(ParameterExpression vendor) { var city = Expression.Property(vendor, nameof(Vendor.City)); var state = Expression.Property(vendor, nameof(Vendor.State)); var address = Expression.Property(vendor, nameof(Vendor.Address)); var cityBlank = IsBlank(city); var stateBlank = IsBlank(state); var addressBlank = IsBlank(address); var 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); } 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); public async Task 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 ExistsAsync(int id) { return await _context.Vendors.AnyAsync(v => v.Id == id); } public async Task GetActiveTokenForVendorAsync(int vendorId, CancellationToken cancellationToken) { var now = DateTime.UtcNow; return await _context.VendorAccessTokens .AsNoTracking() .Where(t => t.VendorId == vendorId && t.RevokedAt == null && t.ExpiresAt > now && (t.IsDeleted == null || t.IsDeleted == false)) .OrderByDescending(t => t.IssuedAt) .FirstOrDefaultAsync(cancellationToken); } public async Task GetTokenWithVendorAsync(string token, CancellationToken cancellationToken) { var now = DateTime.UtcNow; return await _context.VendorAccessTokens .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 AddTokenAsync(VendorAccessToken token, CancellationToken cancellationToken) { await _context.VendorAccessTokens.AddAsync(token, cancellationToken); await _context.SaveChangesAsync(cancellationToken); return token; } public async Task> 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); } public async Task CountAsync() { return await _context.Vendors.CountAsync(); } public async Task GetCompanyByIdAsync(int id) { return await _context.VendorCompanies .AsNoTracking() .FirstOrDefaultAsync(c => c.Id == id); } public async Task GetCompanyByNormalizedNameAsync(string normalizedName) { return await _context.VendorCompanies .AsNoTracking() .FirstOrDefaultAsync(c => c.NormalizedName == normalizedName); } public async Task AddCompanyAsync(VendorCompany company) { await _context.VendorCompanies.AddAsync(company); 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); await _context.SaveChangesAsync(); } internal IQueryable BuildLinkedWorkOrdersQuery(int vendorId) { 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 }; } public async Task> GetLinkedWorkOrdersAsync(int vendorId) { var raw = await BuildLinkedWorkOrdersQuery(vendorId).ToListAsync(); return raw.DistinctBy(x => x.WorkOrderId).ToList(); } public async Task 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 auditLogs) { _context.Vendors.Update(vendor); if (auditLogs.Count > 0) await _context.VendorAuditLogs.AddRangeAsync(auditLogs); await _context.SaveChangesAsync(); } } }