shoc-backend/SeaHaven.Services/Implementation/VendorService.cs
Alexandre Brandizzi 8515676f4d feat(vendors): add admin-assigned vendor company Area
Seed an organization-wide Area catalogue (East, Central, West, California)
with stable ids, add a nullable AreaId to VendorCompany, allow only Admins
to change it through the roster endpoints, expose areas facet metadata and
an areas[n] company-directory filter with the __unassigned__ sentinel.
2026-09-16 11:34:45 -03:00

887 lines
36 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentValidation;
using FluentValidation.Results;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Globalization;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
namespace SeaHaven.Services.Implementation
{
public class VendorService : IVendorService
{
private const int MaxDirectoryPageSize = 100;
private static readonly string[] TerminalLegacyStatuses =
{ "completed", "cancelled", "canceled" };
private readonly IVendorDataService _vendorDataService;
private readonly IVendorPortalTokenService _vendorTokenService;
private readonly IZipCodeDistance _zipDistance;
private readonly FrontendOptions _frontendOptions;
private readonly ICreateVendorValidation _createValidator;
private readonly IUpdateVendorValidation _updateValidator;
private readonly IWorkOrderVendorUpdateValidation _workOrderUpdateValidator;
private readonly ILogger<VendorService> _logger;
public VendorService(
IVendorDataService vendorDataService,
IVendorPortalTokenService vendorTokenService,
IZipCodeDistance zipDistance,
IOptions<FrontendOptions> frontendOptions,
ICreateVendorValidation createValidator,
IUpdateVendorValidation updateValidator,
IWorkOrderVendorUpdateValidation workOrderUpdateValidator,
ILogger<VendorService> logger)
{
_vendorDataService = vendorDataService;
_vendorTokenService = vendorTokenService;
_zipDistance = zipDistance;
_frontendOptions = frontendOptions.Value;
_createValidator = createValidator;
_updateValidator = updateValidator;
_workOrderUpdateValidator = workOrderUpdateValidator;
_logger = logger;
}
public async Task<VendorDTO?> GetVendorByIdAsync(int id)
{
var vendor = await _vendorDataService.GetByIdWithDetailsAsync(id);
return vendor == null ? null : MapToDTO(vendor);
}
public async Task<VendorDTO?> GetVendorByIdWithDetailsAsync(int id)
{
var vendor = await _vendorDataService.GetByIdWithDetailsAsync(id);
return vendor == null ? null : MapToDTO(vendor);
}
public async Task<IEnumerable<VendorDTO>> GetAllVendorsAsync()
{
var vendors = await _vendorDataService.GetAllAsync();
return vendors.Select(MapToDTO);
}
public async Task<PagedResult<VendorDTO>> GetVendorsPagedAsync(
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 (items, totalCount) = await _vendorDataService.GetPagedAsync(
page,
pageSize,
search,
isActive,
companies,
trades,
locations,
jobBuckets);
return new PagedResult<VendorDTO>
{
Items = items.Select(MapToDTO).ToList(),
TotalCount = totalCount,
Page = page,
PageSize = pageSize
};
}
// SH-198 legacy per-technician directory contract (GetVendorList): raw
// pagination semantics are part of the compatibility surface, so pageSize
// is forwarded unclamped.
public async Task<PagedResult<VendorDirectoryItemDTO>> GetVendorTechnicianDirectoryPagedAsync(
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 (items, totalCount) = await _vendorDataService.GetDirectoryPagedAsync(
page,
pageSize,
search,
isActive,
companies,
trades,
locations,
jobBuckets,
cancellationToken);
return new PagedResult<VendorDirectoryItemDTO>
{
Items = items.Select(MapToDirectoryItem).ToList(),
TotalCount = totalCount,
Page = page,
PageSize = pageSize
};
}
private static VendorDirectoryItemDTO MapToDirectoryItem(VendorDirectoryRow row) => new()
{
Id = row.VendorId,
CompanyId = row.CompanyId == 0 ? null : row.CompanyId,
CompanyName = row.CompanyName,
ContactName = row.ContactName,
Email = row.Email,
Phone = row.Phone,
CompanyPhone = row.CompanyPhone,
PreferredContact = row.PreferredContact,
Address = row.Address,
City = row.City,
State = row.State,
Zipcode = row.Zip,
TradeSpecialties = row.TradeSpecialties,
GoogleMapsUrl = row.GoogleMapsUrl,
Notes = row.Notes,
IsActive = row.IsActive,
TotalJobs = row.TotalJobs
};
// SH-281 company-grouped directory (GetVendorDirectoryList).
public async Task<PagedResult<VendorDirectoryItemDTO>> GetVendorCompanyDirectoryPagedAsync(
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,
IReadOnlyCollection<string>? areas = null,
CancellationToken cancellationToken = default)
{
var normalizedPage = Math.Max(1, page);
var normalizedPageSize = Math.Clamp(pageSize, 1, MaxDirectoryPageSize);
var areaFilter = await ResolveAreaFilterAsync(areas, cancellationToken);
var (items, totalCount) = await _vendorDataService.GetCompanyDirectoryPagedAsync(
normalizedPage,
normalizedPageSize,
search,
isActive,
companies,
trades,
locations,
jobBuckets,
areaFilter,
cancellationToken);
return new PagedResult<VendorDirectoryItemDTO>
{
Items = items.Select(MapToCompanyGroup).ToList(),
TotalCount = totalCount,
Page = normalizedPage,
PageSize = normalizedPageSize
};
}
// SH-278 areas[n] contract: blank entries are ignored; "__unassigned__" selects
// companies without an Area; other values must be stable positive integer ids
// of active catalogue rows. Display names, unknown ids and archived ids never
// error — they simply match nobody. Null means no Area filter was requested.
private async Task<VendorAreaFilter?> ResolveAreaFilterAsync(
IReadOnlyCollection<string>? areas,
CancellationToken cancellationToken)
{
var values = areas?
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.ToList();
if (values is not { Count: > 0 })
return null;
var includeUnassigned = values.Contains(VendorAreaFilterValues.Unassigned, StringComparer.Ordinal);
var requestedIds = new HashSet<int>();
foreach (var value in values)
{
if (int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0)
requestedIds.Add(id);
}
IReadOnlyCollection<int> activeIds = Array.Empty<int>();
if (requestedIds.Count > 0)
{
var activeAreas = await _vendorDataService.GetActiveAreasAsync(cancellationToken);
activeIds = activeAreas
.Select(area => area.Id)
.Where(requestedIds.Contains)
.ToArray();
}
return new VendorAreaFilter
{
AreaIds = activeIds,
IncludeUnassigned = includeUnassigned
};
}
private static VendorDirectoryItemDTO MapToCompanyGroup(VendorCompanyGroup group) => new()
{
Id = group.Id,
CompanyId = group.CompanyId,
CompanyName = group.CompanyName,
ContactName = group.ContactName,
Email = group.Email,
Phone = group.Phone,
CompanyPhone = group.CompanyPhone,
PreferredContact = group.PreferredContact,
Address = group.Address,
City = group.City,
State = group.State,
Zipcode = group.Zip,
TradeSpecialties = group.TradeSpecialties,
GoogleMapsUrl = group.GoogleMapsUrl,
Notes = group.Notes,
AreaId = group.AreaId,
AreaName = group.AreaName,
IsActive = group.IsActive,
TotalJobs = group.TotalJobs,
Technicians = group.Technicians.Select(MapToTechnician).ToList()
};
private static VendorDirectoryTechnicianDTO MapToTechnician(VendorCompanyGroupTechnician technician) => new()
{
Id = technician.Id,
ContactName = technician.ContactName,
Email = technician.Email,
Phone = technician.Phone,
PreferredContact = technician.PreferredContact,
TradeSpecialties = technician.TradeSpecialties,
IsActive = technician.IsActive,
TotalJobs = technician.TotalJobs
};
public async Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId)
{
NormalizePhoneFields(dto);
var validationResult = await _createValidator.ValidateAsync(dto);
if (!validationResult.IsValid)
{
throw new ValidationException(validationResult.Errors);
}
var company = await ResolveCompanyAsync(dto.CompanyId, dto.Name, dto, userId);
var vendor = new Vendor
{
CompanyName = dto.Name,
ContactName = dto.ContactName,
Email = dto.Email,
Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone),
CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone),
PreferredContact = dto.PreferredContact,
Address = dto.Address,
City = dto.City,
State = dto.State,
Zip = dto.Zipcode,
TradeSpecialties = dto.TradeSpecialties,
GoogleMapsUrl = dto.GoogleMapsUrl,
Notes = dto.Notes,
IsActive = dto.IsActive,
CompanyId = company?.Id,
CreatedDate = DateTime.UtcNow,
createdby = userId
};
ApplyCompanyFields(vendor, company);
var created = await _vendorDataService.AddAsync(vendor);
return MapToDTO(created);
}
public async Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId)
{
NormalizePhoneFields(dto);
var validationResult = await _updateValidator.ValidateAsync(dto);
if (!validationResult.IsValid)
{
throw new ValidationException(validationResult.Errors);
}
var vendor = await _vendorDataService.GetByIdAsync(id);
if (vendor == null)
throw new InvalidOperationException($"Vendor with ID {id} not found");
if (dto.IsActive.HasValue && !dto.IsActive.Value)
{
if (dto.ConfirmOpenWorkOrders)
await LogConfirmedDeactivationAsync(id, userId);
else
await AssertNoOpenLinkedWorkOrdersAsync(id);
}
if (dto.Name != null) vendor.CompanyName = dto.Name;
if (dto.ContactName != null) vendor.ContactName = dto.ContactName;
if (dto.Email != null) vendor.Email = dto.Email;
if (dto.Phone != null) vendor.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
if (dto.PreferredContact != null) vendor.PreferredContact = dto.PreferredContact;
if (dto.TradeSpecialties != null) vendor.TradeSpecialties = dto.TradeSpecialties;
if (dto.Notes != null) vendor.Notes = dto.Notes;
if (dto.IsActive.HasValue) vendor.IsActive = dto.IsActive.Value;
if (dto.CompanyId.HasValue || dto.Name != null || HasCompanyFieldUpdate(dto))
{
var company = await ResolveCompanyAsync(
dto.CompanyId ?? vendor.CompanyId,
dto.Name ?? vendor.CompanyName,
dto,
userId);
vendor.CompanyId = company?.Id;
ApplyCompanyFields(vendor, company);
}
vendor.LastModificationTime = DateTime.UtcNow;
if (int.TryParse(userId, out int userIdInt))
vendor.LastModifierUserId = userIdInt;
await _vendorDataService.UpdateAsync(vendor);
return MapToDTO(vendor);
}
public async Task DeleteVendorAsync(int id, string userId, bool confirmOpenWorkOrders)
{
var vendor = await _vendorDataService.GetByIdAsync(id);
if (vendor == null)
throw new InvalidOperationException($"Vendor with ID {id} not found");
if (confirmOpenWorkOrders)
await LogConfirmedDeactivationAsync(id, userId);
else
await AssertNoOpenLinkedWorkOrdersAsync(id);
vendor.IsActive = false;
vendor.LastModificationTime = DateTime.UtcNow;
if (int.TryParse(userId, out int userIdInt))
vendor.LastModifierUserId = userIdInt;
await _vendorDataService.UpdateAsync(vendor);
}
public async Task<bool> VendorExistsAsync(int id)
{
return await _vendorDataService.ExistsAsync(id);
}
public async Task<int> GetTotalVendorCountAsync()
{
return await _vendorDataService.CountAsync();
}
public async Task<VendorDeactivationImpactDTO> GetDeactivationImpactAsync(int vendorId)
{
if (!await _vendorDataService.ExistsAsync(vendorId))
throw new InvalidOperationException($"Vendor with ID {vendorId} not found");
var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId);
var openWorkOrders = linked
.Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status))
.Select(MapToLinkedWorkOrderDTO)
.ToList();
return new VendorDeactivationImpactDTO
{
VendorId = vendorId,
CanDeactivate = openWorkOrders.Count == 0,
OpenWorkOrders = openWorkOrders
};
}
public async Task<VendorDTO> UpdateVendorFromWorkOrderAsync(
int vendorId,
WorkOrderVendorUpdateDTO dto,
string userId)
{
dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
var validationResult = await _workOrderUpdateValidator.ValidateAsync(dto);
if (!validationResult.IsValid)
{
throw new ValidationException(validationResult.Errors);
}
var vendor = await _vendorDataService.GetByIdAsync(vendorId);
if (vendor == null)
throw new InvalidOperationException($"Vendor with ID {vendorId} not found");
var isAssigned = await _vendorDataService.IsVendorAssignedToWorkOrderAsync(vendorId, dto.WorkOrderId);
if (!isAssigned)
throw new InvalidOperationException(
$"Vendor {vendorId} is not actively linked to work order {dto.WorkOrderId}.");
var changes = new List<VendorAuditLog>();
var now = DateTime.UtcNow;
void RecordChange(string fieldName, string? oldValue, string? newValue)
{
if (!string.Equals(oldValue, newValue, StringComparison.Ordinal))
{
changes.Add(new VendorAuditLog
{
VendorId = vendorId,
WorkOrderId = dto.WorkOrderId,
FieldName = fieldName,
OldValue = oldValue,
NewValue = newValue,
Actor = userId,
CreatedAt = now
});
}
}
if (dto.ContactName != null)
{
RecordChange(nameof(Vendor.ContactName), vendor.ContactName, dto.ContactName);
vendor.ContactName = dto.ContactName;
}
if (dto.PreferredContact != null)
{
RecordChange(nameof(Vendor.PreferredContact), vendor.PreferredContact, dto.PreferredContact);
vendor.PreferredContact = dto.PreferredContact;
}
if (dto.Phone != null)
{
var normalized = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
RecordChange(nameof(Vendor.Phone), vendor.Phone, normalized);
vendor.Phone = normalized;
}
if (dto.Email != null)
{
RecordChange(nameof(Vendor.Email), vendor.Email, dto.Email);
vendor.Email = dto.Email;
}
if (dto.Notes != null)
{
RecordChange(nameof(Vendor.Notes), vendor.Notes, dto.Notes);
vendor.Notes = dto.Notes;
}
vendor.LastModificationTime = now;
if (int.TryParse(userId, out int userIdInt))
vendor.LastModifierUserId = userIdInt;
await _vendorDataService.UpdateWithAuditLogsAsync(vendor, changes);
return MapToDTO(vendor);
}
private async Task<VendorCompany?> ResolveCompanyAsync(
int? requestedCompanyId,
string? companyName,
object dto,
string userId)
{
if (requestedCompanyId.HasValue)
{
var byId = await _vendorDataService.GetCompanyByIdAsync(requestedCompanyId.Value);
if (byId != null)
{
ApplyCompanyUpdates(byId, companyName, dto, userId);
await _vendorDataService.UpdateCompanyAsync(byId);
return byId;
}
throw new ValidationException(new[]
{
new ValidationFailure(
nameof(CreateVendorDTO.CompanyId),
$"No vendor company exists with ID {requestedCompanyId.Value}.")
});
}
if (string.IsNullOrWhiteSpace(companyName))
return null;
var normalizedName = companyName.Trim().ToLowerInvariant();
var existing = await _vendorDataService.GetCompanyByNormalizedNameAsync(normalizedName);
if (existing != null)
{
ApplyCompanyUpdates(existing, companyName, dto, userId);
await _vendorDataService.UpdateCompanyAsync(existing);
return existing;
}
string? companyPhone = null;
string? email = null;
string? address = null;
string? city = null;
string? state = null;
string? zip = null;
string? mapsUrl = null;
if (dto is CreateVendorDTO create)
{
companyPhone = VendorPhoneNormalizer.NormalizeToCanonical(create.CompanyPhone);
email = create.Email;
address = create.Address;
city = create.City;
state = create.State;
zip = create.Zipcode;
mapsUrl = create.GoogleMapsUrl;
}
else if (dto is UpdateVendorDTO update)
{
companyPhone = VendorPhoneNormalizer.NormalizeToCanonical(update.CompanyPhone);
email = update.Email;
address = update.Address;
city = update.City;
state = update.State;
zip = update.Zipcode;
mapsUrl = update.GoogleMapsUrl;
}
var company = new VendorCompany
{
Name = companyName.Trim(),
NormalizedName = normalizedName,
CompanyPhone = companyPhone,
Email = email,
Address = address,
City = city,
State = state,
Zip = zip,
GoogleMapsUrl = mapsUrl,
CreatedDate = DateTime.UtcNow,
createdby = userId
};
return await _vendorDataService.AddCompanyAsync(company);
}
private static void ApplyCompanyFields(Vendor vendor, VendorCompany? company)
{
if (company == null)
return;
if (company.CompanyPhone != null) vendor.CompanyPhone = company.CompanyPhone;
if (company.Address != null) vendor.Address = company.Address;
if (company.City != null) vendor.City = company.City;
if (company.State != null) vendor.State = company.State;
if (company.Zip != null) vendor.Zip = company.Zip;
if (company.GoogleMapsUrl != null) vendor.GoogleMapsUrl = company.GoogleMapsUrl;
}
private static bool HasCompanyFieldUpdate(UpdateVendorDTO dto) =>
dto.CompanyPhone != null ||
dto.Address != null ||
dto.City != null ||
dto.State != null ||
dto.Zipcode != null ||
dto.GoogleMapsUrl != null;
private static void ApplyCompanyUpdates(
VendorCompany company,
string? companyName,
object dto,
string userId)
{
if (!string.IsNullOrWhiteSpace(companyName))
{
company.Name = companyName.Trim();
company.NormalizedName = company.Name.ToLowerInvariant();
}
if (dto is CreateVendorDTO create)
{
if (create.CompanyPhone != null) company.CompanyPhone = create.CompanyPhone;
if (create.Address != null) company.Address = create.Address;
if (create.City != null) company.City = create.City;
if (create.State != null) company.State = create.State;
if (create.Zipcode != null) company.Zip = create.Zipcode;
if (create.GoogleMapsUrl != null) company.GoogleMapsUrl = create.GoogleMapsUrl;
}
else if (dto is UpdateVendorDTO update)
{
if (update.CompanyPhone != null) company.CompanyPhone = update.CompanyPhone;
if (update.Address != null) company.Address = update.Address;
if (update.City != null) company.City = update.City;
if (update.State != null) company.State = update.State;
if (update.Zipcode != null) company.Zip = update.Zipcode;
if (update.GoogleMapsUrl != null) company.GoogleMapsUrl = update.GoogleMapsUrl;
}
company.LastModificationTime = DateTime.UtcNow;
if (int.TryParse(userId, out int userIdInt))
company.LastModifierUserId = userIdInt;
}
private static void NormalizePhoneFields(CreateVendorDTO dto)
{
dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
dto.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone);
}
private static void NormalizePhoneFields(UpdateVendorDTO dto)
{
dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
dto.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone);
}
// SH-44's story is about not *silently* orphaning active work. When the caller
// confirms past the guard, the work orders it chose to leave open are recorded.
private async Task LogConfirmedDeactivationAsync(int vendorId, string userId)
{
var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId);
var openCount = linked.Count(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status));
if (openCount == 0) return;
_logger.LogWarning(
"Vendor {VendorId} deactivated by user {UserId} with {OpenWorkOrderCount} open work orders, confirmed by the caller.",
vendorId,
userId,
openCount);
}
// Guard for the unconfirmed path only. SH-44 and SH-82 left "blocks or requires
// explicit confirmation" to be settled with the team; SH-254 settles it as
// explicit confirmation, so a caller that has not confirmed is still blocked.
private async Task AssertNoOpenLinkedWorkOrdersAsync(int vendorId)
{
var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId);
var openWorkOrders = linked
.Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status))
.Select(MapToLinkedWorkOrderDTO)
.ToList();
if (openWorkOrders.Count > 0)
{
throw new VendorDeactivationBlockedException(
"Vendor cannot be deactivated while linked work orders are open or scheduled.",
openWorkOrders);
}
}
private static bool IsTerminalWorkOrderStatus(LifecycleStatus? lifecycleStatus, string? legacyStatus)
{
if (lifecycleStatus == LifecycleStatus.Completed || lifecycleStatus == LifecycleStatus.Canceled)
return true;
if (!string.IsNullOrWhiteSpace(legacyStatus) &&
TerminalLegacyStatuses.Contains(legacyStatus.Trim().ToLowerInvariant()))
return true;
return false;
}
private static LinkedWorkOrderDTO MapToLinkedWorkOrderDTO(LinkedWorkOrderInfo info) => new()
{
WorkOrderId = info.WorkOrderId,
WorkOrderNumber = info.WorkOrderNumber,
WorkOrderTitle = info.WorkOrderTitle,
Status = info.Status,
ScheduledDate = info.ScheduledDate,
DispatchId = info.DispatchId
};
private VendorDTO MapToDTO(Vendor vendor)
{
return new VendorDTO
{
Id = vendor.Id,
Name = vendor.CompanyName,
CompanyId = vendor.CompanyId,
ContactName = vendor.ContactName,
Email = vendor.Email,
Phone = vendor.Phone,
CompanyPhone = vendor.CompanyPhone,
PreferredContact = vendor.PreferredContact,
Address = vendor.Address,
City = vendor.City,
State = vendor.State,
Zipcode = vendor.Zip,
TradeSpecialties = vendor.TradeSpecialties,
GoogleMapsUrl = vendor.GoogleMapsUrl,
Notes = vendor.Notes,
IsActive = vendor.IsActive,
TotalJobs = vendor.Dispatches == null
? vendor.TotalJobs
: CountDistinctWorkOrders(vendor)
};
}
private static int CountDistinctWorkOrders(Vendor vendor)
{
if (vendor.Dispatches == null)
return 0;
return vendor.Dispatches
.SelectMany(dispatch =>
{
var linked = dispatch.DispatchWorkOrders?.Select(item => item.WorkOrderId)
?? Enumerable.Empty<int>();
return dispatch.WorkOrderId.HasValue
? linked.Append(dispatch.WorkOrderId.Value)
: linked;
})
.Distinct()
.Count();
}
public async Task<IEnumerable<VendorDropdownItemDTO>> GetDropdownAsync(string? trade, string? siteZip, CancellationToken cancellationToken)
{
var vendors = await _vendorDataService.GetActiveVendorsAsync(cancellationToken);
var result = vendors.Select(v =>
{
var distance = _zipDistance.GetDistanceMiles(siteZip, v.Zip);
var addr = new[] { v.Address, v.City, v.State, v.Zip }
.Where(s => !string.IsNullOrWhiteSpace(s));
return new VendorDropdownItemDTO
{
Id = v.Id,
CompanyName = v.CompanyName,
ContactName = v.ContactName,
PreferredContact = v.PreferredContact,
TradeSpecialties = v.TradeSpecialties,
Address = string.Join(", ", addr),
DistanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null
};
}).ToList();
if (!string.IsNullOrWhiteSpace(trade))
{
var tradeMatched = result.Where(v => (v.TradeSpecialties ?? "").Contains(trade)).ToList();
var rest = result.Where(v => !(v.TradeSpecialties ?? "").Contains(trade)).ToList();
result = tradeMatched.Concat(rest).ToList();
}
if (!string.IsNullOrWhiteSpace(siteZip))
{
result = result.OrderBy(v => v.DistanceMiles ?? 99999).ToList();
}
return result;
}
public async Task<VendorFacetsDTO> GetFacetsAsync(bool? isActive, CancellationToken cancellationToken)
{
// Companies and Locations are sourced from company-owned VendorCompany fields
// (status semantics aligned to the directory union). Trades come from the
// SH-249 server-owned canonical Trade reference data — a stable list that
// does not derive from technician TradeSpecialties free text, so it is
// populated even on an empty database and independent of the status filter.
var companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken);
var canonicalTrades = await _vendorDataService.GetActiveTradesAsync(cancellationToken);
var activeAreas = await _vendorDataService.GetActiveAreasAsync(cancellationToken);
var companyFacets = companies
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
.GroupBy(c => c.Name!.Trim(), StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.Select(c => new VendorFacetCompanyDTO
{
Name = c.Name,
CompanyId = c.Id,
CompanyPhone = c.CompanyPhone,
Email = c.Email,
PreferredContact = null,
Address = c.Address,
City = c.City,
State = c.State,
Zip = c.Zip,
GoogleMapsUrl = c.GoogleMapsUrl
});
var trades = canonicalTrades.Select(t => t.Name).ToList();
// Exact Location label rule (mirrors the directory filter): City/State
// whenever either structured value is present, otherwise Address.
// Address-only companies are therefore represented in the facets.
var locations = companies
.Where(c => !string.IsNullOrWhiteSpace(c.City)
|| !string.IsNullOrWhiteSpace(c.State)
|| !string.IsNullOrWhiteSpace(c.Address))
.Select(c => new VendorFacetLocationDTO
{
City = c.City,
State = c.State,
Label = !string.IsNullOrWhiteSpace(c.City) || !string.IsNullOrWhiteSpace(c.State)
? string.Join(", ", new[] { c.City?.Trim(), c.State?.Trim() }
.Where(value => !string.IsNullOrWhiteSpace(value)))
: c.Address!.Trim()
})
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
.OrderBy(location => location.Label);
// SH-278: the active Area catalogue, A–Z case-insensitive, independent of the
// status filter and of whether any company currently uses an Area. Clients
// prepend the "__unassigned__" option themselves.
var areas = activeAreas
.OrderBy(area => area.Name, StringComparer.OrdinalIgnoreCase)
.Select(area => new VendorFacetAreaDTO { Id = area.Id, Name = area.Name })
.ToList();
return new VendorFacetsDTO
{
Companies = companyFacets,
Trades = trades,
Areas = areas,
Locations = locations,
JobBuckets = new[]
{
new VendorJobBucketDTO { Id = "under-50", Label = "Under 50" },
new VendorJobBucketDTO { Id = "50-99", Label = "50–99" },
new VendorJobBucketDTO { Id = "100-149", Label = "100–149" },
new VendorJobBucketDTO { Id = "150-plus", Label = "150+" }
}
};
}
public async Task<VendorPortalTokenDTO?> GetPortalTokenAsync(int vendorId, CancellationToken cancellationToken)
{
if (!await _vendorDataService.ExistsAsync(vendorId))
return null;
var token = await _vendorTokenService.GetOrCreateActiveTokenAsync(vendorId, cancellationToken);
return BuildPortalTokenResponse(token);
}
public async Task<VendorPortalTokenDTO?> RotatePortalTokenAsync(int vendorId, CancellationToken cancellationToken)
{
if (!await _vendorDataService.ExistsAsync(vendorId))
return null;
var token = await _vendorTokenService.RotateAsync(vendorId, cancellationToken);
return BuildPortalTokenResponse(token);
}
public async Task<bool> RevokePortalTokenAsync(int vendorId, CancellationToken cancellationToken)
{
if (!await _vendorDataService.ExistsAsync(vendorId))
return false;
await _vendorTokenService.RevokeAllAsync(vendorId, cancellationToken);
return true;
}
private VendorPortalTokenDTO BuildPortalTokenResponse(VendorPortalTokenStateDTO token)
{
var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? "";
return new VendorPortalTokenDTO
{
Token = token.Token,
IssuedAt = token.IssuedAt,
ExpiresAt = token.ExpiresAt,
LastUsedAt = token.LastUsedAt,
PortalUrl = $"{frontendBase}/v/{token.Token}/dashboard"
};
}
}
}