shoc-backend/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs
2026-08-27 14:44:17 -03:00

492 lines
21 KiB
C#

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 async Task<VendorCompanyRosterReadModel?> 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,
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<VendorCompanyRosterReadModel> 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;
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<VendorCompanyRosterReadModel> 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,
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<VendorCompanyRosterReadModel> 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;
// 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<List<LinkedWorkOrderInfo>> GetOpenLinkedWorkOrdersAsync(
IReadOnlyCollection<int> 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;
}
}
}