mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
feat(vendors): complete core vendor workflows
This commit is contained in:
parent
1162c68596
commit
8cf49afc2c
18 changed files with 1213 additions and 20 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<IVendorDataService> 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<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(8)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(8)).ReturnsAsync(new List<LinkedWorkOrderInfo>());
|
||||
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<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(9)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(9)).ReturnsAsync(new List<LinkedWorkOrderInfo>());
|
||||
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<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(20)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(20))
|
||||
.ReturnsAsync(new List<LinkedWorkOrderInfo>
|
||||
{
|
||||
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<VendorDeactivationBlockedException>();
|
||||
existing.IsActive.Should().BeTrue();
|
||||
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateVendor_WithUnknownCompanyId_ThrowsAndDoesNotFallBackToName()
|
||||
{
|
||||
var existing = new Vendor { Id = 30, CompanyName = "Existing", IsActive = true };
|
||||
var data = new Mock<IVendorDataService>();
|
||||
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<ValidationException>();
|
||||
data.Verify(x => x.GetCompanyByNormalizedNameAsync(It.IsAny<string>()), Times.Never);
|
||||
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVendor_PropagatesExistingCompanyIdWithoutCreatingCompany()
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
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<Vendor>()))
|
||||
.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<VendorCompany>()), 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<IVendorDataService>();
|
||||
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<InvalidOperationException>();
|
||||
existing.ContactName.Should().Be("Original");
|
||||
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), 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<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(50)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.IsVendorAssignedToWorkOrderAsync(50, 777)).ReturnsAsync(true);
|
||||
var auditLogs = new List<VendorAuditLog>();
|
||||
data.Setup(x => x.UpdateWithAuditLogsAsync(
|
||||
existing,
|
||||
It.IsAny<IReadOnlyCollection<VendorAuditLog>>()))
|
||||
.Callback<Vendor, IReadOnlyCollection<VendorAuditLog>>((_, 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<IVendorDataService>();
|
||||
data.Setup(x => x.ExistsAsync(404)).ReturnsAsync(false);
|
||||
|
||||
var act = () => NewService(data).GetDeactivationImpactAsync(404);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*404*not found");
|
||||
data.Verify(x => x.GetOpenLinkedWorkOrdersAsync(It.IsAny<int>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,16 @@ namespace Data.SeaHavenIndustries
|
|||
.WithMany()
|
||||
.HasForeignKey(w => w.PrimaryDispatchId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.Entity<VendorCompany>()
|
||||
.HasIndex(c => c.NormalizedName)
|
||||
.IsUnique();
|
||||
|
||||
builder.Entity<Vendor>()
|
||||
.HasOne(v => v.Company)
|
||||
.WithMany(c => c.Vendors)
|
||||
.HasForeignKey(v => v.CompanyId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
public DbSet<Category> Categories { get; set; }
|
||||
public DbSet<Locations> Locations { get; set; }
|
||||
|
|
@ -68,6 +78,8 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { get; set; }
|
||||
public DbSet<DropdownOption> DropdownOptions { get; set; }
|
||||
public DbSet<Vendor> Vendors { get; set; }
|
||||
public DbSet<VendorCompany> VendorCompanies { get; set; }
|
||||
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }
|
||||
public DbSet<VendorAccessToken> VendorAccessTokens { get; set; }
|
||||
public DbSet<Dispatch> Dispatches { get; set; }
|
||||
public DbSet<DispatchWorkOrder> DispatchWorkOrders { get; set; }
|
||||
|
|
|
|||
|
|
@ -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<int>(
|
||||
name: "CompanyId",
|
||||
table: "Vendors",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PreferredContact",
|
||||
table: "Vendors",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorCompanies",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
NormalizedName = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
CompanyPhone = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Email = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Address = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
City = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
State = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Zip = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
GoogleMapsUrl = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
|
||||
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VendorCompanies", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorAuditLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
VendorId = table.Column<int>(type: "int", nullable: false),
|
||||
WorkOrderId = table.Column<int>(type: "int", nullable: true),
|
||||
FieldName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
OldValue = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
NewValue = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Actor = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1730,6 +1730,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("City")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("CompanyName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -1772,6 +1775,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("Phone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PreferredContact")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Actor")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("FieldName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("NewValue")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("OldValue")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("VendorId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("WorkOrderId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VendorId");
|
||||
|
||||
b.ToTable("VendorAuditLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Address")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("City")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("CompanyPhone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("GoogleMapsUrl")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("State")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Zip")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("VendorCompanies");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -1978,13 +2088,13 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<DateTime?>("ScheduledStart")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Severity")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Service")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Severity")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("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");
|
||||
|
|
|
|||
15
Data.SeaHavenIndustries/Models/LinkedWorkOrderInfo.cs
Normal file
15
Data.SeaHavenIndustries/Models/LinkedWorkOrderInfo.cs
Normal file
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Dispatch>? Dispatches { get; set; }
|
||||
public ICollection<VendorAuditLog>? AuditLogs { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
Data.SeaHavenIndustries/Models/VendorAuditLog.cs
Normal file
18
Data.SeaHavenIndustries/Models/VendorAuditLog.cs
Normal file
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
19
Data.SeaHavenIndustries/Models/VendorCompany.cs
Normal file
19
Data.SeaHavenIndustries/Models/VendorCompany.cs
Normal file
|
|
@ -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<Vendor>? Vendors { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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<VendorCompany?> GetCompanyByIdAsync(int id)
|
||||
{
|
||||
return await _context.VendorCompanies
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
}
|
||||
|
||||
public async Task<VendorCompany?> GetCompanyByNormalizedNameAsync(string normalizedName)
|
||||
{
|
||||
return await _context.VendorCompanies
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.NormalizedName == normalizedName);
|
||||
}
|
||||
|
||||
public async Task<VendorCompany> AddCompanyAsync(VendorCompany company)
|
||||
{
|
||||
await _context.VendorCompanies.AddAsync(company);
|
||||
await _context.SaveChangesAsync();
|
||||
return company;
|
||||
}
|
||||
|
||||
internal IQueryable<LinkedWorkOrderInfo> 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<List<LinkedWorkOrderInfo>> GetOpenLinkedWorkOrdersAsync(int vendorId)
|
||||
{
|
||||
var raw = await BuildOpenLinkedWorkOrdersQuery(vendorId).ToListAsync();
|
||||
return raw.DistinctBy(x => x.WorkOrderId).ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> 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<VendorAuditLog> auditLogs)
|
||||
{
|
||||
_context.Vendors.Update(vendor);
|
||||
if (auditLogs.Count > 0)
|
||||
await _context.VendorAuditLogs.AddRangeAsync(auditLogs);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,5 +23,12 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<VendorAccessToken?> GetActiveTokenAsync(int vendorId);
|
||||
Task<VendorAccessToken?> GetTokenByStringAsync(string token);
|
||||
Task<int> CountAsync();
|
||||
|
||||
Task<VendorCompany?> GetCompanyByIdAsync(int id);
|
||||
Task<VendorCompany?> GetCompanyByNormalizedNameAsync(string normalizedName);
|
||||
Task<VendorCompany> AddCompanyAsync(VendorCompany company);
|
||||
Task<List<LinkedWorkOrderInfo>> GetOpenLinkedWorkOrdersAsync(int vendorId);
|
||||
Task<bool> IsVendorAssignedToWorkOrderAsync(int vendorId, int workOrderId);
|
||||
Task UpdateWithAuditLogsAsync(Vendor vendor, IReadOnlyCollection<VendorAuditLog> auditLogs);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<LinkedWorkOrderDTO> 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<LinkedWorkOrderDTO> OpenWorkOrders { get; }
|
||||
|
||||
public VendorDeactivationBlockedException(string message, List<LinkedWorkOrderDTO> openWorkOrders)
|
||||
: base(message)
|
||||
{
|
||||
OpenWorkOrders = openWorkOrders;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
SeaHaven.Services/Helpers/VendorPhoneNormalizer.cs
Normal file
25
SeaHaven.Services/Helpers/VendorPhoneNormalizer.cs
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Vendor service implementation
|
||||
/// Contains business logic, validation, and orchestration for Vendor operations
|
||||
/// </summary>
|
||||
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<VendorDTO?> 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<VendorDeactivationImpactDTO> 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<VendorDTO> 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<VendorAuditLog>();
|
||||
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<VendorCompany?> 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,
|
||||
|
|
|
|||
|
|
@ -21,5 +21,7 @@ namespace SeaHaven.Services.Interfaces
|
|||
Task DeleteVendorAsync(int id, string userId);
|
||||
Task<bool> VendorExistsAsync(int id);
|
||||
Task<int> GetTotalVendorCountAsync();
|
||||
Task<VendorDeactivationImpactDTO> GetDeactivationImpactAsync(int vendorId);
|
||||
Task<VendorDTO> UpdateVendorFromWorkOrderAsync(int vendorId, WorkOrderVendorUpdateDTO dto, string userId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<WorkOrderVendorUpdateDTO>
|
||||
{
|
||||
}
|
||||
|
||||
public class WorkOrderVendorUpdateValidation : AbstractValidator<WorkOrderVendorUpdateDTO>, 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue