2026-05-06 10:49:33 -05:00
|
|
|
|
using Data.SeaHavenIndustries;
|
2026-07-23 17:02:40 -03:00
|
|
|
|
using Data.SeaHavenIndustries.Enums;
|
2026-05-14 11:00:12 -05:00
|
|
|
|
using FluentValidation;
|
2026-07-23 17:02:40 -03:00
|
|
|
|
using FluentValidation.Results;
|
2026-08-19 13:48:30 -03:00
|
|
|
|
using Microsoft.Extensions.Logging;
|
2026-07-24 17:35:34 -03:00
|
|
|
|
using Microsoft.Extensions.Options;
|
2026-09-16 11:34:45 -03:00
|
|
|
|
using System.Globalization;
|
2026-05-06 10:49:33 -05:00
|
|
|
|
using SeaHaven.DataServices.Interfaces;
|
2026-08-03 17:53:24 -03:00
|
|
|
|
using SeaHaven.DataServices.Models;
|
2026-07-24 17:35:34 -03:00
|
|
|
|
using SeaHaven.Services.Configuration;
|
2026-05-06 10:49:33 -05:00
|
|
|
|
using SeaHaven.Services.DTOs;
|
2026-07-23 17:02:40 -03:00
|
|
|
|
using SeaHaven.Services.Helpers;
|
2026-05-06 10:49:33 -05:00
|
|
|
|
using SeaHaven.Services.Interfaces;
|
2026-05-14 11:00:12 -05:00
|
|
|
|
using SeaHaven.Services.Validation;
|
2026-05-06 10:49:33 -05:00
|
|
|
|
|
|
|
|
|
|
namespace SeaHaven.Services.Implementation
|
|
|
|
|
|
{
|
|
|
|
|
|
public class VendorService : IVendorService
|
|
|
|
|
|
{
|
2026-09-15 16:02:44 -03:00
|
|
|
|
private const int MaxDirectoryPageSize = 100;
|
2026-07-23 17:02:40 -03:00
|
|
|
|
private static readonly string[] TerminalLegacyStatuses =
|
|
|
|
|
|
{ "completed", "cancelled", "canceled" };
|
|
|
|
|
|
|
2026-05-06 10:49:33 -05:00
|
|
|
|
private readonly IVendorDataService _vendorDataService;
|
2026-07-24 17:35:34 -03:00
|
|
|
|
private readonly IVendorPortalTokenService _vendorTokenService;
|
|
|
|
|
|
private readonly IZipCodeDistance _zipDistance;
|
|
|
|
|
|
private readonly FrontendOptions _frontendOptions;
|
2026-05-14 11:00:12 -05:00
|
|
|
|
private readonly ICreateVendorValidation _createValidator;
|
|
|
|
|
|
private readonly IUpdateVendorValidation _updateValidator;
|
2026-07-23 17:02:40 -03:00
|
|
|
|
private readonly IWorkOrderVendorUpdateValidation _workOrderUpdateValidator;
|
2026-08-19 13:48:30 -03:00
|
|
|
|
private readonly ILogger<VendorService> _logger;
|
2026-05-06 10:49:33 -05:00
|
|
|
|
|
2026-05-14 11:00:12 -05:00
|
|
|
|
public VendorService(
|
|
|
|
|
|
IVendorDataService vendorDataService,
|
2026-07-24 17:35:34 -03:00
|
|
|
|
IVendorPortalTokenService vendorTokenService,
|
|
|
|
|
|
IZipCodeDistance zipDistance,
|
|
|
|
|
|
IOptions<FrontendOptions> frontendOptions,
|
2026-05-14 11:00:12 -05:00
|
|
|
|
ICreateVendorValidation createValidator,
|
2026-07-23 17:02:40 -03:00
|
|
|
|
IUpdateVendorValidation updateValidator,
|
2026-08-19 13:48:30 -03:00
|
|
|
|
IWorkOrderVendorUpdateValidation workOrderUpdateValidator,
|
|
|
|
|
|
ILogger<VendorService> logger)
|
2026-05-06 10:49:33 -05:00
|
|
|
|
{
|
|
|
|
|
|
_vendorDataService = vendorDataService;
|
2026-07-24 17:35:34 -03:00
|
|
|
|
_vendorTokenService = vendorTokenService;
|
|
|
|
|
|
_zipDistance = zipDistance;
|
|
|
|
|
|
_frontendOptions = frontendOptions.Value;
|
2026-05-14 11:00:12 -05:00
|
|
|
|
_createValidator = createValidator;
|
|
|
|
|
|
_updateValidator = updateValidator;
|
2026-07-23 17:02:40 -03:00
|
|
|
|
_workOrderUpdateValidator = workOrderUpdateValidator;
|
2026-08-19 13:48:30 -03:00
|
|
|
|
_logger = logger;
|
2026-05-06 10:49:33 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task<VendorDTO?> GetVendorByIdAsync(int id)
|
|
|
|
|
|
{
|
2026-07-23 12:55:47 -03:00
|
|
|
|
var vendor = await _vendorDataService.GetByIdWithDetailsAsync(id);
|
2026-05-06 10:49:33 -05:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 12:55:47 -03:00
|
|
|
|
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)
|
2026-05-06 10:49:33 -05:00
|
|
|
|
{
|
2026-07-23 12:55:47 -03:00
|
|
|
|
var (items, totalCount) = await _vendorDataService.GetPagedAsync(
|
|
|
|
|
|
page,
|
|
|
|
|
|
pageSize,
|
|
|
|
|
|
search,
|
|
|
|
|
|
isActive,
|
|
|
|
|
|
companies,
|
|
|
|
|
|
trades,
|
|
|
|
|
|
locations,
|
|
|
|
|
|
jobBuckets);
|
2026-05-06 10:49:33 -05:00
|
|
|
|
return new PagedResult<VendorDTO>
|
|
|
|
|
|
{
|
|
|
|
|
|
Items = items.Select(MapToDTO).ToList(),
|
|
|
|
|
|
TotalCount = totalCount,
|
|
|
|
|
|
Page = page,
|
|
|
|
|
|
PageSize = pageSize
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 16:02:44 -03:00
|
|
|
|
// 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(
|
2026-08-03 17:53:24 -03:00
|
|
|
|
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
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-15 16:02:44 -03:00
|
|
|
|
// 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,
|
2026-09-16 11:34:45 -03:00
|
|
|
|
IReadOnlyCollection<string>? areas = null,
|
2026-09-15 16:02:44 -03:00
|
|
|
|
CancellationToken cancellationToken = default)
|
|
|
|
|
|
{
|
|
|
|
|
|
var normalizedPage = Math.Max(1, page);
|
|
|
|
|
|
var normalizedPageSize = Math.Clamp(pageSize, 1, MaxDirectoryPageSize);
|
2026-09-16 11:34:45 -03:00
|
|
|
|
var areaFilter = await ResolveAreaFilterAsync(areas, cancellationToken);
|
2026-09-15 16:02:44 -03:00
|
|
|
|
var (items, totalCount) = await _vendorDataService.GetCompanyDirectoryPagedAsync(
|
|
|
|
|
|
normalizedPage,
|
|
|
|
|
|
normalizedPageSize,
|
|
|
|
|
|
search,
|
|
|
|
|
|
isActive,
|
|
|
|
|
|
companies,
|
|
|
|
|
|
trades,
|
|
|
|
|
|
locations,
|
|
|
|
|
|
jobBuckets,
|
2026-09-16 11:34:45 -03:00
|
|
|
|
areaFilter,
|
2026-09-15 16:02:44 -03:00
|
|
|
|
cancellationToken);
|
|
|
|
|
|
|
|
|
|
|
|
return new PagedResult<VendorDirectoryItemDTO>
|
|
|
|
|
|
{
|
|
|
|
|
|
Items = items.Select(MapToCompanyGroup).ToList(),
|
|
|
|
|
|
TotalCount = totalCount,
|
|
|
|
|
|
Page = normalizedPage,
|
|
|
|
|
|
PageSize = normalizedPageSize
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-16 11:34:45 -03:00
|
|
|
|
// 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
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 16:02:44 -03:00
|
|
|
|
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,
|
2026-09-16 11:34:45 -03:00
|
|
|
|
AreaId = group.AreaId,
|
|
|
|
|
|
AreaName = group.AreaName,
|
2026-09-15 16:02:44 -03:00
|
|
|
|
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
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-06 10:49:33 -05:00
|
|
|
|
public async Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId)
|
|
|
|
|
|
{
|
2026-07-23 19:17:14 -03:00
|
|
|
|
NormalizePhoneFields(dto);
|
2026-05-14 11:00:12 -05:00
|
|
|
|
var validationResult = await _createValidator.ValidateAsync(dto);
|
|
|
|
|
|
if (!validationResult.IsValid)
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new ValidationException(validationResult.Errors);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 17:02:40 -03:00
|
|
|
|
var company = await ResolveCompanyAsync(dto.CompanyId, dto.Name, dto, userId);
|
|
|
|
|
|
|
2026-05-06 10:49:33 -05:00
|
|
|
|
var vendor = new Vendor
|
|
|
|
|
|
{
|
|
|
|
|
|
CompanyName = dto.Name,
|
2026-07-23 12:55:47 -03:00
|
|
|
|
ContactName = dto.ContactName,
|
2026-05-06 10:49:33 -05:00
|
|
|
|
Email = dto.Email,
|
2026-07-23 17:02:40 -03:00
|
|
|
|
Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone),
|
|
|
|
|
|
CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone),
|
|
|
|
|
|
PreferredContact = dto.PreferredContact,
|
2026-05-06 10:49:33 -05:00
|
|
|
|
Address = dto.Address,
|
|
|
|
|
|
City = dto.City,
|
|
|
|
|
|
State = dto.State,
|
|
|
|
|
|
Zip = dto.Zipcode,
|
2026-07-23 12:55:47 -03:00
|
|
|
|
TradeSpecialties = dto.TradeSpecialties,
|
|
|
|
|
|
GoogleMapsUrl = dto.GoogleMapsUrl,
|
|
|
|
|
|
Notes = dto.Notes,
|
|
|
|
|
|
IsActive = dto.IsActive,
|
2026-07-23 17:02:40 -03:00
|
|
|
|
CompanyId = company?.Id,
|
2026-05-06 10:49:33 -05:00
|
|
|
|
CreatedDate = DateTime.UtcNow,
|
|
|
|
|
|
createdby = userId
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-23 17:02:40 -03:00
|
|
|
|
ApplyCompanyFields(vendor, company);
|
|
|
|
|
|
|
2026-05-06 10:49:33 -05:00
|
|
|
|
var created = await _vendorDataService.AddAsync(vendor);
|
|
|
|
|
|
return MapToDTO(created);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId)
|
|
|
|
|
|
{
|
2026-07-23 19:17:14 -03:00
|
|
|
|
NormalizePhoneFields(dto);
|
2026-05-14 11:00:12 -05:00
|
|
|
|
var validationResult = await _updateValidator.ValidateAsync(dto);
|
|
|
|
|
|
if (!validationResult.IsValid)
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new ValidationException(validationResult.Errors);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 10:49:33 -05:00
|
|
|
|
var vendor = await _vendorDataService.GetByIdAsync(id);
|
|
|
|
|
|
if (vendor == null)
|
|
|
|
|
|
throw new InvalidOperationException($"Vendor with ID {id} not found");
|
|
|
|
|
|
|
2026-08-19 13:48:30 -03:00
|
|
|
|
if (dto.IsActive.HasValue && !dto.IsActive.Value)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (dto.ConfirmOpenWorkOrders)
|
|
|
|
|
|
await LogConfirmedDeactivationAsync(id, userId);
|
|
|
|
|
|
else
|
|
|
|
|
|
await AssertNoOpenLinkedWorkOrdersAsync(id);
|
|
|
|
|
|
}
|
2026-07-23 17:02:40 -03:00
|
|
|
|
|
2026-05-06 10:49:33 -05:00
|
|
|
|
if (dto.Name != null) vendor.CompanyName = dto.Name;
|
2026-07-23 12:55:47 -03:00
|
|
|
|
if (dto.ContactName != null) vendor.ContactName = dto.ContactName;
|
2026-05-06 10:49:33 -05:00
|
|
|
|
if (dto.Email != null) vendor.Email = dto.Email;
|
2026-07-23 17:02:40 -03:00
|
|
|
|
if (dto.Phone != null) vendor.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
|
|
|
|
|
|
if (dto.PreferredContact != null) vendor.PreferredContact = dto.PreferredContact;
|
2026-07-23 12:55:47 -03:00
|
|
|
|
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;
|
2026-05-06 10:49:33 -05:00
|
|
|
|
|
2026-07-23 19:17:14 -03:00
|
|
|
|
if (dto.CompanyId.HasValue || dto.Name != null || HasCompanyFieldUpdate(dto))
|
2026-07-23 17:02:40 -03:00
|
|
|
|
{
|
2026-07-23 19:17:14 -03:00
|
|
|
|
var company = await ResolveCompanyAsync(
|
|
|
|
|
|
dto.CompanyId ?? vendor.CompanyId,
|
|
|
|
|
|
dto.Name ?? vendor.CompanyName,
|
|
|
|
|
|
dto,
|
|
|
|
|
|
userId);
|
2026-07-23 17:02:40 -03:00
|
|
|
|
vendor.CompanyId = company?.Id;
|
|
|
|
|
|
ApplyCompanyFields(vendor, company);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 10:49:33 -05:00
|
|
|
|
vendor.LastModificationTime = DateTime.UtcNow;
|
|
|
|
|
|
if (int.TryParse(userId, out int userIdInt))
|
|
|
|
|
|
vendor.LastModifierUserId = userIdInt;
|
|
|
|
|
|
|
|
|
|
|
|
await _vendorDataService.UpdateAsync(vendor);
|
|
|
|
|
|
return MapToDTO(vendor);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 13:31:16 -03:00
|
|
|
|
public async Task DeleteVendorAsync(int id, string userId, bool confirmOpenWorkOrders)
|
2026-05-06 10:49:33 -05:00
|
|
|
|
{
|
|
|
|
|
|
var vendor = await _vendorDataService.GetByIdAsync(id);
|
|
|
|
|
|
if (vendor == null)
|
|
|
|
|
|
throw new InvalidOperationException($"Vendor with ID {id} not found");
|
|
|
|
|
|
|
2026-08-19 13:48:30 -03:00
|
|
|
|
if (confirmOpenWorkOrders)
|
|
|
|
|
|
await LogConfirmedDeactivationAsync(id, userId);
|
|
|
|
|
|
else
|
2026-08-19 13:31:16 -03:00
|
|
|
|
await AssertNoOpenLinkedWorkOrdersAsync(id);
|
2026-07-23 17:02:40 -03:00
|
|
|
|
|
2026-07-23 12:55:47 -03:00
|
|
|
|
vendor.IsActive = false;
|
|
|
|
|
|
vendor.LastModificationTime = DateTime.UtcNow;
|
|
|
|
|
|
if (int.TryParse(userId, out int userIdInt))
|
|
|
|
|
|
vendor.LastModifierUserId = userIdInt;
|
2026-05-06 10:49:33 -05:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 17:02:40 -03:00
|
|
|
|
public async Task<VendorDeactivationImpactDTO> GetDeactivationImpactAsync(int vendorId)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!await _vendorDataService.ExistsAsync(vendorId))
|
|
|
|
|
|
throw new InvalidOperationException($"Vendor with ID {vendorId} not found");
|
|
|
|
|
|
|
2026-07-23 19:17:14 -03:00
|
|
|
|
var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId);
|
2026-07-23 17:02:40 -03:00
|
|
|
|
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)
|
|
|
|
|
|
{
|
2026-07-23 19:17:14 -03:00
|
|
|
|
dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
|
2026-07-23 17:02:40 -03:00
|
|
|
|
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)
|
2026-07-23 19:17:14 -03:00
|
|
|
|
{
|
|
|
|
|
|
ApplyCompanyUpdates(byId, companyName, dto, userId);
|
|
|
|
|
|
await _vendorDataService.UpdateCompanyAsync(byId);
|
2026-07-23 17:02:40 -03:00
|
|
|
|
return byId;
|
2026-07-23 19:17:14 -03:00
|
|
|
|
}
|
2026-07-23 17:02:40 -03:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-07-23 19:17:14 -03:00
|
|
|
|
{
|
|
|
|
|
|
ApplyCompanyUpdates(existing, companyName, dto, userId);
|
|
|
|
|
|
await _vendorDataService.UpdateCompanyAsync(existing);
|
2026-07-23 17:02:40 -03:00
|
|
|
|
return existing;
|
2026-07-23 19:17:14 -03:00
|
|
|
|
}
|
2026-07-23 17:02:40 -03:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
2026-07-23 19:17:14 -03:00
|
|
|
|
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);
|
2026-07-23 17:02:40 -03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 13:48:30 -03:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 13:31:16 -03:00
|
|
|
|
// 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.
|
2026-07-23 17:02:40 -03:00
|
|
|
|
private async Task AssertNoOpenLinkedWorkOrdersAsync(int vendorId)
|
|
|
|
|
|
{
|
2026-07-23 19:17:14 -03:00
|
|
|
|
var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId);
|
2026-07-23 17:02:40 -03:00
|
|
|
|
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
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-06 10:49:33 -05:00
|
|
|
|
private VendorDTO MapToDTO(Vendor vendor)
|
|
|
|
|
|
{
|
|
|
|
|
|
return new VendorDTO
|
|
|
|
|
|
{
|
|
|
|
|
|
Id = vendor.Id,
|
|
|
|
|
|
Name = vendor.CompanyName,
|
2026-07-23 17:02:40 -03:00
|
|
|
|
CompanyId = vendor.CompanyId,
|
2026-07-23 12:55:47 -03:00
|
|
|
|
ContactName = vendor.ContactName,
|
2026-05-06 10:49:33 -05:00
|
|
|
|
Email = vendor.Email,
|
|
|
|
|
|
Phone = vendor.Phone,
|
2026-07-23 12:55:47 -03:00
|
|
|
|
CompanyPhone = vendor.CompanyPhone,
|
2026-07-23 17:02:40 -03:00
|
|
|
|
PreferredContact = vendor.PreferredContact,
|
2026-05-06 10:49:33 -05:00
|
|
|
|
Address = vendor.Address,
|
|
|
|
|
|
City = vendor.City,
|
|
|
|
|
|
State = vendor.State,
|
2026-07-23 12:55:47 -03:00
|
|
|
|
Zipcode = vendor.Zip,
|
|
|
|
|
|
TradeSpecialties = vendor.TradeSpecialties,
|
|
|
|
|
|
GoogleMapsUrl = vendor.GoogleMapsUrl,
|
|
|
|
|
|
Notes = vendor.Notes,
|
|
|
|
|
|
IsActive = vendor.IsActive,
|
|
|
|
|
|
TotalJobs = vendor.Dispatches == null
|
|
|
|
|
|
? vendor.TotalJobs
|
|
|
|
|
|
: CountDistinctWorkOrders(vendor)
|
2026-05-06 10:49:33 -05:00
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-07-23 12:55:47 -03:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-07-24 17:35:34 -03:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
{
|
2026-08-03 17:53:24 -03:00
|
|
|
|
// Companies and Locations are sourced from company-owned VendorCompany fields
|
2026-08-18 12:11:13 -03:00
|
|
|
|
// (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.
|
2026-08-03 17:53:24 -03:00
|
|
|
|
var companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken);
|
2026-08-18 12:11:13 -03:00
|
|
|
|
var canonicalTrades = await _vendorDataService.GetActiveTradesAsync(cancellationToken);
|
2026-09-16 11:34:45 -03:00
|
|
|
|
var activeAreas = await _vendorDataService.GetActiveAreasAsync(cancellationToken);
|
2026-07-24 17:35:34 -03:00
|
|
|
|
|
2026-08-03 17:53:24 -03:00
|
|
|
|
var companyFacets = companies
|
|
|
|
|
|
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
|
|
|
|
|
.GroupBy(c => c.Name!.Trim(), StringComparer.OrdinalIgnoreCase)
|
2026-07-24 17:35:34 -03:00
|
|
|
|
.Select(group => group.First())
|
2026-08-03 17:53:24 -03:00
|
|
|
|
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
|
|
|
|
|
|
.Select(c => new VendorFacetCompanyDTO
|
2026-07-24 17:35:34 -03:00
|
|
|
|
{
|
2026-08-03 17:53:24 -03:00
|
|
|
|
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
|
2026-07-24 17:35:34 -03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-18 12:11:13 -03:00
|
|
|
|
var trades = canonicalTrades.Select(t => t.Name).ToList();
|
2026-07-24 17:35:34 -03:00
|
|
|
|
|
2026-08-25 20:33:47 -03:00
|
|
|
|
// 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.
|
2026-08-03 17:53:24 -03:00
|
|
|
|
var locations = companies
|
2026-08-25 20:33:47 -03:00
|
|
|
|
.Where(c => !string.IsNullOrWhiteSpace(c.City)
|
|
|
|
|
|
|| !string.IsNullOrWhiteSpace(c.State)
|
|
|
|
|
|
|| !string.IsNullOrWhiteSpace(c.Address))
|
2026-08-03 17:53:24 -03:00
|
|
|
|
.Select(c => new VendorFacetLocationDTO
|
2026-07-24 17:35:34 -03:00
|
|
|
|
{
|
2026-08-03 17:53:24 -03:00
|
|
|
|
City = c.City,
|
|
|
|
|
|
State = c.State,
|
2026-08-25 20:33:47 -03:00
|
|
|
|
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()
|
2026-07-24 17:35:34 -03:00
|
|
|
|
})
|
|
|
|
|
|
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
|
|
|
|
|
|
.OrderBy(location => location.Label);
|
|
|
|
|
|
|
2026-09-16 11:34:45 -03:00
|
|
|
|
// 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();
|
|
|
|
|
|
|
2026-07-24 17:35:34 -03:00
|
|
|
|
return new VendorFacetsDTO
|
|
|
|
|
|
{
|
2026-08-03 17:53:24 -03:00
|
|
|
|
Companies = companyFacets,
|
2026-07-24 17:35:34 -03:00
|
|
|
|
Trades = trades,
|
2026-09-16 11:34:45 -03:00
|
|
|
|
Areas = areas,
|
2026-07-24 17:35:34 -03:00
|
|
|
|
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"
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-05-06 10:49:33 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|