diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs index 31c4940..80057f5 100644 --- a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using FluentAssertions; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; @@ -266,4 +267,58 @@ public class VendorDataServiceTests if (filter == "job-bucket") sql.Should().Contain("UNION"); } + + [Fact] + public void BuildOpenLinkedWorkOrdersQuery_TranslatesForSqlServerWithoutClientEvaluation() + { + using var context = NewSqlServerContext(); + var service = new VendorDataService(context); + + var sql = service.BuildOpenLinkedWorkOrdersQuery(60).ToQueryString(); + + sql.Should().Contain("SELECT"); + sql.Should().Contain("Dispatches"); + sql.Should().Contain("DispatchWorkOrders"); + sql.Should().Contain("workOrders"); + } + + [Fact] + public async Task GetOpenLinkedWorkOrdersAsync_DedupsToOneRecordPerWorkOrderAcrossDirectAndMultiLinks() + { + await using var context = NewContext(); + var vendor = new Vendor { Id = 60, CompanyName = "Linked Co", IsActive = true }; + context.Vendors.Add(vendor); + context.workOrders.AddRange( + new WorkOrder + { + Id = 701, + WorkerOrderNumber = "WO-701", + WorkerOrderTitle = "Direct And Multi", + Status = "Scheduled", + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 702, + WorkerOrderNumber = "WO-702", + WorkerOrderTitle = "Multi Only", + Status = "Scheduled", + LifecycleStatus = LifecycleStatus.Scheduled + }); + + var directDispatch = new Dispatch { Id = 7001, VendorId = 60, WorkOrderId = 701, Vendor = vendor }; + var multiDispatch = new Dispatch { Id = 7002, VendorId = 60, WorkOrderId = null, Vendor = vendor }; + context.Dispatches.AddRange(directDispatch, multiDispatch); + context.DispatchWorkOrders.AddRange( + new DispatchWorkOrder { DispatchId = 7002, Dispatch = multiDispatch, WorkOrderId = 702 }, + new DispatchWorkOrder { DispatchId = 7002, Dispatch = multiDispatch, WorkOrderId = 701 }); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + var result = await service.GetOpenLinkedWorkOrdersAsync(60); + + result.Should().HaveCount(2); + result.Select(x => x.WorkOrderId).Should().BeEquivalentTo(new[] { 701, 702 }); + result.Select(x => x.WorkOrderId).Should().OnlyHaveUniqueItems(); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index 1067869..9c49893 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -1,5 +1,7 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using FluentAssertions; +using FluentValidation; using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; @@ -12,7 +14,7 @@ namespace Api.SeaHavenIndustries.Tests; public class VendorServiceTests { private static VendorService NewService(Mock data) => - new(data.Object, new CreateVendorValidation(), new UpdateVendorValidation()); + new(data.Object, new CreateVendorValidation(), new UpdateVendorValidation(), new WorkOrderVendorUpdateValidation()); [Fact] public async Task CreateVendor_PersistsCompleteProfile() @@ -27,8 +29,8 @@ public class VendorServiceTests Name = "Gateway Plumbing", ContactName = "Jordan Lee", Email = "dispatch@gateway.example", - Phone = "555-0100", - CompanyPhone = "555-0199", + Phone = "(555) 555-0100", + CompanyPhone = "(555) 555-0199", Address = "10 Lake St", City = "Chicago", State = "IL", @@ -42,7 +44,7 @@ public class VendorServiceTests saved.Should().NotBeNull(); saved!.ContactName.Should().Be("Jordan Lee"); saved.TradeSpecialties.Should().Be("Plumbing, Backflow"); - saved.CompanyPhone.Should().Be("555-0199"); + saved.CompanyPhone.Should().Be("(555) 555-0199"); saved.GoogleMapsUrl.Should().Be("https://maps.google.com/example"); saved.Notes.Should().Be("After-hours dispatch available"); saved.City.Should().Be("Chicago"); @@ -56,6 +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.UpdateAsync(existing)).Returns(Task.CompletedTask); var result = await NewService(data).UpdateVendorAsync(8, new UpdateVendorDTO @@ -80,6 +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.UpdateAsync(existing)).Returns(Task.CompletedTask); await NewService(data).DeleteVendorAsync(9, "42"); @@ -210,4 +214,175 @@ public class VendorServiceTests result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName)); result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl)); } + + [Fact] + public async Task UpdateVendor_DeactivatingWithOpenWorkOrders_ThrowsAndLeavesActive() + { + 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)) + .ReturnsAsync(new List + { + new() + { + WorkOrderId = 500, + WorkOrderNumber = "WO-500", + Status = "Scheduled", + LifecycleStatus = LifecycleStatus.Scheduled + } + }); + + var act = () => NewService(data).UpdateVendorAsync(20, new UpdateVendorDTO { IsActive = false }, "42"); + + await act.Should().ThrowAsync(); + existing.IsActive.Should().BeTrue(); + data.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdateVendor_WithUnknownCompanyId_ThrowsAndDoesNotFallBackToName() + { + var existing = new Vendor { Id = 30, CompanyName = "Existing", IsActive = true }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(30)).ReturnsAsync(existing); + data.Setup(x => x.GetCompanyByIdAsync(999)).ReturnsAsync((VendorCompany?)null); + + var act = () => NewService(data).UpdateVendorAsync(30, new UpdateVendorDTO + { + CompanyId = 999, + Name = "Fallback Try" + }, "42"); + + await act.Should().ThrowAsync(); + data.Verify(x => x.GetCompanyByNormalizedNameAsync(It.IsAny()), Times.Never); + data.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task CreateVendor_PropagatesExistingCompanyIdWithoutCreatingCompany() + { + var data = new Mock(); + var company = new VendorCompany { Id = 77, Name = "Acme", NormalizedName = "acme" }; + data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(company); + Vendor? saved = null; + data.Setup(x => x.AddAsync(It.IsAny())) + .ReturnsAsync((Vendor v) => { saved = v; v.Id = 40; return v; }); + + var result = await NewService(data).CreateVendorAsync(new CreateVendorDTO + { + Name = "Acme", + CompanyId = 77, + ContactName = "Riley" + }, "42"); + + saved.Should().NotBeNull(); + saved!.CompanyId.Should().Be(77); + result.CompanyId.Should().Be(77); + data.Verify(x => x.AddCompanyAsync(It.IsAny()), Times.Never); + } + + [Fact] + public void CreateVendor_RejectsNonCanonicalPhoneAndInvalidPreferredContact() + { + var validator = new CreateVendorValidation(); + + var result = validator.Validate(new CreateVendorDTO + { + Name = "Acme", + ContactName = "Riley", + Phone = "555-1234", + PreferredContact = "Fax" + }); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorDTO.Phone)); + result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorDTO.PreferredContact)); + } + + [Fact] + public void WorkOrderVendorUpdate_AcceptsCanonicalPhoneAndValidPreferredContact() + { + var validator = new WorkOrderVendorUpdateValidation(); + + var result = validator.Validate(new WorkOrderVendorUpdateDTO + { + WorkOrderId = 5, + Phone = "(312) 555-0100", + PreferredContact = "Text" + }); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public async Task UpdateVendorFromWorkOrder_RejectsUnassignedWorkOrder() + { + var existing = new Vendor { Id = 50, ContactName = "Original", IsActive = true }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(50)).ReturnsAsync(existing); + data.Setup(x => x.IsVendorAssignedToWorkOrderAsync(50, 777)).ReturnsAsync(false); + + var act = () => NewService(data).UpdateVendorFromWorkOrderAsync(50, new WorkOrderVendorUpdateDTO + { + WorkOrderId = 777, + ContactName = "Should Not Apply" + }, "42"); + + await act.Should().ThrowAsync(); + existing.ContactName.Should().Be("Original"); + data.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdateVendorFromWorkOrder_RecordsAuditOldAndNewValues() + { + var existing = new Vendor + { + Id = 50, + CompanyName = "Assigned", + ContactName = "Old Name", + Phone = "(312) 555-0000", + PreferredContact = "Phone", + IsActive = true + }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(50)).ReturnsAsync(existing); + data.Setup(x => x.IsVendorAssignedToWorkOrderAsync(50, 777)).ReturnsAsync(true); + var auditLogs = new List(); + data.Setup(x => x.UpdateWithAuditLogsAsync( + existing, + It.IsAny>())) + .Callback>((_, logs) => auditLogs.AddRange(logs)) + .Returns(Task.CompletedTask); + + await NewService(data).UpdateVendorFromWorkOrderAsync(50, new WorkOrderVendorUpdateDTO + { + WorkOrderId = 777, + ContactName = "New Name", + Phone = "(312) 555-0199", + PreferredContact = "Email" + }, "42"); + + existing.ContactName.Should().Be("New Name"); + existing.Phone.Should().Be("(312) 555-0199"); + existing.PreferredContact.Should().Be("Email"); + auditLogs.Should().NotBeEmpty(); + auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.ContactName) && a.OldValue == "Old Name" && a.NewValue == "New Name"); + auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.Phone) && a.OldValue == "(312) 555-0000" && a.NewValue == "(312) 555-0199"); + auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.PreferredContact) && a.OldValue == "Phone" && a.NewValue == "Email"); + } + + [Fact] + public async Task GetDeactivationImpact_RejectsUnknownVendor() + { + var data = new Mock(); + data.Setup(x => x.ExistsAsync(404)).ReturnsAsync(false); + + var act = () => NewService(data).GetDeactivationImpactAsync(404); + + await act.Should().ThrowAsync() + .WithMessage("*404*not found"); + data.Verify(x => x.GetOpenLinkedWorkOrdersAsync(It.IsAny()), Times.Never); + } } diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index ecc4ccd..00763f0 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -56,10 +56,12 @@ namespace Api.SeaHavenIndustries.Controllers { v.Id, CompanyName = v.Name, + v.CompanyId, v.ContactName, v.Email, v.Phone, v.CompanyPhone, + v.PreferredContact, v.Address, v.City, v.State, @@ -98,10 +100,12 @@ namespace Api.SeaHavenIndustries.Controllers { vendor.Id, CompanyName = vendor.Name, + vendor.CompanyId, vendor.ContactName, vendor.Email, vendor.Phone, vendor.CompanyPhone, + vendor.PreferredContact, vendor.Address, vendor.City, vendor.State, @@ -123,10 +127,12 @@ namespace Api.SeaHavenIndustries.Controllers var createDto = new CreateVendorDTO { Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required"), + CompanyId = model.CompanyId, ContactName = model.ContactName, Email = model.Email, Phone = model.Phone, CompanyPhone = model.CompanyPhone, + PreferredContact = model.PreferredContact, Address = model.Address, City = model.City, State = model.State, @@ -169,10 +175,12 @@ namespace Api.SeaHavenIndustries.Controllers var updateDto = new UpdateVendorDTO { Name = model.CompanyName, + CompanyId = model.CompanyId, ContactName = model.ContactName, Email = model.Email, Phone = model.Phone, CompanyPhone = model.CompanyPhone, + PreferredContact = model.PreferredContact, Address = model.Address, City = model.City, State = model.State, @@ -195,6 +203,15 @@ namespace Api.SeaHavenIndustries.Controllers var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); return BadRequest(new Response { Status = "Validation Error", Message = errors }); } + catch (VendorDeactivationBlockedException dbex) + { + return Conflict(new + { + Status = "Conflict", + Message = dbex.Message, + OpenWorkOrders = dbex.OpenWorkOrders + }); + } catch (InvalidOperationException) { return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); @@ -223,6 +240,15 @@ namespace Api.SeaHavenIndustries.Controllers await _vendorService.DeleteVendorAsync(vendorId, userId); return Ok(new DataResponse { Message = "Vendor Deactivated", Status = "200" }); } + catch (VendorDeactivationBlockedException dbex) + { + return Conflict(new + { + Status = "Conflict", + Message = dbex.Message, + OpenWorkOrders = dbex.OpenWorkOrders + }); + } catch (InvalidOperationException) { return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); @@ -233,6 +259,64 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpGet("{id:int}/deactivation-impact")] + public async Task GetDeactivationImpact(int id) + { + try + { + var impact = await _vendorService.GetDeactivationImpactAsync(id); + return Ok(impact); + } + catch (InvalidOperationException) + { + return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + } + } + + [HttpPut("{id:int}/work-order-update")] + public async Task UpdateFromWorkOrder(int id, [FromBody] WorkOrderVendorUpdate_DTO model) + { + try + { + if (model == null || model.WorkOrderId <= 0) + return BadRequest(new Response { Status = "Error", Message = "A valid workOrderId is required" }); + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); + + var dto = new WorkOrderVendorUpdateDTO + { + WorkOrderId = model.WorkOrderId, + ContactName = model.ContactName, + PreferredContact = model.PreferredContact, + Phone = model.Phone, + Email = model.Email, + Notes = model.Notes + }; + + var result = await _vendorService.UpdateVendorFromWorkOrderAsync(id, dto, userId); + return Ok(result); + } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } + catch (InvalidOperationException) + { + return NotFound(new Response { Status = "Error", Message = "Vendor or work order link not found" }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + } + } + [HttpGet("Dropdown")] public async Task GetDropdown([FromQuery] string? trade = null, [FromQuery] string? siteZip = null) { @@ -243,6 +327,8 @@ namespace Api.SeaHavenIndustries.Controllers { v.Id, v.CompanyName, + v.ContactName, + v.PreferredContact, v.TradeSpecialties, v.Address, v.City, @@ -260,6 +346,8 @@ namespace Api.SeaHavenIndustries.Controllers { v.Id, v.CompanyName, + v.ContactName, + v.PreferredContact, v.TradeSpecialties, address = string.Join(", ", addr), distanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null @@ -303,8 +391,10 @@ namespace Api.SeaHavenIndustries.Controllers .Select(v => new { Name = v.CompanyName, + v.CompanyId, v.CompanyPhone, v.Email, + v.PreferredContact, v.Address, v.City, v.State, diff --git a/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs b/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs index d2a4717..6249bb8 100644 --- a/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs @@ -3,10 +3,12 @@ namespace Api.SeaHavenIndustries.DTOs public class Vendor_DTO { public string? CompanyName { get; set; } + public int? CompanyId { get; set; } public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } public string? CompanyPhone { get; set; } + public string? PreferredContact { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } @@ -21,4 +23,14 @@ namespace Api.SeaHavenIndustries.DTOs { public int Id { get; set; } } + + public class WorkOrderVendorUpdate_DTO + { + public int WorkOrderId { get; set; } + public string? ContactName { get; set; } + public string? PreferredContact { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? Notes { get; set; } + } } diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index a34d5d4..c7c85c6 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -51,6 +51,16 @@ namespace Data.SeaHavenIndustries .WithMany() .HasForeignKey(w => w.PrimaryDispatchId) .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasIndex(c => c.NormalizedName) + .IsUnique(); + + builder.Entity() + .HasOne(v => v.Company) + .WithMany(c => c.Vendors) + .HasForeignKey(v => v.CompanyId) + .OnDelete(DeleteBehavior.Restrict); } public DbSet Categories { get; set; } public DbSet Locations { get; set; } @@ -68,6 +78,8 @@ namespace Data.SeaHavenIndustries public DbSet WorkOrderFieldLocks { get; set; } public DbSet DropdownOptions { get; set; } public DbSet Vendors { get; set; } + public DbSet VendorCompanies { get; set; } + public DbSet VendorAuditLogs { get; set; } public DbSet VendorAccessTokens { get; set; } public DbSet Dispatches { get; set; } public DbSet DispatchWorkOrders { get; set; } diff --git a/Data.SeaHavenIndustries/Migrations/20260723194013_AddVendorCompanyAndAudit.cs b/Data.SeaHavenIndustries/Migrations/20260723194013_AddVendorCompanyAndAudit.cs new file mode 100644 index 0000000..bda0f92 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260723194013_AddVendorCompanyAndAudit.cs @@ -0,0 +1,185 @@ +using System; +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260723194013_AddVendorCompanyAndAudit")] + public partial class AddVendorCompanyAndAudit : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CompanyId", + table: "Vendors", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "PreferredContact", + table: "Vendors", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.CreateTable( + name: "VendorCompanies", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(max)", nullable: false), + NormalizedName = table.Column(type: "nvarchar(450)", nullable: false), + CompanyPhone = table.Column(type: "nvarchar(max)", nullable: true), + Email = table.Column(type: "nvarchar(max)", nullable: true), + Address = table.Column(type: "nvarchar(max)", nullable: true), + City = table.Column(type: "nvarchar(max)", nullable: true), + State = table.Column(type: "nvarchar(max)", nullable: true), + Zip = table.Column(type: "nvarchar(max)", nullable: true), + GoogleMapsUrl = table.Column(type: "nvarchar(max)", nullable: true), + IsDeleted = table.Column(type: "bit", nullable: true), + createdby = table.Column(type: "nvarchar(max)", nullable: true), + DeleterUserId = table.Column(type: "nvarchar(max)", nullable: true), + DeletionTime = table.Column(type: "datetime2", nullable: true), + CreatedDate = table.Column(type: "datetime2", nullable: true), + LastModificationTime = table.Column(type: "datetime2", nullable: true), + LastModifierUserId = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_VendorCompanies", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "VendorAuditLogs", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + VendorId = table.Column(type: "int", nullable: false), + WorkOrderId = table.Column(type: "int", nullable: true), + FieldName = table.Column(type: "nvarchar(max)", nullable: true), + OldValue = table.Column(type: "nvarchar(max)", nullable: true), + NewValue = table.Column(type: "nvarchar(max)", nullable: true), + Actor = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_VendorAuditLogs", x => x.Id); + table.ForeignKey( + name: "FK_VendorAuditLogs_Vendors_VendorId", + column: x => x.VendorId, + principalTable: "Vendors", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + // Backfill: create one VendorCompany per distinct (case-insensitive, trimmed) legacy name. + // Keep all company-owned fields from the same deterministic source vendor row. + migrationBuilder.Sql(@" +WITH RankedVendors AS ( + SELECT + v.[CompanyName] AS [Name], + LOWER(LTRIM(RTRIM(v.[CompanyName]))) AS [NormalizedName], + v.[CompanyPhone], + v.[Email], + v.[Address], + v.[City], + v.[State], + v.[Zip], + v.[GoogleMapsUrl], + ROW_NUMBER() OVER ( + PARTITION BY LOWER(LTRIM(RTRIM(v.[CompanyName]))) + ORDER BY v.[Id] + ) AS [RowNumber] + FROM [Vendors] v + WHERE v.[CompanyName] IS NOT NULL AND LTRIM(RTRIM(v.[CompanyName])) <> '' +) +INSERT INTO [VendorCompanies] ( + [Name], [NormalizedName], [CompanyPhone], [Email], [Address], [City], [State], [Zip], [GoogleMapsUrl], [CreatedDate] +) +SELECT + ranked.[Name], + ranked.[NormalizedName], + ranked.[CompanyPhone], + ranked.[Email], + ranked.[Address], + ranked.[City], + ranked.[State], + ranked.[Zip], + ranked.[GoogleMapsUrl], + GETUTCDATE() AS [CreatedDate] +FROM RankedVendors ranked +WHERE ranked.[RowNumber] = 1; + +UPDATE v +SET v.[CompanyId] = vc.[Id] +FROM [Vendors] v +INNER JOIN [VendorCompanies] vc + ON vc.[NormalizedName] = LOWER(LTRIM(RTRIM(v.[CompanyName]))) +WHERE v.[CompanyName] IS NOT NULL AND LTRIM(RTRIM(v.[CompanyName])) <> ''; +"); + + migrationBuilder.CreateIndex( + name: "IX_Vendors_CompanyId", + table: "Vendors", + column: "CompanyId"); + + migrationBuilder.CreateIndex( + name: "IX_VendorAuditLogs_VendorId", + table: "VendorAuditLogs", + column: "VendorId"); + + migrationBuilder.CreateIndex( + name: "IX_VendorCompanies_NormalizedName", + table: "VendorCompanies", + column: "NormalizedName", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Vendors_VendorCompanies_CompanyId", + table: "Vendors", + column: "CompanyId", + principalTable: "VendorCompanies", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Vendors_VendorCompanies_CompanyId", + table: "Vendors"); + + migrationBuilder.DropIndex( + name: "IX_Vendors_CompanyId", + table: "Vendors"); + + migrationBuilder.DropIndex( + name: "IX_VendorAuditLogs_VendorId", + table: "VendorAuditLogs"); + + migrationBuilder.DropIndex( + name: "IX_VendorCompanies_NormalizedName", + table: "VendorCompanies"); + + migrationBuilder.DropTable( + name: "VendorAuditLogs"); + + migrationBuilder.DropTable( + name: "VendorCompanies"); + + migrationBuilder.DropColumn( + name: "CompanyId", + table: "Vendors"); + + migrationBuilder.DropColumn( + name: "PreferredContact", + table: "Vendors"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 190a690..09b4ef3 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1730,6 +1730,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("City") .HasColumnType("nvarchar(max)"); + b.Property("CompanyId") + .HasColumnType("int"); + b.Property("CompanyName") .HasColumnType("nvarchar(max)"); @@ -1772,6 +1775,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Phone") .HasColumnType("nvarchar(max)"); + b.Property("PreferredContact") + .HasColumnType("nvarchar(max)"); + b.Property("State") .HasColumnType("nvarchar(max)"); @@ -1786,6 +1792,8 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); + b.HasIndex("CompanyId"); + b.ToTable("Vendors"); }); @@ -1848,6 +1856,108 @@ namespace Data.SeaHavenIndustries.Migrations b.ToTable("VendorAccessTokens"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Actor") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("VendorCompanies"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => { b.Property("Id") @@ -1978,13 +2088,13 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("ScheduledStart") .HasColumnType("datetime2"); - b.Property("Severity") - .HasColumnType("nvarchar(max)"); - b.Property("Service") .HasMaxLength(128) .HasColumnType("nvarchar(128)"); + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + b.Property("SignOffAttachment") .HasColumnType("nvarchar(max)"); @@ -2728,6 +2838,16 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("POC"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company") + .WithMany("Vendors") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Company"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => { b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") @@ -2739,6 +2859,17 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("Vendor"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("AuditLogs") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => { b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") @@ -2972,9 +3103,16 @@ namespace Data.SeaHavenIndustries.Migrations modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => { + b.Navigation("AuditLogs"); + b.Navigation("Dispatches"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Navigation("Vendors"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => { b.Navigation("Comments"); diff --git a/Data.SeaHavenIndustries/Models/LinkedWorkOrderInfo.cs b/Data.SeaHavenIndustries/Models/LinkedWorkOrderInfo.cs new file mode 100644 index 0000000..db76d4b --- /dev/null +++ b/Data.SeaHavenIndustries/Models/LinkedWorkOrderInfo.cs @@ -0,0 +1,15 @@ +using Data.SeaHavenIndustries.Enums; + +namespace Data.SeaHavenIndustries +{ + public class LinkedWorkOrderInfo + { + public int WorkOrderId { get; set; } + public string? WorkOrderNumber { get; set; } + public string? WorkOrderTitle { get; set; } + public string? Status { get; set; } + public LifecycleStatus? LifecycleStatus { get; set; } + public DateTime? ScheduledDate { get; set; } + public int? DispatchId { get; set; } + } +} diff --git a/Data.SeaHavenIndustries/Models/Vendor.cs b/Data.SeaHavenIndustries/Models/Vendor.cs index d1d48b7..9f8071a 100644 --- a/Data.SeaHavenIndustries/Models/Vendor.cs +++ b/Data.SeaHavenIndustries/Models/Vendor.cs @@ -17,8 +17,15 @@ namespace Data.SeaHavenIndustries public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } public bool IsActive { get; set; } = true; + public string? PreferredContact { get; set; } + + public int? CompanyId { get; set; } + [ForeignKey(nameof(CompanyId))] + public virtual VendorCompany? Company { get; set; } + [NotMapped] public int TotalJobs { get; set; } public ICollection? Dispatches { get; set; } + public ICollection? AuditLogs { get; set; } } } diff --git a/Data.SeaHavenIndustries/Models/VendorAuditLog.cs b/Data.SeaHavenIndustries/Models/VendorAuditLog.cs new file mode 100644 index 0000000..278b165 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/VendorAuditLog.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + public class VendorAuditLog + { + public int Id { get; set; } + public int VendorId { get; set; } + [ForeignKey(nameof(VendorId))] + public virtual Vendor? Vendor { get; set; } + public int? WorkOrderId { get; set; } + public string? FieldName { get; set; } + public string? OldValue { get; set; } + public string? NewValue { get; set; } + public string? Actor { get; set; } + public DateTime CreatedAt { get; set; } + } +} diff --git a/Data.SeaHavenIndustries/Models/VendorCompany.cs b/Data.SeaHavenIndustries/Models/VendorCompany.cs new file mode 100644 index 0000000..5b2e062 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/VendorCompany.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + public class VendorCompany : FullAuditEntity + { + public string Name { get; set; } = string.Empty; + public string NormalizedName { get; set; } = string.Empty; + public string? CompanyPhone { get; set; } + public string? Email { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + public string? GoogleMapsUrl { get; set; } + + public ICollection? Vendors { get; set; } + } +} diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index 273dec0..e89620b 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -141,10 +141,12 @@ namespace SeaHaven.DataServices.Implementation { Id = v.Id, CompanyName = v.CompanyName, + CompanyId = v.CompanyId, ContactName = v.ContactName, Email = v.Email, Phone = v.Phone, CompanyPhone = v.CompanyPhone, + PreferredContact = v.PreferredContact, Address = v.Address, City = v.City, State = v.State, @@ -298,5 +300,83 @@ namespace SeaHaven.DataServices.Implementation { return await _context.Vendors.CountAsync(); } + + public async Task GetCompanyByIdAsync(int id) + { + return await _context.VendorCompanies + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == id); + } + + public async Task GetCompanyByNormalizedNameAsync(string normalizedName) + { + return await _context.VendorCompanies + .AsNoTracking() + .FirstOrDefaultAsync(c => c.NormalizedName == normalizedName); + } + + public async Task AddCompanyAsync(VendorCompany company) + { + await _context.VendorCompanies.AddAsync(company); + await _context.SaveChangesAsync(); + return company; + } + + internal IQueryable BuildOpenLinkedWorkOrdersQuery(int vendorId) + { + var directLinks = _context.Dispatches + .Where(d => d.VendorId == vendorId && d.WorkOrderId.HasValue) + .Select(d => new { DispatchId = d.Id, WorkOrderId = d.WorkOrderId!.Value }); + + var multiLinks = _context.DispatchWorkOrders + .Where(link => link.Dispatch != null && link.Dispatch.VendorId == vendorId) + .Select(link => new { DispatchId = link.DispatchId, WorkOrderId = link.WorkOrderId }); + + var linkedIds = directLinks.Concat(multiLinks); + + return from link in linkedIds + join wo in _context.workOrders on link.WorkOrderId equals wo.Id + orderby wo.Id, link.DispatchId + select new LinkedWorkOrderInfo + { + WorkOrderId = wo.Id, + WorkOrderNumber = wo.WorkerOrderNumber, + WorkOrderTitle = wo.WorkerOrderTitle, + Status = wo.Status, + LifecycleStatus = wo.LifecycleStatus, + ScheduledDate = wo.ScheduledDate, + DispatchId = link.DispatchId + }; + } + + public async Task> GetOpenLinkedWorkOrdersAsync(int vendorId) + { + var raw = await BuildOpenLinkedWorkOrdersQuery(vendorId).ToListAsync(); + return raw.DistinctBy(x => x.WorkOrderId).ToList(); + } + + public async Task IsVendorAssignedToWorkOrderAsync(int vendorId, int workOrderId) + { + var hasDirect = await _context.Dispatches + .AnyAsync(d => d.VendorId == vendorId && d.WorkOrderId == workOrderId); + + if (hasDirect) + return true; + + return await _context.DispatchWorkOrders + .AnyAsync(link => link.WorkOrderId == workOrderId + && link.Dispatch != null + && link.Dispatch.VendorId == vendorId); + } + + public async Task UpdateWithAuditLogsAsync( + Vendor vendor, + IReadOnlyCollection auditLogs) + { + _context.Vendors.Update(vendor); + if (auditLogs.Count > 0) + await _context.VendorAuditLogs.AddRangeAsync(auditLogs); + await _context.SaveChangesAsync(); + } } } diff --git a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index 2dbd0c1..6f5a469 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -23,5 +23,12 @@ namespace SeaHaven.DataServices.Interfaces Task GetActiveTokenAsync(int vendorId); Task GetTokenByStringAsync(string token); Task CountAsync(); + + Task GetCompanyByIdAsync(int id); + Task GetCompanyByNormalizedNameAsync(string normalizedName); + Task AddCompanyAsync(VendorCompany company); + Task> GetOpenLinkedWorkOrdersAsync(int vendorId); + Task IsVendorAssignedToWorkOrderAsync(int vendorId, int workOrderId); + Task UpdateWithAuditLogsAsync(Vendor vendor, IReadOnlyCollection auditLogs); } } diff --git a/SeaHaven.Services/DTOs/VendorDTOs.cs b/SeaHaven.Services/DTOs/VendorDTOs.cs index 6c1cd82..780b724 100644 --- a/SeaHaven.Services/DTOs/VendorDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorDTOs.cs @@ -4,10 +4,12 @@ namespace SeaHaven.Services.DTOs { public int Id { get; set; } public string? Name { get; set; } + public int? CompanyId { get; set; } public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } public string? CompanyPhone { get; set; } + public string? PreferredContact { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } @@ -22,10 +24,12 @@ namespace SeaHaven.Services.DTOs public class CreateVendorDTO { public required string Name { get; set; } + public int? CompanyId { get; set; } public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } public string? CompanyPhone { get; set; } + public string? PreferredContact { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } @@ -39,10 +43,12 @@ namespace SeaHaven.Services.DTOs public class UpdateVendorDTO { public string? Name { get; set; } + public int? CompanyId { get; set; } public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } public string? CompanyPhone { get; set; } + public string? PreferredContact { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } @@ -52,4 +58,42 @@ namespace SeaHaven.Services.DTOs public string? Notes { get; set; } public bool? IsActive { get; set; } } + + public class VendorDeactivationImpactDTO + { + public int VendorId { get; set; } + public bool CanDeactivate { get; set; } + public List OpenWorkOrders { get; set; } = new(); + } + + public class LinkedWorkOrderDTO + { + public int WorkOrderId { get; set; } + public string? WorkOrderNumber { get; set; } + public string? WorkOrderTitle { get; set; } + public string? Status { get; set; } + public DateTime? ScheduledDate { get; set; } + public int? DispatchId { get; set; } + } + + public class WorkOrderVendorUpdateDTO + { + public int WorkOrderId { get; set; } + public string? ContactName { get; set; } + public string? PreferredContact { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? Notes { get; set; } + } + + public class VendorDeactivationBlockedException : InvalidOperationException + { + public List OpenWorkOrders { get; } + + public VendorDeactivationBlockedException(string message, List openWorkOrders) + : base(message) + { + OpenWorkOrders = openWorkOrders; + } + } } diff --git a/SeaHaven.Services/Helpers/VendorPhoneNormalizer.cs b/SeaHaven.Services/Helpers/VendorPhoneNormalizer.cs new file mode 100644 index 0000000..5254184 --- /dev/null +++ b/SeaHaven.Services/Helpers/VendorPhoneNormalizer.cs @@ -0,0 +1,25 @@ +using System.Text.RegularExpressions; + +namespace SeaHaven.Services.Helpers +{ + public static class VendorPhoneNormalizer + { + public static readonly Regex NorthAmericanPhoneRegex = new( + @"^\(\d{3}\) \d{3}-\d{4}$", + RegexOptions.Compiled); + + public static string? NormalizeToCanonical(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + var digits = new string(value.Where(char.IsDigit).ToArray()); + if (digits.Length == 11 && digits.StartsWith('1')) + digits = digits[1..]; + + return digits.Length == 10 + ? $"({digits[..3]}) {digits[3..6]}-{digits[6..]}" + : value; + } + } +} diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index 6ba8e71..39b20c9 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -1,30 +1,35 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using FluentValidation; +using FluentValidation.Results; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; using SeaHaven.Services.Validation; namespace SeaHaven.Services.Implementation { - /// - /// Vendor service implementation - /// Contains business logic, validation, and orchestration for Vendor operations - /// public class VendorService : IVendorService { + private static readonly string[] TerminalLegacyStatuses = + { "completed", "cancelled", "canceled" }; + private readonly IVendorDataService _vendorDataService; private readonly ICreateVendorValidation _createValidator; private readonly IUpdateVendorValidation _updateValidator; + private readonly IWorkOrderVendorUpdateValidation _workOrderUpdateValidator; public VendorService( IVendorDataService vendorDataService, ICreateVendorValidation createValidator, - IUpdateVendorValidation updateValidator) + IUpdateVendorValidation updateValidator, + IWorkOrderVendorUpdateValidation workOrderUpdateValidator) { _vendorDataService = vendorDataService; _createValidator = createValidator; _updateValidator = updateValidator; + _workOrderUpdateValidator = workOrderUpdateValidator; } public async Task GetVendorByIdAsync(int id) @@ -81,13 +86,16 @@ namespace SeaHaven.Services.Implementation throw new ValidationException(validationResult.Errors); } + var company = await ResolveCompanyAsync(dto.CompanyId, dto.Name, dto, userId); + var vendor = new Vendor { CompanyName = dto.Name, ContactName = dto.ContactName, Email = dto.Email, - Phone = dto.Phone, - CompanyPhone = dto.CompanyPhone, + Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone), + CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone), + PreferredContact = dto.PreferredContact, Address = dto.Address, City = dto.City, State = dto.State, @@ -96,10 +104,13 @@ namespace SeaHaven.Services.Implementation GoogleMapsUrl = dto.GoogleMapsUrl, Notes = dto.Notes, IsActive = dto.IsActive, + CompanyId = company?.Id, CreatedDate = DateTime.UtcNow, createdby = userId }; + ApplyCompanyFields(vendor, company); + var created = await _vendorDataService.AddAsync(vendor); return MapToDTO(created); } @@ -116,11 +127,15 @@ namespace SeaHaven.Services.Implementation if (vendor == null) throw new InvalidOperationException($"Vendor with ID {id} not found"); + if (dto.IsActive.HasValue && !dto.IsActive.Value) + await AssertNoOpenLinkedWorkOrdersAsync(id); + if (dto.Name != null) vendor.CompanyName = dto.Name; if (dto.ContactName != null) vendor.ContactName = dto.ContactName; if (dto.Email != null) vendor.Email = dto.Email; - if (dto.Phone != null) vendor.Phone = dto.Phone; - if (dto.CompanyPhone != null) vendor.CompanyPhone = dto.CompanyPhone; + 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; @@ -130,6 +145,13 @@ namespace SeaHaven.Services.Implementation if (dto.Notes != null) vendor.Notes = dto.Notes; if (dto.IsActive.HasValue) vendor.IsActive = dto.IsActive.Value; + if (dto.CompanyId.HasValue || dto.Name != null) + { + var company = await ResolveCompanyAsync(dto.CompanyId, dto.Name, dto, userId); + vendor.CompanyId = company?.Id; + ApplyCompanyFields(vendor, company); + } + vendor.LastModificationTime = DateTime.UtcNow; if (int.TryParse(userId, out int userIdInt)) vendor.LastModifierUserId = userIdInt; @@ -144,6 +166,8 @@ namespace SeaHaven.Services.Implementation if (vendor == null) throw new InvalidOperationException($"Vendor with ID {id} not found"); + await AssertNoOpenLinkedWorkOrdersAsync(id); + vendor.IsActive = false; vendor.LastModificationTime = DateTime.UtcNow; if (int.TryParse(userId, out int userIdInt)) @@ -162,16 +186,243 @@ namespace SeaHaven.Services.Implementation return await _vendorDataService.CountAsync(); } + public async Task GetDeactivationImpactAsync(int vendorId) + { + if (!await _vendorDataService.ExistsAsync(vendorId)) + throw new InvalidOperationException($"Vendor with ID {vendorId} not found"); + + var linked = await _vendorDataService.GetOpenLinkedWorkOrdersAsync(vendorId); + var openWorkOrders = linked + .Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status)) + .Select(MapToLinkedWorkOrderDTO) + .ToList(); + + return new VendorDeactivationImpactDTO + { + VendorId = vendorId, + CanDeactivate = openWorkOrders.Count == 0, + OpenWorkOrders = openWorkOrders + }; + } + + public async Task UpdateVendorFromWorkOrderAsync( + int vendorId, + WorkOrderVendorUpdateDTO dto, + string userId) + { + var validationResult = await _workOrderUpdateValidator.ValidateAsync(dto); + if (!validationResult.IsValid) + { + throw new ValidationException(validationResult.Errors); + } + + var vendor = await _vendorDataService.GetByIdAsync(vendorId); + if (vendor == null) + throw new InvalidOperationException($"Vendor with ID {vendorId} not found"); + + var isAssigned = await _vendorDataService.IsVendorAssignedToWorkOrderAsync(vendorId, dto.WorkOrderId); + if (!isAssigned) + throw new InvalidOperationException( + $"Vendor {vendorId} is not actively linked to work order {dto.WorkOrderId}."); + + var changes = new List(); + var now = DateTime.UtcNow; + + void RecordChange(string fieldName, string? oldValue, string? newValue) + { + if (!string.Equals(oldValue, newValue, StringComparison.Ordinal)) + { + changes.Add(new VendorAuditLog + { + VendorId = vendorId, + WorkOrderId = dto.WorkOrderId, + FieldName = fieldName, + OldValue = oldValue, + NewValue = newValue, + Actor = userId, + CreatedAt = now + }); + } + } + + if (dto.ContactName != null) + { + RecordChange(nameof(Vendor.ContactName), vendor.ContactName, dto.ContactName); + vendor.ContactName = dto.ContactName; + } + + if (dto.PreferredContact != null) + { + RecordChange(nameof(Vendor.PreferredContact), vendor.PreferredContact, dto.PreferredContact); + vendor.PreferredContact = dto.PreferredContact; + } + + if (dto.Phone != null) + { + var normalized = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone); + RecordChange(nameof(Vendor.Phone), vendor.Phone, normalized); + vendor.Phone = normalized; + } + + if (dto.Email != null) + { + RecordChange(nameof(Vendor.Email), vendor.Email, dto.Email); + vendor.Email = dto.Email; + } + + if (dto.Notes != null) + { + RecordChange(nameof(Vendor.Notes), vendor.Notes, dto.Notes); + vendor.Notes = dto.Notes; + } + + vendor.LastModificationTime = now; + if (int.TryParse(userId, out int userIdInt)) + vendor.LastModifierUserId = userIdInt; + + await _vendorDataService.UpdateWithAuditLogsAsync(vendor, changes); + + return MapToDTO(vendor); + } + + private async Task ResolveCompanyAsync( + int? requestedCompanyId, + string? companyName, + object dto, + string userId) + { + if (requestedCompanyId.HasValue) + { + var byId = await _vendorDataService.GetCompanyByIdAsync(requestedCompanyId.Value); + if (byId != null) + return byId; + + throw new ValidationException(new[] + { + new ValidationFailure( + nameof(CreateVendorDTO.CompanyId), + $"No vendor company exists with ID {requestedCompanyId.Value}.") + }); + } + + if (string.IsNullOrWhiteSpace(companyName)) + return null; + + var normalizedName = companyName.Trim().ToLowerInvariant(); + var existing = await _vendorDataService.GetCompanyByNormalizedNameAsync(normalizedName); + if (existing != null) + return existing; + + string? companyPhone = null; + string? email = null; + string? address = null; + string? city = null; + string? state = null; + string? zip = null; + string? mapsUrl = null; + + if (dto is CreateVendorDTO create) + { + companyPhone = VendorPhoneNormalizer.NormalizeToCanonical(create.CompanyPhone); + email = create.Email; + address = create.Address; + city = create.City; + state = create.State; + zip = create.Zipcode; + mapsUrl = create.GoogleMapsUrl; + } + else if (dto is UpdateVendorDTO update) + { + companyPhone = VendorPhoneNormalizer.NormalizeToCanonical(update.CompanyPhone); + email = update.Email; + address = update.Address; + city = update.City; + state = update.State; + zip = update.Zipcode; + mapsUrl = update.GoogleMapsUrl; + } + + var company = new VendorCompany + { + Name = companyName.Trim(), + NormalizedName = normalizedName, + CompanyPhone = companyPhone, + Email = email, + Address = address, + City = city, + State = state, + Zip = zip, + GoogleMapsUrl = mapsUrl, + CreatedDate = DateTime.UtcNow, + createdby = userId + }; + + return await _vendorDataService.AddCompanyAsync(company); + } + + private static void ApplyCompanyFields(Vendor vendor, VendorCompany? company) + { + 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; + } + + private async Task AssertNoOpenLinkedWorkOrdersAsync(int vendorId) + { + var linked = await _vendorDataService.GetOpenLinkedWorkOrdersAsync(vendorId); + var openWorkOrders = linked + .Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status)) + .Select(MapToLinkedWorkOrderDTO) + .ToList(); + + if (openWorkOrders.Count > 0) + { + throw new VendorDeactivationBlockedException( + "Vendor cannot be deactivated while linked work orders are open or scheduled.", + openWorkOrders); + } + } + + 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; + } + + private static LinkedWorkOrderDTO MapToLinkedWorkOrderDTO(LinkedWorkOrderInfo info) => new() + { + WorkOrderId = info.WorkOrderId, + WorkOrderNumber = info.WorkOrderNumber, + WorkOrderTitle = info.WorkOrderTitle, + Status = info.Status, + ScheduledDate = info.ScheduledDate, + DispatchId = info.DispatchId + }; + private VendorDTO MapToDTO(Vendor vendor) { return new VendorDTO { Id = vendor.Id, Name = vendor.CompanyName, + CompanyId = vendor.CompanyId, ContactName = vendor.ContactName, Email = vendor.Email, Phone = vendor.Phone, CompanyPhone = vendor.CompanyPhone, + PreferredContact = vendor.PreferredContact, Address = vendor.Address, City = vendor.City, State = vendor.State, diff --git a/SeaHaven.Services/Interfaces/IVendorService.cs b/SeaHaven.Services/Interfaces/IVendorService.cs index aad7b13..2ab4c67 100644 --- a/SeaHaven.Services/Interfaces/IVendorService.cs +++ b/SeaHaven.Services/Interfaces/IVendorService.cs @@ -21,5 +21,7 @@ namespace SeaHaven.Services.Interfaces Task DeleteVendorAsync(int id, string userId); Task VendorExistsAsync(int id); Task GetTotalVendorCountAsync(); + Task GetDeactivationImpactAsync(int vendorId); + Task UpdateVendorFromWorkOrderAsync(int vendorId, WorkOrderVendorUpdateDTO dto, string userId); } } diff --git a/SeaHaven.Services/Validation/VendorValidation.cs b/SeaHaven.Services/Validation/VendorValidation.cs index acce074..5ee2c06 100644 --- a/SeaHaven.Services/Validation/VendorValidation.cs +++ b/SeaHaven.Services/Validation/VendorValidation.cs @@ -1,5 +1,6 @@ using FluentValidation; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; namespace SeaHaven.Services.Validation { @@ -23,16 +24,23 @@ namespace SeaHaven.Services.Validation .NotEmpty().WithMessage("Technician name is required") .MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters"); + RuleFor(x => x.PreferredContact) + .Must(VendorValidationRules.BeValidPreferredContact) + .WithMessage("PreferredContact must be one of: Phone, Email, Text") + .When(x => !string.IsNullOrEmpty(x.PreferredContact)); + RuleFor(x => x.TradeSpecialties) .MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters") .When(x => !string.IsNullOrEmpty(x.TradeSpecialties)); RuleFor(x => x.Phone) - .MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters") + .Must(VendorValidationRules.BeValidNorthAmericanPhone) + .WithMessage("Phone must be in North American format: (XXX) XXX-XXXX") .When(x => !string.IsNullOrEmpty(x.Phone)); RuleFor(x => x.CompanyPhone) - .MaximumLength(20).WithMessage("Company phone cannot exceed 20 characters") + .Must(VendorValidationRules.BeValidNorthAmericanPhone) + .WithMessage("Company phone must be in North American format: (XXX) XXX-XXXX") .When(x => !string.IsNullOrEmpty(x.CompanyPhone)); RuleFor(x => x.Address) @@ -82,16 +90,23 @@ namespace SeaHaven.Services.Validation .MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters") .When(x => x.ContactName != null); + RuleFor(x => x.PreferredContact) + .Must(VendorValidationRules.BeValidPreferredContact) + .WithMessage("PreferredContact must be one of: Phone, Email, Text") + .When(x => !string.IsNullOrEmpty(x.PreferredContact)); + RuleFor(x => x.TradeSpecialties) .MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters") .When(x => !string.IsNullOrEmpty(x.TradeSpecialties)); RuleFor(x => x.Phone) - .MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters") + .Must(VendorValidationRules.BeValidNorthAmericanPhone) + .WithMessage("Phone must be in North American format: (XXX) XXX-XXXX") .When(x => !string.IsNullOrEmpty(x.Phone)); RuleFor(x => x.CompanyPhone) - .MaximumLength(20).WithMessage("Company phone cannot exceed 20 characters") + .Must(VendorValidationRules.BeValidNorthAmericanPhone) + .WithMessage("Company phone must be in North American format: (XXX) XXX-XXXX") .When(x => !string.IsNullOrEmpty(x.CompanyPhone)); RuleFor(x => x.Address) @@ -121,9 +136,52 @@ namespace SeaHaven.Services.Validation } + public interface IWorkOrderVendorUpdateValidation : IValidator + { + } + + public class WorkOrderVendorUpdateValidation : AbstractValidator, IWorkOrderVendorUpdateValidation + { + public WorkOrderVendorUpdateValidation() + { + RuleFor(x => x.WorkOrderId) + .GreaterThan(0).WithMessage("A valid work order id is required"); + + RuleFor(x => x.PreferredContact) + .Must(VendorValidationRules.BeValidPreferredContact) + .WithMessage("PreferredContact must be one of: Phone, Email, Text") + .When(x => !string.IsNullOrEmpty(x.PreferredContact)); + + RuleFor(x => x.Phone) + .Must(VendorValidationRules.BeValidNorthAmericanPhone) + .WithMessage("Phone must be in North American format: (XXX) XXX-XXXX") + .When(x => !string.IsNullOrEmpty(x.Phone)); + + RuleFor(x => x.Email) + .EmailAddress().WithMessage("Invalid email address") + .When(x => !string.IsNullOrEmpty(x.Email)); + + RuleFor(x => x.Notes) + .MaximumLength(2000).WithMessage("Notes cannot exceed 2000 characters") + .When(x => !string.IsNullOrEmpty(x.Notes)); + + RuleFor(x => x.ContactName) + .MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters") + .When(x => !string.IsNullOrEmpty(x.ContactName)); + } + } + internal static class VendorValidationRules { + private static readonly string[] AllowedPreferredContacts = { "Phone", "Email", "Text" }; + internal static bool BeValidHttpsUrl(string? value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps; + + internal static bool BeValidPreferredContact(string? value) => + AllowedPreferredContacts.Contains(value, StringComparer.OrdinalIgnoreCase); + + internal static bool BeValidNorthAmericanPhone(string? value) => + value is not null && VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(value); } }