mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 14:13:12 +00:00
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.
504 lines
22 KiB
C#
504 lines
22 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 Task<bool> IsActiveAreaAsync(int areaId, CancellationToken cancellationToken) =>
|
|
_context.Areas
|
|
.AsNoTracking()
|
|
.AnyAsync(area => area.Id == areaId && area.IsActive, cancellationToken);
|
|
|
|
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,
|
|
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<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;
|
|
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<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,
|
|
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<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;
|
|
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<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;
|
|
}
|
|
}
|
|
}
|