mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 11:53:12 +00:00
* feat(vendors): add company roster management * fix(security): remove request-controlled write guards * fix(vendors): synchronize roster company fields * fix(vendors): source facets from companies
320 lines
13 KiB
C#
320 lines
13 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 company = new VendorCompany
|
|
{
|
|
Name = trimmedName,
|
|
NormalizedName = trimmedName.ToLowerInvariant(),
|
|
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);
|
|
}
|
|
|
|
await _context.SaveChangesAsync(cancellationToken);
|
|
|
|
return await GetRosterAsync(null, company.Id, cancellationToken)
|
|
?? throw new InvalidOperationException("Roster could not be reloaded after create.");
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|