using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Implementation { public class VendorCompanyRosterDataService : IVendorCompanyRosterDataService { private static readonly string[] TerminalLegacyStatuses = { "completed", "cancelled", "canceled" }; private readonly ApplicationDbContext _context; public VendorCompanyRosterDataService(ApplicationDbContext context) { _context = context; } public Task IsActiveAreaAsync(int areaId, CancellationToken cancellationToken) => _context.Areas .AsNoTracking() .AnyAsync(area => area.Id == areaId && area.IsActive, cancellationToken); public async Task GetRosterAsync( int? vendorId, int? companyId, CancellationToken cancellationToken) { int? resolvedCompanyId = companyId; if (vendorId.HasValue) { var vendorCompanyId = await _context.Vendors .AsNoTracking() .Where(v => v.Id == vendorId.Value && (v.IsDeleted == null || v.IsDeleted == false)) .Select(v => v.CompanyId) .FirstOrDefaultAsync(cancellationToken); if (!vendorCompanyId.HasValue || (companyId.HasValue && vendorCompanyId.Value != companyId.Value)) return null; resolvedCompanyId = vendorCompanyId; } if (!resolvedCompanyId.HasValue) return null; var company = await _context.VendorCompanies .AsNoTracking() .Where(c => c.Id == resolvedCompanyId.Value) .Select(c => new VendorCompanyRosterReadModel { CompanyId = c.Id, Name = c.Name, CompanyPhone = c.CompanyPhone, Email = c.Email, Address = c.Address, City = c.City, State = c.State, Zip = c.Zip, GoogleMapsUrl = c.GoogleMapsUrl, Notes = c.Notes, AreaId = c.AreaId, AreaName = c.Area != null ? c.Area.Name : null, RowVersion = c.RowVersion }) .FirstOrDefaultAsync(cancellationToken); if (company == null) return null; var technicians = await _context.Vendors .AsNoTracking() .Where(v => v.CompanyId == company.CompanyId && (v.IsDeleted == null || v.IsDeleted == false)) .OrderBy(v => v.Id) .Select(v => new VendorRosterTechnicianReadModel { Id = v.Id, ContactName = v.ContactName, Phone = v.Phone, Email = v.Email, PreferredContact = v.PreferredContact, TradeSpecialties = v.TradeSpecialties, IsActive = v.IsActive, TotalJobs = v.TotalJobs }) .ToListAsync(cancellationToken); company.Technicians = technicians; return company; } public async Task SaveRosterAsync( VendorCompanyRosterWriteModel roster, CancellationToken cancellationToken) { var company = await _context.VendorCompanies .FirstOrDefaultAsync(c => c.Id == roster.CompanyId, cancellationToken); if (company == null) throw new KeyNotFoundException($"Vendor company with ID {roster.CompanyId} not found."); // Concurrency: pin the client-supplied row version as the original value so a // stale save throws DbUpdateConcurrencyException (mapped to a stable 409). if (roster.RowVersion != null && roster.RowVersion.Length > 0) _context.Entry(company).Property(c => c.RowVersion).OriginalValue = roster.RowVersion; var now = DateTime.UtcNow; var trimmedName = roster.Name.Trim(); company.Name = trimmedName; company.NormalizedName = trimmedName.ToLowerInvariant(); company.CompanyPhone = roster.CompanyPhone; company.Email = roster.Email; company.Address = roster.Address; company.City = roster.City; company.State = roster.State; company.Zip = roster.Zip; company.GoogleMapsUrl = roster.GoogleMapsUrl; company.Notes = roster.Notes; if (roster.AreaIdProvided) company.AreaId = roster.AreaId; company.LastModificationTime = now; int? actorId = int.TryParse(roster.ActorUserId, out var parsedActorId) ? parsedActorId : null; if (actorId.HasValue) company.LastModifierUserId = actorId; var existingTechnicians = await _context.Vendors .Where(v => v.CompanyId == company.Id && (v.IsDeleted == null || v.IsDeleted == false)) .ToListAsync(cancellationToken); var snapshotIds = roster.Technicians .Where(t => t.Id.HasValue) .Select(t => t.Id!.Value) .ToHashSet(); var toRemove = existingTechnicians .Where(e => !snapshotIds.Contains(e.Id)) .ToList(); // A technician omitted from the snapshot may only be soft-deleted when it has no // open linked work orders; otherwise the whole reconcile fails (nothing saved). if (toRemove.Count > 0) { var blocked = await GetOpenLinkedWorkOrdersAsync( toRemove.Select(vendor => vendor.Id).ToArray(), cancellationToken); if (blocked.Count > 0) throw new VendorRosterConflictException( "One or more technicians cannot be removed because they have open linked work orders.", blocked); } foreach (var removed in toRemove) { removed.IsActive = false; removed.IsDeleted = true; removed.DeletionTime = now; removed.LastModificationTime = now; if (actorId.HasValue) removed.LastModifierUserId = actorId; } foreach (var technician in roster.Technicians) { if (technician.Id.HasValue) { var existing = existingTechnicians.FirstOrDefault(e => e.Id == technician.Id.Value); if (existing == null) continue; existing.ContactName = technician.ContactName; existing.Phone = technician.Phone; existing.Email = technician.Email; existing.PreferredContact = technician.PreferredContact; existing.TradeSpecialties = technician.TradeSpecialties; existing.IsActive = technician.IsActive; existing.CompanyName = trimmedName; existing.CompanyPhone = company.CompanyPhone; existing.Address = company.Address; existing.City = company.City; existing.State = company.State; existing.Zip = company.Zip; existing.GoogleMapsUrl = company.GoogleMapsUrl; existing.LastModificationTime = now; if (actorId.HasValue) existing.LastModifierUserId = actorId; } else { var vendor = new Vendor { CompanyName = trimmedName, ContactName = technician.ContactName, Phone = technician.Phone, Email = technician.Email, PreferredContact = technician.PreferredContact, TradeSpecialties = technician.TradeSpecialties, IsActive = technician.IsActive, CompanyId = company.Id, CompanyPhone = company.CompanyPhone, Address = company.Address, City = company.City, State = company.State, Zip = company.Zip, GoogleMapsUrl = company.GoogleMapsUrl, CreatedDate = now, createdby = roster.ActorUserId }; await _context.Vendors.AddAsync(vendor, cancellationToken); } } await _context.SaveChangesAsync(cancellationToken); return await GetRosterAsync(null, company.Id, cancellationToken) ?? throw new InvalidOperationException("Roster could not be reloaded after save."); } public async Task CreateRosterAsync( VendorCompanyRosterWriteModel roster, CancellationToken cancellationToken) { var now = DateTime.UtcNow; var trimmedName = roster.Name.Trim(); var normalizedName = trimmedName.ToLowerInvariant(); // NormalizedName has an unfiltered unique index, so deleted companies still // reserve their names. Detect the ordinary collision before tracking a new // aggregate; the save guard below covers the concurrent-create race. var nameTaken = await _context.VendorCompanies .AnyAsync(company => company.NormalizedName == normalizedName, cancellationToken); if (nameTaken) throw new VendorRosterDuplicateNameException( "Another vendor company already uses that name.", new InvalidOperationException( $"NormalizedName '{normalizedName}' is already in use.")); var company = new VendorCompany { Name = trimmedName, NormalizedName = normalizedName, CompanyPhone = roster.CompanyPhone, Email = roster.Email, Address = roster.Address, City = roster.City, State = roster.State, Zip = roster.Zip, GoogleMapsUrl = roster.GoogleMapsUrl, Notes = roster.Notes, AreaId = roster.AreaId, CreatedDate = now, createdby = roster.ActorUserId }; await _context.VendorCompanies.AddAsync(company, cancellationToken); foreach (var technician in roster.Technicians) { var vendor = new Vendor { CompanyName = trimmedName, ContactName = technician.ContactName, Phone = technician.Phone, Email = technician.Email, PreferredContact = technician.PreferredContact, TradeSpecialties = technician.TradeSpecialties, IsActive = technician.IsActive, Company = company, CompanyPhone = company.CompanyPhone, Address = company.Address, City = company.City, State = company.State, Zip = company.Zip, GoogleMapsUrl = company.GoogleMapsUrl, CreatedDate = now, createdby = roster.ActorUserId }; await _context.Vendors.AddAsync(vendor, cancellationToken); } try { await _context.SaveChangesAsync(cancellationToken); } catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex)) { throw new VendorRosterDuplicateNameException( "Another vendor company already uses that name.", ex); } return await GetRosterAsync(null, company.Id, cancellationToken) ?? throw new InvalidOperationException("Roster could not be reloaded after create."); } private static bool IsUniqueConstraintViolation(DbUpdateException ex) { var message = ex.InnerException?.Message ?? ex.Message; return message.Contains("duplicate", StringComparison.OrdinalIgnoreCase) || message.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase); } public async Task AddTechniciansAsync( VendorCompanyRosterAddWriteModel roster, CancellationToken cancellationToken) { var company = await _context.VendorCompanies .FirstOrDefaultAsync(c => c.Id == roster.CompanyId, cancellationToken); if (company == null) throw new KeyNotFoundException($"Vendor company with ID {roster.CompanyId} not found."); // Concurrency: pin the client-supplied row version as the original value so a // stale save throws DbUpdateConcurrencyException (mapped to a stable 409). if (roster.RowVersion != null && roster.RowVersion.Length > 0) _context.Entry(company).Property(c => c.RowVersion).OriginalValue = roster.RowVersion; var now = DateTime.UtcNow; int? actorId = int.TryParse(roster.ActorUserId, out var parsedActorId) ? parsedActorId : null; string? renamedTo = null; // Partial company update: only fields present in CompanyFields are applied; // a null field leaves the stored value untouched. The company row is always // touched so the concurrency token fires on every additive save. if (roster.CompanyFields != null) { var fields = roster.CompanyFields; if (fields.Name != null) { var trimmedName = fields.Name.Trim(); var normalized = trimmedName.ToLowerInvariant(); if (normalized != company.NormalizedName) { var nameTaken = await _context.VendorCompanies .AnyAsync( other => other.Id != company.Id && (other.IsDeleted == null || other.IsDeleted == false) && other.NormalizedName == normalized, cancellationToken); if (nameTaken) throw new VendorRosterDuplicateNameException( "Another vendor company already uses that name.", new InvalidOperationException( $"NormalizedName '{normalized}' is already in use.")); renamedTo = normalized; } company.Name = trimmedName; company.NormalizedName = normalized; } if (fields.CompanyPhone != null) company.CompanyPhone = fields.CompanyPhone; if (fields.Email != null) company.Email = fields.Email; if (fields.Address != null) company.Address = fields.Address; if (fields.City != null) company.City = fields.City; if (fields.State != null) company.State = fields.State; if (fields.Zip != null) company.Zip = fields.Zip; if (fields.GoogleMapsUrl != null) company.GoogleMapsUrl = fields.GoogleMapsUrl; if (fields.Notes != null) company.Notes = fields.Notes; if (fields.AreaIdProvided) company.AreaId = fields.AreaId; // Keep the denormalized company columns on existing technicians aligned // with the company row (same invariant SaveRosterAsync maintains). This // never removes, deactivates or rewrites technician-owned fields. var existingTechnicians = await _context.Vendors .Where(v => v.CompanyId == company.Id && (v.IsDeleted == null || v.IsDeleted == false)) .ToListAsync(cancellationToken); foreach (var existing in existingTechnicians) { existing.CompanyName = company.Name; existing.CompanyPhone = company.CompanyPhone; existing.Address = company.Address; existing.City = company.City; existing.State = company.State; existing.Zip = company.Zip; existing.GoogleMapsUrl = company.GoogleMapsUrl; existing.LastModificationTime = now; if (actorId.HasValue) existing.LastModifierUserId = actorId; } } company.LastModificationTime = now; if (actorId.HasValue) company.LastModifierUserId = actorId; // Purely additive: every submitted technician is a new row. Existing // technicians are deliberately not consulted for removal. foreach (var technician in roster.AddTechnicians) { var vendor = new Vendor { CompanyName = company.Name, ContactName = technician.ContactName, Phone = technician.Phone, Email = technician.Email, PreferredContact = technician.PreferredContact, TradeSpecialties = technician.TradeSpecialties, IsActive = technician.IsActive, CompanyId = company.Id, CompanyPhone = company.CompanyPhone, Address = company.Address, City = company.City, State = company.State, Zip = company.Zip, GoogleMapsUrl = company.GoogleMapsUrl, CreatedDate = now, createdby = roster.ActorUserId }; await _context.Vendors.AddAsync(vendor, cancellationToken); } // SH-250: a rename can collide with the unique NormalizedName index. Without // this guard the DbUpdateException reached the controller's generic handler // and surfaced as a 500, even though SQL Server rolls the batch back cleanly. // The pre-check above handles the ordinary case; this covers the race where a // competing rename commits between that check and this save. try { await _context.SaveChangesAsync(cancellationToken); } catch (DbUpdateException ex) when (renamedTo != null && ex is not DbUpdateConcurrencyException) { throw new VendorRosterDuplicateNameException( "Another vendor company already uses that name.", ex); } return await GetRosterAsync(null, company.Id, cancellationToken) ?? throw new InvalidOperationException("Roster could not be reloaded after save."); } private async Task> GetOpenLinkedWorkOrdersAsync( IReadOnlyCollection vendorIds, CancellationToken cancellationToken) { var directLinks = _context.Dispatches .Where(d => vendorIds.Contains(d.VendorId) && d.WorkOrderId.HasValue) .Select(d => new { DispatchId = d.Id, WorkOrderId = d.WorkOrderId!.Value }); var multiLinks = _context.DispatchWorkOrders .Where(link => link.Dispatch != null && vendorIds.Contains(link.Dispatch.VendorId)) .Select(link => new { DispatchId = link.DispatchId, WorkOrderId = link.WorkOrderId }); var linkedIds = await directLinks .Concat(multiLinks) .Join(_context.workOrders, link => link.WorkOrderId, wo => wo.Id, (link, wo) => new LinkedWorkOrderInfo { WorkOrderId = wo.Id, WorkOrderNumber = wo.WorkerOrderNumber, WorkOrderTitle = wo.WorkerOrderTitle, Status = wo.Status, LifecycleStatus = wo.LifecycleStatus, ScheduledDate = wo.ScheduledDate, DispatchId = link.DispatchId }) .OrderBy(wo => wo.WorkOrderId) .ToListAsync(cancellationToken); return linkedIds .Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status)) .DistinctBy(x => x.WorkOrderId) .ToList(); } 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; } } }