From 5b4b538c16e6d07523298ffc140591d667467def Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 23 Jul 2026 19:17:14 -0300 Subject: [PATCH] fix(vendors): preserve company and phone integrity --- .../VendorDataServiceTests.cs | 8 +- .../VendorServiceTests.cs | 103 +++++++++++++++++- .../Implementation/VendorDataService.cs | 29 ++++- .../Interfaces/IVendorDataService.cs | 3 +- .../Implementation/VendorService.cs | 97 ++++++++++++++--- 5 files changed, 209 insertions(+), 31 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs index 80057f5..482e4bb 100644 --- a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -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 }); diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index 9c49893..315bd18 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -58,7 +58,7 @@ public class VendorServiceTests var existing = new Vendor { Id = 8, CompanyName = "Old", IsActive = true }; var data = new Mock(); data.Setup(x => x.GetByIdAsync(8)).ReturnsAsync(existing); - data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(8)).ReturnsAsync(new List()); + data.Setup(x => x.GetLinkedWorkOrdersAsync(8)).ReturnsAsync(new List()); 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(); data.Setup(x => x.GetByIdAsync(9)).ReturnsAsync(existing); - data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(9)).ReturnsAsync(new List()); + data.Setup(x => x.GetLinkedWorkOrdersAsync(9)).ReturnsAsync(new List()); 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(); data.Setup(x => x.GetByIdAsync(20)).ReturnsAsync(existing); - data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(20)) + data.Setup(x => x.GetLinkedWorkOrdersAsync(20)) .ReturnsAsync(new List { 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(); + Vendor? saved = null; + data.Setup(x => x.AddAsync(It.IsAny())) + .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(); + 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(); + 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() .WithMessage("*404*not found"); - data.Verify(x => x.GetOpenLinkedWorkOrdersAsync(It.IsAny()), Times.Never); + data.Verify(x => x.GetLinkedWorkOrdersAsync(It.IsAny()), Times.Never); } } diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index e89620b..541c89f 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -318,11 +318,30 @@ namespace SeaHaven.DataServices.Implementation public async Task 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 BuildOpenLinkedWorkOrdersQuery(int vendorId) + public async Task UpdateCompanyAsync(VendorCompany company) + { + _context.VendorCompanies.Update(company); + await _context.SaveChangesAsync(); + } + + internal IQueryable 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> GetOpenLinkedWorkOrdersAsync(int vendorId) + public async Task> GetLinkedWorkOrdersAsync(int vendorId) { - var raw = await BuildOpenLinkedWorkOrdersQuery(vendorId).ToListAsync(); + var raw = await BuildLinkedWorkOrdersQuery(vendorId).ToListAsync(); return raw.DistinctBy(x => x.WorkOrderId).ToList(); } diff --git a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index 6f5a469..b0acd6d 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -27,7 +27,8 @@ namespace SeaHaven.DataServices.Interfaces Task GetCompanyByIdAsync(int id); Task GetCompanyByNormalizedNameAsync(string normalizedName); Task AddCompanyAsync(VendorCompany company); - Task> GetOpenLinkedWorkOrdersAsync(int vendorId); + Task UpdateCompanyAsync(VendorCompany company); + Task> GetLinkedWorkOrdersAsync(int vendorId); Task IsVendorAssignedToWorkOrderAsync(int vendorId, int workOrderId); Task UpdateWithAuditLogsAsync(Vendor vendor, IReadOnlyCollection auditLogs); } diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index 39b20c9..009a9e4 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -80,6 +80,7 @@ namespace SeaHaven.Services.Implementation public async Task 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 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)