shoc-backend/SeaHaven.DataServices/Implementation/VendorDataService.cs
2026-07-23 19:17:14 -03:00

401 lines
16 KiB
C#

using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using System.Linq.Expressions;
using System.Reflection;
using SeaHaven.DataServices.Interfaces;
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)
{
return await _context.Vendors.FirstOrDefaultAsync(v => v.Id == id);
}
public async Task<Vendor?> GetByIdWithDetailsAsync(int id)
{
return await ProjectWithTotalJobs(
_context.Vendors
.AsNoTracking()
.Where(v => v.Id == id))
.FirstOrDefaultAsync();
}
public async Task<IEnumerable<Vendor>> GetAllAsync()
{
return await _context.Vendors.ToListAsync();
}
public async Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
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)
{
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);
}
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);
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<string>();
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<string>();
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<string>();
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<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,
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<Func<Vendor, bool>> BuildTradePredicate(
IReadOnlyCollection<string> selectedTrades)
{
var vendor = Expression.Parameter(typeof(Vendor), "vendor");
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 Expression.Lambda<Func<Vendor, bool>>(body ?? Expression.Constant(false), vendor);
}
private static Expression<Func<Vendor, bool>> BuildLocationPredicate(
IReadOnlyCollection<string> selectedLocations)
{
var vendor = Expression.Parameter(typeof(Vendor), "vendor");
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 Expression.Lambda<Func<Vendor, bool>>(body ?? Expression.Constant(false), vendor);
}
private static Expression BuildLocationLabelBody(ParameterExpression vendor)
{
var city = Expression.Property(vendor, nameof(Vendor.City));
var state = Expression.Property(vendor, nameof(Vendor.State));
var cityBlank = IsBlank(city);
var stateBlank = IsBlank(state);
var cityCommaState = Concat(Concat(city, Expression.Constant(", ")), state);
var onlyState = Expression.Condition(stateBlank, Expression.Constant(string.Empty), state);
var cityOrBoth = Expression.Condition(stateBlank, city, cityCommaState);
return Expression.Condition(cityBlank, onlyState, cityOrBoth);
}
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<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);
}
public async Task<VendorAccessToken?> GetActiveTokenAsync(int vendorId)
{
return await _context.VendorAccessTokens
.FirstOrDefaultAsync(t => t.VendorId == vendorId);
}
public async Task<VendorAccessToken?> GetTokenByStringAsync(string token)
{
return await _context.VendorAccessTokens
.FirstOrDefaultAsync(t => t.Token == token);
}
public async Task<int> CountAsync()
{
return await _context.Vendors.CountAsync();
}
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);
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<LinkedWorkOrderInfo> 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<List<LinkedWorkOrderInfo>> GetLinkedWorkOrdersAsync(int vendorId)
{
var raw = await BuildLinkedWorkOrdersQuery(vendorId).ToListAsync();
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();
}
}
}