using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentValidation; using FluentValidation.Results; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; 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 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; public VendorService( IVendorDataService vendorDataService, IVendorPortalTokenService vendorTokenService, IZipCodeDistance zipDistance, IOptions frontendOptions, ICreateVendorValidation createValidator, IUpdateVendorValidation updateValidator, IWorkOrderVendorUpdateValidation workOrderUpdateValidator) { _vendorDataService = vendorDataService; _vendorTokenService = vendorTokenService; _zipDistance = zipDistance; _frontendOptions = frontendOptions.Value; _createValidator = createValidator; _updateValidator = updateValidator; _workOrderUpdateValidator = workOrderUpdateValidator; } public async Task GetVendorByIdAsync(int id) { var vendor = await _vendorDataService.GetByIdWithDetailsAsync(id); return vendor == null ? null : MapToDTO(vendor); } public async Task GetVendorByIdWithDetailsAsync(int id) { var vendor = await _vendorDataService.GetByIdWithDetailsAsync(id); return vendor == null ? null : MapToDTO(vendor); } public async Task> GetAllVendorsAsync() { var vendors = await _vendorDataService.GetAllAsync(); return vendors.Select(MapToDTO); } public async Task> GetVendorsPagedAsync( int page, int pageSize, string? search = null, bool? isActive = true, IReadOnlyCollection? companies = null, IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null) { var (items, totalCount) = await _vendorDataService.GetPagedAsync( page, pageSize, search, isActive, companies, trades, locations, jobBuckets); return new PagedResult { Items = items.Select(MapToDTO).ToList(), TotalCount = totalCount, Page = page, PageSize = pageSize }; } public async Task 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 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) 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) { var vendor = await _vendorDataService.GetByIdAsync(id); if (vendor == null) throw new InvalidOperationException($"Vendor with ID {id} not found"); 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 VendorExistsAsync(int id) { return await _vendorDataService.ExistsAsync(id); } public async Task GetTotalVendorCountAsync() { return await _vendorDataService.CountAsync(); } public async Task 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 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(); 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 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); } 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(); return dispatch.WorkOrderId.HasValue ? linked.Append(dispatch.WorkOrderId.Value) : linked; }) .Distinct() .Count(); } public async Task> 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 GetFacetsAsync(bool? isActive, CancellationToken cancellationToken) { var vendors = await _vendorDataService.GetVendorsForFacetsAsync(isActive, cancellationToken); var companies = vendors .Where(v => !string.IsNullOrWhiteSpace(v.CompanyName)) .GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) .OrderBy(v => v.CompanyName) .Select(v => new VendorFacetCompanyDTO { Name = v.CompanyName, CompanyId = v.CompanyId, CompanyPhone = v.CompanyPhone, Email = v.Email, PreferredContact = v.PreferredContact, Address = v.Address, City = v.City, State = v.State, Zip = v.Zip, GoogleMapsUrl = v.GoogleMapsUrl }); var trades = vendors .SelectMany(v => (v.TradeSpecialties ?? "") .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(value => value); var locations = vendors .Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State)) .Select(v => new VendorFacetLocationDTO { City = v.City, State = v.State, Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value))) }) .DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase) .OrderBy(location => location.Label); return new VendorFacetsDTO { Companies = companies, Trades = trades, 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 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 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 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" }; } } }