mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
fix(vendors): preserve company and phone integrity
This commit is contained in:
parent
8cf49afc2c
commit
5b4b538c16
5 changed files with 209 additions and 31 deletions
|
|
@ -269,12 +269,12 @@ public class VendorDataServiceTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildOpenLinkedWorkOrdersQuery_TranslatesForSqlServerWithoutClientEvaluation()
|
||||
public void BuildLinkedWorkOrdersQuery_TranslatesForSqlServerWithoutClientEvaluation()
|
||||
{
|
||||
using var context = NewSqlServerContext();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var sql = service.BuildOpenLinkedWorkOrdersQuery(60).ToQueryString();
|
||||
var sql = service.BuildLinkedWorkOrdersQuery(60).ToQueryString();
|
||||
|
||||
sql.Should().Contain("SELECT");
|
||||
sql.Should().Contain("Dispatches");
|
||||
|
|
@ -283,7 +283,7 @@ public class VendorDataServiceTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOpenLinkedWorkOrdersAsync_DedupsToOneRecordPerWorkOrderAcrossDirectAndMultiLinks()
|
||||
public async Task GetLinkedWorkOrdersAsync_DedupsToOneRecordPerWorkOrderAcrossDirectAndMultiLinks()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
var vendor = new Vendor { Id = 60, CompanyName = "Linked Co", IsActive = true };
|
||||
|
|
@ -315,7 +315,7 @@ public class VendorDataServiceTests
|
|||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new VendorDataService(context);
|
||||
var result = await service.GetOpenLinkedWorkOrdersAsync(60);
|
||||
var result = await service.GetLinkedWorkOrdersAsync(60);
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
result.Select(x => x.WorkOrderId).Should().BeEquivalentTo(new[] { 701, 702 });
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ public class VendorServiceTests
|
|||
var existing = new Vendor { Id = 8, CompanyName = "Old", IsActive = true };
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(8)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(8)).ReturnsAsync(new List<LinkedWorkOrderInfo>());
|
||||
data.Setup(x => x.GetLinkedWorkOrdersAsync(8)).ReturnsAsync(new List<LinkedWorkOrderInfo>());
|
||||
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
|
||||
|
||||
var result = await NewService(data).UpdateVendorAsync(8, new UpdateVendorDTO
|
||||
|
|
@ -83,7 +83,7 @@ public class VendorServiceTests
|
|||
var existing = new Vendor { Id = 9, CompanyName = "Keep History", IsActive = true };
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(9)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(9)).ReturnsAsync(new List<LinkedWorkOrderInfo>());
|
||||
data.Setup(x => x.GetLinkedWorkOrdersAsync(9)).ReturnsAsync(new List<LinkedWorkOrderInfo>());
|
||||
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
|
||||
|
||||
await NewService(data).DeleteVendorAsync(9, "42");
|
||||
|
|
@ -221,7 +221,7 @@ public class VendorServiceTests
|
|||
var existing = new Vendor { Id = 20, CompanyName = "Guarded", IsActive = true };
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(20)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(20))
|
||||
data.Setup(x => x.GetLinkedWorkOrdersAsync(20))
|
||||
.ReturnsAsync(new List<LinkedWorkOrderInfo>
|
||||
{
|
||||
new()
|
||||
|
|
@ -283,7 +283,7 @@ public class VendorServiceTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateVendor_RejectsNonCanonicalPhoneAndInvalidPreferredContact()
|
||||
public void CreateVendor_RejectsInvalidPhoneAndInvalidPreferredContact()
|
||||
{
|
||||
var validator = new CreateVendorValidation();
|
||||
|
||||
|
|
@ -300,6 +300,99 @@ public class VendorServiceTests
|
|||
result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorDTO.PreferredContact));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVendor_NormalizesCommonNorthAmericanPhoneFormatsBeforeValidation()
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
Vendor? saved = null;
|
||||
data.Setup(x => x.AddAsync(It.IsAny<Vendor>()))
|
||||
.ReturnsAsync((Vendor vendor) =>
|
||||
{
|
||||
saved = vendor;
|
||||
vendor.Id = 41;
|
||||
return vendor;
|
||||
});
|
||||
|
||||
await NewService(data).CreateVendorAsync(new CreateVendorDTO
|
||||
{
|
||||
Name = "Acme",
|
||||
ContactName = "Riley",
|
||||
Phone = "+1 312 555 0100"
|
||||
}, "42");
|
||||
|
||||
saved!.Phone.Should().Be("(312) 555-0100");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateVendor_RoutesCompanyOwnedFieldsToTheLinkedCompany()
|
||||
{
|
||||
var existing = new Vendor
|
||||
{
|
||||
Id = 42,
|
||||
CompanyId = 77,
|
||||
CompanyName = "Acme",
|
||||
Address = "Old address",
|
||||
IsActive = true
|
||||
};
|
||||
var company = new VendorCompany
|
||||
{
|
||||
Id = 77,
|
||||
Name = "Acme",
|
||||
NormalizedName = "acme",
|
||||
Address = "Old address"
|
||||
};
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(42)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(company);
|
||||
data.Setup(x => x.UpdateCompanyAsync(company)).Returns(Task.CompletedTask);
|
||||
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
|
||||
|
||||
await NewService(data).UpdateVendorAsync(42, new UpdateVendorDTO
|
||||
{
|
||||
Name = "Acme Services",
|
||||
Address = "200 New Street"
|
||||
}, "42");
|
||||
|
||||
company.Name.Should().Be("Acme Services");
|
||||
company.NormalizedName.Should().Be("acme services");
|
||||
company.Address.Should().Be("200 New Street");
|
||||
existing.Address.Should().Be("200 New Street");
|
||||
data.Verify(x => x.UpdateCompanyAsync(company), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateVendor_SparseCompanyDoesNotEraseExistingVendorFields()
|
||||
{
|
||||
var existing = new Vendor
|
||||
{
|
||||
Id = 43,
|
||||
CompanyId = 77,
|
||||
CompanyName = "Acme",
|
||||
Address = "Keep this address",
|
||||
CompanyPhone = "(312) 555-0199",
|
||||
IsActive = true
|
||||
};
|
||||
var sparseCompany = new VendorCompany
|
||||
{
|
||||
Id = 77,
|
||||
Name = "Acme",
|
||||
NormalizedName = "acme"
|
||||
};
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(43)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(sparseCompany);
|
||||
data.Setup(x => x.UpdateCompanyAsync(sparseCompany)).Returns(Task.CompletedTask);
|
||||
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
|
||||
|
||||
await NewService(data).UpdateVendorAsync(43, new UpdateVendorDTO
|
||||
{
|
||||
Name = "Acme"
|
||||
}, "42");
|
||||
|
||||
existing.Address.Should().Be("Keep this address");
|
||||
existing.CompanyPhone.Should().Be("(312) 555-0199");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkOrderVendorUpdate_AcceptsCanonicalPhoneAndValidPreferredContact()
|
||||
{
|
||||
|
|
@ -383,6 +476,6 @@ public class VendorServiceTests
|
|||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*404*not found");
|
||||
data.Verify(x => x.GetOpenLinkedWorkOrdersAsync(It.IsAny<int>()), Times.Never);
|
||||
data.Verify(x => x.GetLinkedWorkOrdersAsync(It.IsAny<int>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -318,11 +318,30 @@ namespace SeaHaven.DataServices.Implementation
|
|||
public async Task<VendorCompany> AddCompanyAsync(VendorCompany company)
|
||||
{
|
||||
await _context.VendorCompanies.AddAsync(company);
|
||||
await _context.SaveChangesAsync();
|
||||
return company;
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
return company;
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
_context.Entry(company).State = EntityState.Detached;
|
||||
var existing = await _context.VendorCompanies
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(c => c.NormalizedName == company.NormalizedName);
|
||||
if (existing != null)
|
||||
return existing;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
internal IQueryable<LinkedWorkOrderInfo> BuildOpenLinkedWorkOrdersQuery(int vendorId)
|
||||
public async Task UpdateCompanyAsync(VendorCompany company)
|
||||
{
|
||||
_context.VendorCompanies.Update(company);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
internal IQueryable<LinkedWorkOrderInfo> BuildLinkedWorkOrdersQuery(int vendorId)
|
||||
{
|
||||
var directLinks = _context.Dispatches
|
||||
.Where(d => d.VendorId == vendorId && d.WorkOrderId.HasValue)
|
||||
|
|
@ -349,9 +368,9 @@ namespace SeaHaven.DataServices.Implementation
|
|||
};
|
||||
}
|
||||
|
||||
public async Task<List<LinkedWorkOrderInfo>> GetOpenLinkedWorkOrdersAsync(int vendorId)
|
||||
public async Task<List<LinkedWorkOrderInfo>> GetLinkedWorkOrdersAsync(int vendorId)
|
||||
{
|
||||
var raw = await BuildOpenLinkedWorkOrdersQuery(vendorId).ToListAsync();
|
||||
var raw = await BuildLinkedWorkOrdersQuery(vendorId).ToListAsync();
|
||||
return raw.DistinctBy(x => x.WorkOrderId).ToList();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<VendorCompany?> GetCompanyByIdAsync(int id);
|
||||
Task<VendorCompany?> GetCompanyByNormalizedNameAsync(string normalizedName);
|
||||
Task<VendorCompany> AddCompanyAsync(VendorCompany company);
|
||||
Task<List<LinkedWorkOrderInfo>> GetOpenLinkedWorkOrdersAsync(int vendorId);
|
||||
Task UpdateCompanyAsync(VendorCompany company);
|
||||
Task<List<LinkedWorkOrderInfo>> GetLinkedWorkOrdersAsync(int vendorId);
|
||||
Task<bool> IsVendorAssignedToWorkOrderAsync(int vendorId, int workOrderId);
|
||||
Task UpdateWithAuditLogsAsync(Vendor vendor, IReadOnlyCollection<VendorAuditLog> auditLogs);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
public async Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId)
|
||||
{
|
||||
NormalizePhoneFields(dto);
|
||||
var validationResult = await _createValidator.ValidateAsync(dto);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
|
|
@ -117,6 +118,7 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
public async Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId)
|
||||
{
|
||||
NormalizePhoneFields(dto);
|
||||
var validationResult = await _updateValidator.ValidateAsync(dto);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
|
|
@ -134,20 +136,18 @@ namespace SeaHaven.Services.Implementation
|
|||
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.CompanyPhone != null) vendor.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone);
|
||||
if (dto.PreferredContact != null) vendor.PreferredContact = dto.PreferredContact;
|
||||
if (dto.Address != null) vendor.Address = dto.Address;
|
||||
if (dto.City != null) vendor.City = dto.City;
|
||||
if (dto.State != null) vendor.State = dto.State;
|
||||
if (dto.Zipcode != null) vendor.Zip = dto.Zipcode;
|
||||
if (dto.TradeSpecialties != null) vendor.TradeSpecialties = dto.TradeSpecialties;
|
||||
if (dto.GoogleMapsUrl != null) vendor.GoogleMapsUrl = dto.GoogleMapsUrl;
|
||||
if (dto.Notes != null) vendor.Notes = dto.Notes;
|
||||
if (dto.IsActive.HasValue) vendor.IsActive = dto.IsActive.Value;
|
||||
|
||||
if (dto.CompanyId.HasValue || dto.Name != null)
|
||||
if (dto.CompanyId.HasValue || dto.Name != null || HasCompanyFieldUpdate(dto))
|
||||
{
|
||||
var company = await ResolveCompanyAsync(dto.CompanyId, dto.Name, dto, userId);
|
||||
var company = await ResolveCompanyAsync(
|
||||
dto.CompanyId ?? vendor.CompanyId,
|
||||
dto.Name ?? vendor.CompanyName,
|
||||
dto,
|
||||
userId);
|
||||
vendor.CompanyId = company?.Id;
|
||||
ApplyCompanyFields(vendor, company);
|
||||
}
|
||||
|
|
@ -191,7 +191,7 @@ namespace SeaHaven.Services.Implementation
|
|||
if (!await _vendorDataService.ExistsAsync(vendorId))
|
||||
throw new InvalidOperationException($"Vendor with ID {vendorId} not found");
|
||||
|
||||
var linked = await _vendorDataService.GetOpenLinkedWorkOrdersAsync(vendorId);
|
||||
var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId);
|
||||
var openWorkOrders = linked
|
||||
.Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status))
|
||||
.Select(MapToLinkedWorkOrderDTO)
|
||||
|
|
@ -210,6 +210,7 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderVendorUpdateDTO dto,
|
||||
string userId)
|
||||
{
|
||||
dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
|
||||
var validationResult = await _workOrderUpdateValidator.ValidateAsync(dto);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
|
|
@ -295,7 +296,11 @@ namespace SeaHaven.Services.Implementation
|
|||
{
|
||||
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[]
|
||||
{
|
||||
|
|
@ -311,7 +316,11 @@ namespace SeaHaven.Services.Implementation
|
|||
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;
|
||||
|
|
@ -365,17 +374,73 @@ namespace SeaHaven.Services.Implementation
|
|||
if (company == null)
|
||||
return;
|
||||
|
||||
vendor.CompanyPhone = company.CompanyPhone;
|
||||
vendor.Address = company.Address;
|
||||
vendor.City = company.City;
|
||||
vendor.State = company.State;
|
||||
vendor.Zip = company.Zip;
|
||||
vendor.GoogleMapsUrl = company.GoogleMapsUrl;
|
||||
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.GetOpenLinkedWorkOrdersAsync(vendorId);
|
||||
var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId);
|
||||
var openWorkOrders = linked
|
||||
.Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status))
|
||||
.Select(MapToLinkedWorkOrderDTO)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue