diff --git a/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs b/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs new file mode 100644 index 0000000..c786e8f --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs @@ -0,0 +1,280 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.Extensions.Configuration; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.Validation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +internal static class TestAsyncEnumerableHelper +{ + public static async IAsyncEnumerable ToAsyncEnumerable(this IEnumerable source) + { + foreach (var item in source) + yield return item; + await Task.CompletedTask; + } +} + +public class SyncServiceBehaviorTests +{ + [Fact] + public async Task SyncWorkOrders_CreatesNewAndUpdatesExisting() + { + var data = new Mock(); + var external = new Mock(); + + var existingWO = new WorkOrder { Id = 5, ExternalWorkOrderId = "EXT-1", WorkerOrderTitle = "Old" }; + + data.Setup(d => d.GetLastInternalWONumberAsync(It.IsAny())) + .ReturnsAsync("10000005"); + data.Setup(d => d.GetWorkOrderByExternalIdAsync("EXT-1", It.IsAny())) + .ReturnsAsync(existingWO); + data.Setup(d => d.GetWorkOrderByExternalIdAsync("EXT-2", It.IsAny())) + .ReturnsAsync((WorkOrder?)null); + data.Setup(d => d.ResolveLocationAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int?)null); + data.Setup(d => d.GetLocationCountAsync(It.IsAny())) + .ReturnsAsync(3); + + var items = new List> + { + new() { ["work_order_id"] = "EXT-1", ["description"] = "Updated Title", ["wo_status"] = "in_progress" }, + new() { ["work_order_id"] = "EXT-2", ["description"] = "New WO", ["wo_status"] = "new", ["severity"] = "3" } + }; + + external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny())) + .Returns(items.ToAsyncEnumerable()); + + var service = new SyncService(data.Object, external.Object); + var result = await service.SyncWorkOrdersAsync(CancellationToken.None); + + Assert.Equal(2, result.Synced); + Assert.Equal(1, result.Created); + Assert.Equal(1, result.Updated); + Assert.Equal(3, result.LocationsCreated); + Assert.Equal("Updated Title", existingWO.WorkerOrderTitle); + Assert.Equal("In Progress", existingWO.Status); + data.Verify(d => d.EnqueueWorkOrder(It.Is(w => w.ExternalWorkOrderId == "EXT-2")), Times.Once); + } + + [Fact] + public async Task SyncWorkOrders_MapsStatusAndSeverity() + { + var data = new Mock(); + var external = new Mock(); + + data.Setup(d => d.GetLastInternalWONumberAsync(It.IsAny())).ReturnsAsync((string?)null); + data.Setup(d => d.GetWorkOrderByExternalIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync((WorkOrder?)null); + data.Setup(d => d.ResolveLocationAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((int?)null); + data.Setup(d => d.GetLocationCountAsync(It.IsAny())).ReturnsAsync(0); + + var items = new List> + { + new() { ["work_order_id"] = "W1", ["wo_status"] = "completed", ["severity"] = "2" } + }; + external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny())).Returns(items.ToAsyncEnumerable()); + + await new SyncService(data.Object, external.Object).SyncWorkOrdersAsync(CancellationToken.None); + + data.Verify(d => d.EnqueueWorkOrder(It.Is(w => w.Status == "Done" && w.Priority == "Sev 2" && w.Severity == "2")), Times.Once); + } + + [Fact] + public async Task SyncComments_SkipsExistingAndOrphans() + { + var data = new Mock(); + var external = new Mock(); + var wo = new WorkOrder { Id = 10, ExternalWorkOrderId = "WO-1" }; + + data.Setup(d => d.CommentExistsByExternalIdAsync("C-EXISTS", It.IsAny())).ReturnsAsync(true); + data.Setup(d => d.CommentExistsByExternalIdAsync("C-NEW", It.IsAny())).ReturnsAsync(false); + data.Setup(d => d.CommentExistsByExternalIdAsync("C-ORPHAN", It.IsAny())).ReturnsAsync(false); + data.Setup(d => d.GetWorkOrderByExternalIdAsync("WO-1", It.IsAny())).ReturnsAsync(wo); + data.Setup(d => d.GetWorkOrderByExternalIdAsync("WO-MISSING", It.IsAny())).ReturnsAsync((WorkOrder?)null); + + var items = new List> + { + new() { ["comment_id"] = "C-EXISTS", ["work_order_id"] = "WO-1" }, + new() { ["comment_id"] = "C-NEW", ["work_order_id"] = "WO-1", ["text"] = "Hello" }, + new() { ["comment_id"] = "C-ORPHAN", ["work_order_id"] = "WO-MISSING" } + }; + external.Setup(e => e.ScanCommentsAsync(It.IsAny())).Returns(items.ToAsyncEnumerable()); + + var result = await new SyncService(data.Object, external.Object).SyncCommentsAsync(CancellationToken.None); + + Assert.Equal(2, result.Synced); + Assert.Equal(1, result.Created); + Assert.Equal(2, result.Skipped); + data.Verify(d => d.EnqueueComment(It.Is(c => c.ExternalCommentId == "C-NEW")), Times.Once); + } +} + +public class WorkOrderDispatchServiceBehaviorTests +{ + [Fact] + public async Task VerifyDispatch_ThrowsWhenChecklistIncomplete() + { + var dispatchData = CreateVerifyDispatchMocks( + out var dispatch, + incompleteCount: 1, + checklistComplete: false, + hasCustomerSignoff: true, + hasVendorSignoff: true); + dispatch.Id = 1; + + var service = CreateDispatchService(dispatchData); + var ex = await Assert.ThrowsAsync(() => service.VerifyDispatchAsync(1, "user1")); + Assert.Contains("1 checklist item(s) incomplete", ex.Missing); + } + + [Fact] + public async Task VerifyDispatch_ThrowsWhenVendorSignoffMissing() + { + var dispatchData = CreateVerifyDispatchMocks( + out var dispatch, + incompleteCount: 0, + checklistComplete: true, + hasCustomerSignoff: true, + hasVendorSignoff: false); + dispatch.Id = 1; + + var service = CreateDispatchService(dispatchData); + var ex = await Assert.ThrowsAsync(() => service.VerifyDispatchAsync(1, "user1")); + Assert.Contains("Missing Vendor sign-off", ex.Missing); + } + + [Fact] + public async Task VerifyDispatch_SucceedsWhenAllComplete() + { + var dispatchData = CreateVerifyDispatchMocks( + out var dispatch, + incompleteCount: 0, + checklistComplete: true, + hasCustomerSignoff: true, + hasVendorSignoff: true); + dispatch.Id = 1; + + var userMock = new Mock(); + userMock.Setup(u => u.GetByIdAsync("user1")).ReturnsAsync(new ApplicationUser { FirstName = "Jane", LastName = "Doe" }); + + var service = CreateDispatchService(dispatchData, userMock: userMock); + var result = await service.VerifyDispatchAsync(1, "user1"); + + Assert.NotNull(result); + Assert.Equal("Dispatch verified", result!.Message); + Assert.Equal("Verified", dispatch.Status); + Assert.Equal("Jane Doe", dispatch.VerifiedBy); + } + + private static Mock CreateVerifyDispatchMocks( + out Dispatch dispatch, + int incompleteCount, + bool checklistComplete, + bool hasCustomerSignoff, + bool hasVendorSignoff) + { + dispatch = new Dispatch { Id = 1, DispatchNumber = "DSP-00001", WorkOrderId = 10 }; + var mock = new Mock(); + mock.Setup(d => d.GetByIdAsync(1)).ReturnsAsync(dispatch); + + var checklist = checklistComplete + ? new List { new() { IsCompleted = true } } + : new List { new() { IsCompleted = false } }; + mock.Setup(d => d.GetChecklistItemsAsync(1)).ReturnsAsync(checklist); + + var signoffs = new List(); + if (hasCustomerSignoff) signoffs.Add(new DispatchSignoff { SignoffType = "Customer" }); + if (hasVendorSignoff) signoffs.Add(new DispatchSignoff { SignoffType = "Vendor" }); + mock.Setup(d => d.GetSignoffsAsync(1)).ReturnsAsync(signoffs); + + return mock; + } + + private static WorkOrderDispatchService CreateDispatchService( + Mock? dispatchDataMock = null, + Mock? userMock = null) + { + return new WorkOrderDispatchService( + Mock.Of(), + (dispatchDataMock ?? new Mock()).Object, + Mock.Of(), + (userMock ?? new Mock()).Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); + } +} + +public class WorkOrderServiceBehaviorTests +{ + [Fact] + public async Task ChangeStatus_AddsAuditWhenStatusDiffers() + { + var wo = new WorkOrder { Id = 5, Status = "Open" }; + var woData = new Mock(); + woData.Setup(d => d.GetByIdAsync(5)).ReturnsAsync(wo); + + var service = CreateWorkOrderService(woData); + var result = await service.ChangeStatusAsync(5, "In Progress", "user1"); + + Assert.NotNull(result); + Assert.Equal("In Progress", wo.Status); + woData.Verify(d => d.AddAuditLogAsync(It.Is(a => + a.FieldName == "Status" && a.OldValue == "Open" && a.NewValue == "In Progress")), Times.Once); + } + + [Fact] + public async Task ChangeStatus_DoesNotAuditWhenStatusSame() + { + var wo = new WorkOrder { Id = 5, Status = "Open" }; + var woData = new Mock(); + woData.Setup(d => d.GetByIdAsync(5)).ReturnsAsync(wo); + + await CreateWorkOrderService(woData).ChangeStatusAsync(5, "Open", "user1"); + + woData.Verify(d => d.AddAuditLogAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task DeleteWorkOrderCascade_UnlinksQuotesAndDeletesComments() + { + var woData = new Mock(); + woData.Setup(d => d.ExistsAsync(5)).ReturnsAsync(true); + + var commentData = new Mock(); + commentData.Setup(c => c.GetByWorkOrderIdAsync(5)) + .ReturnsAsync(new List { new() { Id = 1 }, new() { Id = 2 } }); + + var quotesData = new Mock(); + + var result = await CreateWorkOrderService(woData, commentData, quotesData).DeleteWorkOrderCascadeAsync(5, "user1"); + + Assert.True(result); + quotesData.Verify(q => q.UnlinkFromWorkOrderAsync(5), Times.Once); + commentData.Verify(c => c.DeleteAsync(1), Times.Once); + commentData.Verify(c => c.DeleteAsync(2), Times.Once); + } + + private static WorkOrderService CreateWorkOrderService( + Mock? woData = null, + Mock? commentData = null, + Mock? quotesData = null) + { + return new WorkOrderService( + (woData ?? new Mock()).Object, + (commentData ?? new Mock()).Object, + Mock.Of(), + (quotesData ?? new Mock()).Object, + Mock.Of(), + Mock.Of(), + Mock.Of()); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/QuotesController.cs b/Api.SeaHavenIndustries/Controllers/QuotesController.cs index 311fb33..6967159 100644 --- a/Api.SeaHavenIndustries/Controllers/QuotesController.cs +++ b/Api.SeaHavenIndustries/Controllers/QuotesController.cs @@ -1,12 +1,9 @@ using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.Data.SqlClient; -using Microsoft.EntityFrameworkCore; -using System.Linq; -using static System.Runtime.InteropServices.JavaScript.JSType; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -15,52 +12,21 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/Quotes")] public class QuotesController : Controller { - private readonly UserManager _userManager; - private readonly ApplicationDbContext _db; - private readonly IWebHostEnvironment _webHostEnvironment; - IHttpContextAccessor _httpContext; - public QuotesController(UserManager userManager, ApplicationDbContext db, IWebHostEnvironment webHostEnvironment, IHttpContextAccessor httpContext) + private readonly IQuotesService _quotesService; + + public QuotesController(IQuotesService quotesService) { - _userManager = userManager; - _db = db; - _webHostEnvironment = webHostEnvironment; - _httpContext = httpContext; + _quotesService = quotesService; } + [HttpPost] [Route("AddQuote")] - public async Task AddQuote([FromBody] Quote_DTO model) + public async Task AddQuote([FromBody] Quote_DTO model, CancellationToken cancellationToken) { try { - Random rnd = new Random(); - int id = rnd.Next(1, 99999); - Quotes quotes = new Quotes(); - quotes.QuotId = id.ToString(); - quotes.ContactId = model.ContactId; - quotes.PO = model.PO; - quotes.TT = model.TT; - quotes.ProjectName = model.ProjectName; - quotes.Date = DateTime.Parse(model.Date); - quotes.WorkorderId = model.WorkorderId; - quotes.SiteName = model.SiteName; - quotes.SiteId = model.SiteId; - quotes.BillingAddress = model.BillingAddress; - quotes.LineItems = quotes.LineItems == null ? new List() : quotes.LineItems; - if (model.LineItems != null && model.LineItems.Any()) - { - foreach (var lineItemDto in model.LineItems) - { - QuotesLineItems lineItem = new QuotesLineItems() - { - LineitemNumber = lineItemDto.LineitemNumber, - Lineitem = lineItemDto.Lineitem, - Descriptions = lineItemDto.Descriptions - }; - quotes.LineItems.Add(lineItem); - } - } - _db.Quotes.Add(quotes); - await _db.SaveChangesAsync(); + await _quotesService.CreateQuoteAsync(MapToCreateInput(model), cancellationToken); + return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (Exception ex) @@ -71,50 +37,14 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPut] [Route("EditQuote")] - public async Task EditQuote([FromBody] EditQuote_DTO model) + public async Task EditQuote([FromBody] EditQuote_DTO model, CancellationToken cancellationToken) { try { - var quotes = _db.Quotes.Where(w => w.Id == model.id).FirstOrDefault(); - if (quotes != null) - { - quotes.QuotId = model.QuotId; - quotes.ContactId = model.ContactId; - quotes.PO = model.PO; - quotes.TT = model.TT; - quotes.ProjectName = model.ProjectName; - quotes.Date = DateTime.Parse(model.Date); - quotes.WorkorderId = model.WorkorderId; - quotes.SiteName = model.SiteName; - quotes.SiteId = model.SiteId; - quotes.BillingAddress = model.BillingAddress; - - var existQuoteLine = _db.QuotesLines.Where(w => w.QuoteId == model.id).ToList(); - if (existQuoteLine.Count() > 0) - { - _db.QuotesLines.RemoveRange(existQuoteLine); - } - quotes.LineItems = quotes.LineItems == null ? new List() : quotes.LineItems; - if (model.LineItems != null && model.LineItems.Any()) - { - foreach (var lineItemDto in model.LineItems) - { - QuotesLineItems lineItem = new QuotesLineItems() - { - LineitemNumber = lineItemDto.LineitemNumber, - Lineitem = lineItemDto.Lineitem, - Descriptions = lineItemDto.Descriptions - }; - quotes.LineItems.Add(lineItem); - } - } - _db.Update(quotes); - } - else - { + var updated = await _quotesService.UpdateQuoteAsync(MapToUpdateInput(model), cancellationToken); + if (!updated) return BadRequest("ID not matched!"); - } - await _db.SaveChangesAsync(); + return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (Exception ex) @@ -125,26 +55,15 @@ namespace Api.SeaHavenIndustries.Controllers [HttpDelete] [Route("DeleteQuote")] - public IActionResult DeleteQuote(EditQuote_DTO model) + public async Task DeleteQuote(EditQuote_DTO model, CancellationToken cancellationToken) { int id = model.id; - try + try { - var data = _db.Quotes.Where(w => w.Id == id).FirstOrDefault(); - if (data != null) - { - var existlineitem = _db.QuotesLines.Where(w => w.QuoteId == id).ToList(); - if (existlineitem.Any()) - { - _db.QuotesLines.RemoveRange(existlineitem); - } - _db.Quotes.Remove(data); - } - else - { + var deleted = await _quotesService.DeleteQuoteAsync(id, cancellationToken); + if (!deleted) return BadRequest("ID not matched!"); - } - _db.SaveChanges(); + return Ok(new DataResponse { Message = "Removed Successfully", Status = "200" }); } catch (Exception ex) @@ -155,38 +74,18 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet] [Route("GetQuoutes")] - public IActionResult GetQuotes(string search = "", int page = 1, int pageSize = 10) + public async Task GetQuotes(string search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) { - var totalCount = _db.Quotes.Count(); - var data = _db.Quotes.Include(s => s.WorkOrder).AsNoTracking().Where(w => (w.QuotId ?? "").ToLower().Contains(search.ToLower()) || (w.SiteName ?? "").ToLower().Contains(search.ToLower()) || (w.WorkOrder != null && (w.WorkOrder.WorkerOrderTitle ?? "").ToLower().Contains(search.ToLower()))).Select(s => new - { - s.Id, - QuoteId = "QO-" + s.QuotId, - WorkOrder = s.WorkOrder != null ? s.WorkOrder.WorkerOrderNumber : "", - s.ProjectName, - TotalAmount = "$" + (s.LineItems != null ? s.LineItems.Sum(l => l.Lineitem ?? 0) : 0).ToString(), - s.SiteName, - s.SiteId, - s.PO, - s.TT, - s.BillingAddress, - s.Status, - s.ContactId, - s.Date, - lineitems = s.LineItems.Select(q => new { q.Descriptions, q.Lineitem, q.LineitemNumber}), - }).OrderByDescending(d => d.Id).Skip((page - 1) * pageSize) - .Take(pageSize).ToList(); + var result = await _quotesService.GetQuotesPagedAsync(page, pageSize, search, cancellationToken); - // Calculate the number of pages - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + var totalPages = (int)Math.Ceiling(result.TotalCount / (double)pageSize); - // Create a view model containing the paged items and pagination metadata var viewModel = new Pagination_DTO { - Data = data, + Data = result.Items, PageNumber = page, PageSize = pageSize, - TotalCount = totalCount, + TotalCount = result.TotalCount, TotalPages = totalPages }; return Ok(viewModel); @@ -194,66 +93,85 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet] [Route("GetQuoutesdetailsAgainstWorkOrder")] - public async Task GetQuoutesdetailsAgainstWorkOrder(int woId) + public async Task GetQuoutesdetailsAgainstWorkOrder(int woId, CancellationToken cancellationToken) { - var data = await _db.workOrders.Include(s => s.Locations).Where(w => w.Id == woId).Select(s => new - { - s.Id, - s.PO, - s.TT, - Title = s.Locations != null ? s.Locations.Title : "", - Name = s.Locations != null ? s.Locations.Name : "" - }).ToListAsync(); + var data = await _quotesService.GetWorkOrderQuoteDetailsAsync(woId, cancellationToken); return Ok(data); } [HttpGet] [Route("GetQuoutesById")] - public async Task GetQuoutesById(int id) + public async Task GetQuoutesById(int id, CancellationToken cancellationToken) { - var data = await _db.Quotes.Where(s => s.Id == id).Include(s => s.WorkOrder).Select(s => new - { - s.Id, - s.QuotId, - s.Date, - Name = s.Contacts != null ? (s.Contacts.FirstName + " " + s.Contacts.LastName).Trim() : "", - s.PO, - s.TT, - s.SiteName, - s.ProjectName, - s.SiteId, - s.BillingAddress, - s.ShippingAddress, - lineitems = s.LineItems.Select(q => new { q.Descriptions, q.Lineitem }), - TotalAmount = "$" + (s.LineItems != null ? s.LineItems.Sum(l => l.Lineitem ?? 0) : 0).ToString() - }).FirstOrDefaultAsync(); + var data = await _quotesService.GetQuoteDetailsByIdAsync(id, cancellationToken); return Ok(data); } [HttpGet] [Route("GetReporting")] - public async Task GetReporting() + public async Task GetReporting(CancellationToken cancellationToken) { - var totalCreatedWorkOrder = await _db.workOrders.CountAsync(); - var totalCompleted = _db.workOrders.Count(w => w.Status == "Done"); - var totalSev1 = _db.workOrders.Count(w => w.Priority == "Sev 1"); - var totalSev2 = _db.workOrders.Count(w => w.Priority == "Sev 2"); - var totalSev3 = _db.workOrders.Count(w => w.Priority == "Sev 3"); - var totalSev4 = _db.workOrders.Count(w => w.Priority == "Sev 4"); - var totalSev5 = _db.workOrders.Count(w => w.Priority == "Sev 5"); + var data = await _quotesService.GetReportingAsync(cancellationToken); - var data = new Reporting_DTO + var reportingDto = new Reporting_DTO { - TotalCreatedWorkOrder = totalCreatedWorkOrder, - TotalCompleted = totalCompleted, - TotalSev1 = totalSev1, - TotalSev2 = totalSev2, - TotalSev3 = totalSev3, - TotalSev4 = totalSev4, - TotalSev5 = totalSev5 + TotalCreatedWorkOrder = data.TotalCreatedWorkOrder, + TotalCompleted = data.TotalCompleted, + TotalSev1 = data.TotalSev1, + TotalSev2 = data.TotalSev2, + TotalSev3 = data.TotalSev3, + TotalSev4 = data.TotalSev4, + TotalSev5 = data.TotalSev5 }; - return Ok(data); + return Ok(reportingDto); + } + + private static CreateQuoteInput MapToCreateInput(Quote_DTO dto) + { + return new CreateQuoteInput + { + QuotId = dto.QuotId, + ContactId = dto.ContactId, + PO = dto.PO, + TT = dto.TT, + ProjectName = dto.ProjectName, + Date = dto.Date != null ? DateTime.Parse(dto.Date) : null, + WorkorderId = dto.WorkorderId, + SiteName = dto.SiteName, + SiteId = dto.SiteId, + BillingAddress = dto.BillingAddress, + LineItems = dto.LineItems?.Select(li => new QuoteLineItemInput + { + LineitemNumber = li.LineitemNumber, + Lineitem = li.Lineitem, + Descriptions = li.Descriptions + }).ToList() + }; + } + + private static UpdateQuoteInput MapToUpdateInput(EditQuote_DTO dto) + { + return new UpdateQuoteInput + { + Id = dto.id, + QuotId = dto.QuotId, + ContactId = dto.ContactId, + PO = dto.PO, + TT = dto.TT, + ProjectName = dto.ProjectName, + Date = dto.Date != null ? DateTime.Parse(dto.Date) : null, + WorkorderId = dto.WorkorderId, + SiteName = dto.SiteName, + SiteId = dto.SiteId, + BillingAddress = dto.BillingAddress, + LineItems = dto.LineItems?.Select(li => new QuoteLineItemInput + { + LineitemNumber = li.LineitemNumber, + Lineitem = li.Lineitem, + Descriptions = li.Descriptions + }).ToList() + }; } } } diff --git a/Api.SeaHavenIndustries/Controllers/SyncController.cs b/Api.SeaHavenIndustries/Controllers/SyncController.cs index 5d7f778..1be2b1e 100644 --- a/Api.SeaHavenIndustries/Controllers/SyncController.cs +++ b/Api.SeaHavenIndustries/Controllers/SyncController.cs @@ -1,9 +1,6 @@ -using Amazon.DynamoDBv2; -using Amazon.DynamoDBv2.Model; -using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -12,412 +9,60 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/[controller]")] public class SyncController : Controller { - private readonly ApplicationDbContext _db; - private readonly AmazonDynamoDBClient _dynamo; - private const string WO_TABLE = "WorkOrders"; - private const string COMMENTS_TABLE = "WorkOrderComments"; - private const string VENDOR_REPLIES_TABLE = "VendorReplies"; + private readonly ISyncService _syncService; - public SyncController(ApplicationDbContext db) + public SyncController(ISyncService syncService) { - _db = db; - _dynamo = new AmazonDynamoDBClient(Amazon.RegionEndpoint.USEast1); + _syncService = syncService; } [HttpPost("WorkOrders")] - public async Task SyncWorkOrders() + public async Task SyncWorkOrders(CancellationToken cancellationToken) { - var synced = 0; - var created = 0; - var updated = 0; - var locationsCreated = 0; - - var lastInternalWO = await _db.workOrders - .Where(w => w.InternalWONumber != null && w.InternalWONumber != "") - .OrderByDescending(w => w.InternalWONumber) - .Select(w => w.InternalWONumber) - .FirstOrDefaultAsync(); - int nextInternal = 10000001; - if (lastInternalWO != null && int.TryParse(lastInternalWO, out var parsed)) - nextInternal = parsed + 1; - - Dictionary? lastKey = null; - - do - { - var request = new ScanRequest - { - TableName = WO_TABLE, - Limit = 100, - ExclusiveStartKey = lastKey - }; - - var response = await _dynamo.ScanAsync(request); - - foreach (var item in response.Items) - { - var externalId = GetString(item, "work_order_id"); - if (string.IsNullOrEmpty(externalId)) continue; - - var existing = await _db.workOrders - .FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalId); - - var locationId = await ResolveLocationId( - GetString(item, "site_code"), - GetString(item, "building"), - GetString(item, "address")); - - if (locationId.HasValue && !locationsCreated.Equals(0)) - locationsCreated++; - - if (existing == null) - { - var wo = new WorkOrder - { - InternalWONumber = (nextInternal++).ToString("D8"), - ExternalWorkOrderId = externalId, - WorkerOrderNumber = externalId, - WorkerOrderTitle = GetString(item, "description"), - Description = GetString(item, "description"), - Status = MapStatus(GetString(item, "wo_status")), - Priority = MapSeverityToPriority(GetString(item, "severity")), - Severity = GetString(item, "severity"), - Customer = GetString(item, "customer"), - SiteCode = GetString(item, "site_code"), - Building = GetString(item, "building"), - LocationId = locationId, - DueDate = ParseDate(GetString(item, "due_date")), - DateReported = ParseDate(GetString(item, "date_reported")), - ScheduledStart = ParseDate(GetString(item, "scheduled_start")), - AssignTo = null, - SourceEmailS3Key = GetString(item, "source_email_s3_key"), - CreatedDate = ParseDate(GetString(item, "created_at")) ?? DateTime.UtcNow, - istemplate = false - }; - _db.workOrders.Add(wo); - created++; - } - else - { - existing.WorkerOrderTitle = GetString(item, "description") ?? existing.WorkerOrderTitle; - existing.Description = GetString(item, "description") ?? existing.Description; - existing.Status = MapStatus(GetString(item, "wo_status")) ?? existing.Status; - existing.Priority = MapSeverityToPriority(GetString(item, "severity")) ?? existing.Priority; - existing.Severity = GetString(item, "severity") ?? existing.Severity; - existing.SiteCode = GetString(item, "site_code") ?? existing.SiteCode; - existing.Building = GetString(item, "building") ?? existing.Building; - existing.LocationId = locationId ?? existing.LocationId; - existing.DueDate = ParseDate(GetString(item, "due_date")) ?? existing.DueDate; - existing.DateReported = ParseDate(GetString(item, "date_reported")) ?? existing.DateReported; - existing.ScheduledStart = ParseDate(GetString(item, "scheduled_start")) ?? existing.ScheduledStart; - existing.SourceEmailS3Key = GetString(item, "source_email_s3_key") ?? existing.SourceEmailS3Key; - updated++; - } - - synced++; - - if (synced % 100 == 0) - await _db.SaveChangesAsync(); - } - - lastKey = response.LastEvaluatedKey; - - } while (lastKey != null && lastKey.Count > 0); - - await _db.SaveChangesAsync(); - - return Ok(new - { - synced, - created, - updated, - locationsCreated = await _db.Locations.CountAsync() - }); + var result = await _syncService.SyncWorkOrdersAsync(cancellationToken); + return Ok(result); } [HttpPost("Comments")] - public async Task SyncComments() + public async Task SyncComments(CancellationToken cancellationToken) { - var synced = 0; - var created = 0; - var skipped = 0; - Dictionary? lastKey = null; - - do - { - var request = new ScanRequest - { - TableName = COMMENTS_TABLE, - Limit = 100, - ExclusiveStartKey = lastKey - }; - - var response = await _dynamo.ScanAsync(request); - - foreach (var item in response.Items) - { - var externalCommentId = GetString(item, "comment_id"); - var externalWoId = GetString(item, "work_order_id"); - - if (string.IsNullOrEmpty(externalCommentId) || string.IsNullOrEmpty(externalWoId)) - continue; - - var alreadyExists = await _db.Comments - .AnyAsync(c => c.ExternalCommentId == externalCommentId); - - if (alreadyExists) - { - skipped++; - synced++; - continue; - } - - var workOrder = await _db.workOrders - .FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalWoId); - - if (workOrder == null) - { - skipped++; - continue; - } - - var comment = new Comments - { - ExternalCommentId = externalCommentId, - WorkerOrderId = workOrder.Id, - Commenttext = GetString(item, "text"), - Commenter = GetString(item, "commenter"), - RecordType = GetString(item, "record_type"), - CommentType = "customer", - CreatedDate = ParseDate(GetString(item, "created_at")) ?? DateTime.UtcNow, - }; - - _db.Comments.Add(comment); - created++; - synced++; - - if (synced % 100 == 0) - await _db.SaveChangesAsync(); - } - - lastKey = response.LastEvaluatedKey; - - } while (lastKey != null && lastKey.Count > 0); - - await _db.SaveChangesAsync(); - - return Ok(new { synced, created, skipped }); + var result = await _syncService.SyncCommentsAsync(cancellationToken); + return Ok(result); } [HttpPost("BackfillInternalWONumbers")] - public async Task BackfillInternalWONumbers() + public async Task BackfillInternalWONumbers(CancellationToken cancellationToken) { - var wosMissing = await _db.workOrders - .Where(w => w.InternalWONumber == null || w.InternalWONumber == "") - .OrderBy(w => w.Id) - .ToListAsync(); - - if (wosMissing.Count == 0) - return Ok(new { updated = 0 }); - - var lastWO = await _db.workOrders - .Where(w => w.InternalWONumber != null && w.InternalWONumber != "") - .OrderByDescending(w => w.InternalWONumber) - .Select(w => w.InternalWONumber) - .FirstOrDefaultAsync(); - - int next = 10000001; - if (lastWO != null && int.TryParse(lastWO, out var lastNum)) - next = lastNum + 1; - - foreach (var wo in wosMissing) - { - wo.InternalWONumber = next.ToString("D8"); - next++; - } - - await _db.SaveChangesAsync(); - return Ok(new { updated = wosMissing.Count }); + var result = await _syncService.BackfillInternalWONumbersAsync(cancellationToken); + return Ok(result); } [HttpPost("BackfillDispatchNumbers")] - public async Task BackfillDispatchNumbers() + public async Task BackfillDispatchNumbers(CancellationToken cancellationToken) { - var missing = await _db.Dispatches - .Where(d => d.DispatchNumber == null || d.DispatchNumber == "") - .OrderBy(d => d.Id) - .ToListAsync(); - if (missing.Count == 0) return Ok(new { updated = 0 }); - - int next = 1; - var last = await _db.Dispatches - .Where(d => d.DispatchNumber != null && d.DispatchNumber != "") - .OrderByDescending(d => d.DispatchNumber) - .Select(d => d.DispatchNumber) - .FirstOrDefaultAsync(); - if (last != null && last.StartsWith("DSP-") && int.TryParse(last.Substring(4), out var n)) - next = n + 1; - - foreach (var d in missing) - { - d.DispatchNumber = $"DSP-{next:D5}"; - next++; - } - await _db.SaveChangesAsync(); - return Ok(new { updated = missing.Count }); + var result = await _syncService.BackfillDispatchNumbersAsync(cancellationToken); + return Ok(result); } [HttpPost("VendorReplies")] - public async Task SyncVendorReplies() + public async Task SyncVendorReplies(CancellationToken cancellationToken) { - var synced = 0; - var created = 0; - var skipped = 0; - Dictionary? lastKey = null; - - do - { - var request = new ScanRequest - { - TableName = VENDOR_REPLIES_TABLE, - Limit = 100, - ExclusiveStartKey = lastKey - }; - - var response = await _dynamo.ScanAsync(request); - - foreach (var item in response.Items) - { - var replyId = GetString(item, "reply_id"); - var woNumber = GetString(item, "internal_wo_number"); - var dispatchNumber = GetString(item, "dispatch_number"); - - if (string.IsNullOrEmpty(replyId) || string.IsNullOrEmpty(woNumber)) - continue; - - var alreadyExists = await _db.Comments - .AnyAsync(c => c.ExternalCommentId == replyId); - if (alreadyExists) { skipped++; synced++; continue; } - - var workOrder = await _db.workOrders - .FirstOrDefaultAsync(w => w.InternalWONumber == woNumber); - if (workOrder == null) { skipped++; continue; } - - int? dispatchId = null; - if (!string.IsNullOrWhiteSpace(dispatchNumber)) - { - var dispatch = await _db.Dispatches - .FirstOrDefaultAsync(d => d.DispatchNumber == dispatchNumber && d.WorkOrderId == workOrder.Id); - dispatchId = dispatch?.Id; - } - - var comment = new Comments - { - ExternalCommentId = replyId, - WorkerOrderId = workOrder.Id, - DispatchId = dispatchId, - Commenttext = GetString(item, "reply_text"), - Commenter = GetString(item, "sender_email"), - CommentType = "vendor", - RecordType = "vendor_reply", - CreatedDate = ParseDate(GetString(item, "received_at")) ?? DateTime.UtcNow, - }; - - _db.Comments.Add(comment); - created++; - synced++; - - if (synced % 50 == 0) - await _db.SaveChangesAsync(); - } - - lastKey = response.LastEvaluatedKey; - } while (lastKey != null && lastKey.Count > 0); - - await _db.SaveChangesAsync(); - return Ok(new { synced, created, skipped }); + var result = await _syncService.SyncVendorRepliesAsync(cancellationToken); + return Ok(result); } [HttpPost("BackfillCommentTypes")] - public async Task BackfillCommentTypes() + public async Task BackfillCommentTypes(CancellationToken cancellationToken) { - var updated = await _db.Database.ExecuteSqlRawAsync( - "UPDATE Comments SET CommentType = 'customer' WHERE ExternalCommentId IS NOT NULL AND (CommentType IS NULL OR CommentType = '')"); - return Ok(new { updated }); + var result = await _syncService.BackfillCommentTypesAsync(cancellationToken); + return Ok(result); } [HttpPost("All")] - public async Task SyncAll() + public async Task SyncAll(CancellationToken cancellationToken) { - var woResult = await SyncWorkOrders() as OkObjectResult; - var commentResult = await SyncComments() as OkObjectResult; - - return Ok(new - { - workOrders = woResult?.Value, - comments = commentResult?.Value - }); - } - - private async Task ResolveLocationId(string? siteCode, string? building, string? address) - { - if (string.IsNullOrWhiteSpace(siteCode) && string.IsNullOrWhiteSpace(building)) - return null; - - var matchCode = siteCode ?? building; - var existing = await _db.Locations - .FirstOrDefaultAsync(l => l.Name == matchCode || l.Title == matchCode); - - if (existing != null) - return existing.Id; - - var location = new Locations - { - Name = matchCode, - Title = building, - Address1 = address, - Status = "Active" - }; - - _db.Locations.Add(location); - await _db.SaveChangesAsync(); - - return location.Id; - } - - private static string? GetString(Dictionary item, string key) - { - if (item.TryGetValue(key, out var val) && val.S != null) - return val.S; - return null; - } - - private static DateTime? ParseDate(string? value) - { - if (string.IsNullOrWhiteSpace(value)) return null; - if (DateTime.TryParse(value, out var dt)) return dt; - return null; - } - - private static string? MapStatus(string? dynamoStatus) - { - return dynamoStatus switch - { - "new" => "Open", - "assigned" => "Open", - "in_progress" => "In Progress", - "on_hold" => "On Hold", - "completed" => "Done", - "cancelled" => "Cancelled", - "unknown" => "Open", - _ => "Open" - }; - } - - private static string? MapSeverityToPriority(string? severity) - { - if (string.IsNullOrWhiteSpace(severity)) return null; - return $"Sev {severity}"; + var result = await _syncService.SyncAllAsync(cancellationToken); + return Ok(result); } } } diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 705f22b..d7d6c12 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -1,23 +1,12 @@ using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; -using Data.SeaHavenIndustries.Enums; using FluentValidation; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Components.Forms; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.Data.SqlClient; -using Microsoft.EntityFrameworkCore; -using Microsoft.VisualBasic; -using SendGrid.Helpers.Mail; -using System.IO; -using System.Net.Http.Headers; -using System.Security.Claims; -using static System.Runtime.InteropServices.JavaScript.JSType; -using SeaHaven.Services.Interfaces; using SeaHaven.Services.DTOs; -using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Interfaces; +using SeaHaven.DataServices.Models; +using System.Security.Claims; namespace Api.SeaHavenIndustries.Controllers { @@ -27,55 +16,17 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/workorders")] public class WorkOrderController : Controller { - private readonly UserManager _userManager; private readonly IWorkOrderService _workOrderService; - private readonly IWorkOrderDataService _workOrderDataService; - private readonly ICommentDataService _commentDataService; - private readonly IQuotesDataService _quotesDataService; - private readonly IUserDataService _userDataService; - private readonly IDispatchDataService _dispatchDataService; - private readonly IVendorDataService _vendorDataService; - private readonly ITaskListTemplateDataService _taskListTemplateDataService; - private readonly ApplicationDbContext _db; - private readonly IWebHostEnvironment _webHostEnvironment; - IHttpContextAccessor _httpContext; - private readonly Helper.SendMessage _sendMessage; - private readonly IVendorPortalTokenService _vendorTokens; - private readonly IConfiguration _config; + private readonly IWorkOrderDispatchService _dispatchService; public WorkOrderController( - UserManager userManager, IWorkOrderService workOrderService, - IWorkOrderDataService workOrderDataService, - ICommentDataService commentDataService, - IQuotesDataService quotesDataService, - IUserDataService userDataService, - IDispatchDataService dispatchDataService, - IVendorDataService vendorDataService, - ITaskListTemplateDataService taskListTemplateDataService, - ApplicationDbContext db, - IWebHostEnvironment webHostEnvironment, - IHttpContextAccessor httpContext, - Helper.SendMessage sendMessage, - IVendorPortalTokenService vendorTokens, - IConfiguration config) + IWorkOrderDispatchService dispatchService) { - _userManager = userManager; _workOrderService = workOrderService; - _workOrderDataService = workOrderDataService; - _commentDataService = commentDataService; - _quotesDataService = quotesDataService; - _userDataService = userDataService; - _dispatchDataService = dispatchDataService; - _vendorDataService = vendorDataService; - _taskListTemplateDataService = taskListTemplateDataService; - _db = db; - _webHostEnvironment = webHostEnvironment; - _httpContext = httpContext; - _sendMessage = sendMessage; - _vendorTokens = vendorTokens; - _config = config; + _dispatchService = dispatchService; } + [HttpPost] [Route("AddWorkorder")] public async Task Addworkorder([FromForm] Workorder_DTO model) @@ -83,76 +34,23 @@ namespace Api.SeaHavenIndustries.Controllers try { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - - var createDto = new CreateWorkOrderDTO + var input = new CreateWorkOrderWithDetailsInput { WorkerOrderTitle = model.WorkerOrderTitle ?? "", Description = model.Description, - Status = WorkOrderStatus.Open, - Priority = model.Priority ?? WorkOrderPriority.Medium, + Priority = model.Priority?.ToString(), LocationId = model.LocationId, AssignTo = model.AssignTo, - DueDate = model.DueDate + DueDate = model.DueDate, + ContactIds = model.ContactIds, + CategoryIds = model.CategoryIds, + Attachments = model.Attachments, + BeforPhotoAttachment = model.BeforPhotoAttachment, + AfterPhotoAttachment = model.AfterPhotoAttachment, + SignOffAttachment = model.SignOffAttachment }; - var workOrderId = await _workOrderService.CreateWorkOrderAsync(createDto, userId!); - - if (model.ContactIds != null && model.ContactIds.Any()) - { - await _workOrderDataService.AddWorkOrderContactsAsync(workOrderId, model.ContactIds); - } - - if (model.CategoryIds != null && model.CategoryIds.Any()) - { - await _workOrderDataService.AddWorkOrderCategoriesAsync(workOrderId, model.CategoryIds); - } - - if (model.Attachments != null) - { - foreach (var attachment in model.Attachments) - { - var fileUrl = await SaveFileAsync(attachment); - await _workOrderDataService.AddWorkOrderAttachmentAsync(new WorkOrderAttachments - { - WorkorderId = workOrderId, - Attachments = fileUrl - }); - } - } - - if (model.BeforPhotoAttachment != null) - { - var fileUrl = await SaveFileAsync(model.BeforPhotoAttachment); - var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); - if (workOrder != null) - { - workOrder.BeforPhotoAttachment = fileUrl; - await _workOrderDataService.UpdateAsync(workOrder); - } - } - - if (model.AfterPhotoAttachment != null) - { - var fileUrl = await SaveFileAsync(model.AfterPhotoAttachment); - var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); - if (workOrder != null) - { - workOrder.AfterPhotoAttachment = fileUrl; - await _workOrderDataService.UpdateAsync(workOrder); - } - } - - if (model.SignOffAttachment != null) - { - var fileUrl = await SaveFileAsync(model.SignOffAttachment); - var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); - if (workOrder != null) - { - workOrder.SignOffAttachment = fileUrl; - await _workOrderDataService.UpdateAsync(workOrder); - } - } - + var workOrderId = await _workOrderService.CreateWorkOrderWithDetailsAsync(input, userId!); return Ok(new DataResponse { Message = "Work order created successfully", Status = "200", Data = workOrderId }); } catch (ValidationException vex) @@ -166,7 +64,6 @@ namespace Api.SeaHavenIndustries.Controllers } } - [HttpPost] [HttpPut("{id:int}")] [Route("EditWorkorder")] @@ -178,82 +75,29 @@ namespace Api.SeaHavenIndustries.Controllers return BadRequest(new Response { Status = "Error", Message = "Work order ID is required" }); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - - var updateDto = new UpdateWorkOrderDTO + var input = new UpdateWorkOrderWithDetailsInput { + Id = model.Id.Value, WorkerOrderTitle = model.WorkerOrderTitle, Description = model.Description, - Status = model.Status, - Priority = model.Priority, + Status = model.Status?.ToString(), + Priority = model.Priority?.ToString(), LocationId = model.LocationId, AssignTo = model.AssignTo, DueDate = model.DueDate, - CompletedDate = model.CompletedDate + CompletedDate = model.CompletedDate, + ContactIds = model.ContactIds, + CategoryIds = model.CategoryIds, + Attachments = model.Attachments, + BeforPhotoAttachment = model.BeforPhotoAttachment, + AfterPhotoAttachment = model.AfterPhotoAttachment, + SignOffAttachment = model.SignOffAttachment }; - var success = await _workOrderService.UpdateWorkOrderAsync(model.Id.Value, updateDto, userId!); + var success = await _workOrderService.UpdateWorkOrderWithDetailsAsync(input, userId!); if (!success) return BadRequest(new Response { Status = "Error", Message = "Work order not found" }); - await _workOrderDataService.DeleteWorkOrderContactsAsync(model.Id.Value); - await _workOrderDataService.DeleteWorkOrderCategoriesAsync(model.Id.Value); - - if (model.ContactIds != null && model.ContactIds.Any()) - { - await _workOrderDataService.AddWorkOrderContactsAsync(model.Id.Value, model.ContactIds); - } - - if (model.CategoryIds != null && model.CategoryIds.Any()) - { - await _workOrderDataService.AddWorkOrderCategoriesAsync(model.Id.Value, model.CategoryIds); - } - - if (model.Attachments != null) - { - foreach (var attachment in model.Attachments) - { - var fileUrl = await SaveFileAsync(attachment); - await _workOrderDataService.AddWorkOrderAttachmentAsync(new WorkOrderAttachments - { - WorkorderId = model.Id.Value, - Attachments = fileUrl - }); - } - } - - if (model.BeforPhotoAttachment != null) - { - var fileUrl = await SaveFileAsync(model.BeforPhotoAttachment); - var workOrder = await _workOrderDataService.GetByIdAsync(model.Id.Value); - if (workOrder != null) - { - workOrder.BeforPhotoAttachment = fileUrl; - await _workOrderDataService.UpdateAsync(workOrder); - } - } - - if (model.AfterPhotoAttachment != null) - { - var fileUrl = await SaveFileAsync(model.AfterPhotoAttachment); - var workOrder = await _workOrderDataService.GetByIdAsync(model.Id.Value); - if (workOrder != null) - { - workOrder.AfterPhotoAttachment = fileUrl; - await _workOrderDataService.UpdateAsync(workOrder); - } - } - - if (model.SignOffAttachment != null) - { - var fileUrl = await SaveFileAsync(model.SignOffAttachment); - var workOrder = await _workOrderDataService.GetByIdAsync(model.Id.Value); - if (workOrder != null) - { - workOrder.SignOffAttachment = fileUrl; - await _workOrderDataService.UpdateAsync(workOrder); - } - } - return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (ValidationException vex) @@ -266,21 +110,12 @@ namespace Api.SeaHavenIndustries.Controllers return BadRequest(new Response { Status = "Error", Message = ex.Message }); } } - // ----------------------------------- - // GET: api/WorkOrder/GetWorkOrderList - // ----------------------------------- + [HttpGet("GetWorkOrderList")] public async Task GetWorkOrderList(string? search = "", int page = 1, int pageSize = 12, string? sortBy = "lastUpdated", string? sortDir = "desc", string? status = "", string? assignee = "", int? locationId = null) { - var (data, totalCount) = await _workOrderDataService.GetWorkOrderListPagedAsync( - page, - pageSize, - search, - status, - assignee, - locationId, - sortBy, - sortDir); + var (data, totalCount) = await _workOrderService.GetWorkOrderListPagedAsync( + page, pageSize, search, status, assignee, locationId, sortBy, sortDir); var viewModel = new Pagination_DTO { @@ -294,32 +129,22 @@ namespace Api.SeaHavenIndustries.Controllers return Ok(viewModel); } - [HttpDelete] [Route("DeleteWorkorder")] public async Task Deleteworkorder([FromBody] EditWorkorder_DTO model) { int id = model.Id.HasValue ? model.Id.Value : 0; if (id == 0) - { return BadRequest("Id is required"); - } + try { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var success = await _workOrderService.DeleteWorkOrderAsync(id, userId!); + var success = await _workOrderService.DeleteWorkOrderCascadeAsync(id, userId!); if (!success) return BadRequest("Work order not found!"); - await _quotesDataService.UnlinkFromWorkOrderAsync(id); - - var comments = await _commentDataService.GetByWorkOrderIdAsync(id); - foreach (var comment in comments) - { - await _commentDataService.DeleteAsync(comment.Id); - } - return Ok(new DataResponse { Message = "Removed Successfully", Status = "200" }); } catch (Exception ex) @@ -330,106 +155,26 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet] [Route("GetFilteredWorkorder")] - public IActionResult GetFilteredWorkorder( - [FromQuery] string[] assignto, - [FromQuery] int[]? location, - [FromQuery] string[]? priority, - [FromQuery] string[]? status, - [FromQuery] string[]? duedate, - [FromQuery] string search = "", - [FromQuery] string sort = "Sort Order", - [FromQuery] string sortby = "Sort By", - int page = 1, - int pageSize = 10) + public async Task GetFilteredWorkorder( + [FromQuery] string[] assignto, + [FromQuery] int[]? location, + [FromQuery] string[]? priority, + [FromQuery] string[]? status, + [FromQuery] string[]? duedate, + [FromQuery] string search = "", + [FromQuery] string sort = "Sort Order", + [FromQuery] string sortby = "Sort By", + int page = 1, + int pageSize = 10) { try { - IQueryable query = _db.workOrders.Where(w => w.istemplate != true).Include(w => w.Locations); - - if (!string.IsNullOrEmpty(search)) - { - var s = search.ToLower(); - query = query.Where(w => - (w.WorkerOrderTitle ?? "").ToLower().Contains(s) || - (w.WorkerOrderNumber ?? "").ToLower().Contains(s) || - (w.Locations != null && (w.Locations.Title ?? "").ToLower().Contains(s))); - } - - if (assignto != null && assignto.Length > 0) - { - if (assignto[0] != "" && assignto[0] != null) - query = query.Where(w => assignto.Contains(w.AssignTo)); - } - - if (location != null && location.Length > 0) - { - if (location[0] != 0) - query = query.Where(w => location.Contains(w.LocationId ?? 0)); - } - - if (priority != null && priority.Length > 0) - { - if (priority[0] != "" && priority[0] != null) - query = query.Where(w => priority.Contains(w.Priority)); - } - - if (status != null && status.Length > 0) - { - if (status[0] != "" && status[0] != null) - query = query.Where(w => status.Contains(w.Status)); - } - var data = query.ToList(); - - // Perform client-side filtering for due dates - data = data.Where(w => - duedate == null || duedate.Length == 0 || duedate[0] == null || - duedate.Any(dd => w.DueDate.HasValue && CheckDueDate(dd, w.DueDate.Value.Date, DateTime.UtcNow.Date))).ToList(); - - // Apply sorting - if (!string.IsNullOrEmpty(sort) && !string.IsNullOrEmpty(sortby) && sortby != "") - { - switch (sortby) - { - case "Name": - data = (sort.ToLower() == "ascending") ? data.OrderBy(w => w.WorkerOrderTitle).ToList() : data.OrderByDescending(w => w.WorkerOrderTitle).ToList(); - break; - case "Due Date": - data = (sort.ToLower() == "ascending") ? data.OrderBy(w => w.DueDate).ToList() : data.OrderByDescending(w => w.DueDate).ToList(); - break; - case "Severity": - data = (sort.ToLower() == "ascending") ? data.OrderBy(w => w.Priority).ToList() : data.OrderByDescending(w => w.Priority).ToList(); - break; - default: - // Handle default case or throw an exception - break; - } - } - - var totalCount = data.Count(); - var pagedData = data - .Skip((page - 1) * pageSize) - .Take(pageSize) - .Select(s => new { s.Id, s.WorkerOrderTitle, s.DueDate, s.Status, s.Priority, Name = s.Locations != null ? s.Locations.Name : "" }) - .OrderByDescending(d => d.Name) - .ToList(); - - var totalPages1 = (int)Math.Ceiling(totalCount / (double)pageSize); - - // Create a view model containing the paged items and pagination metadata - var viewModel = new Pagination_DTO - { - Data = pagedData, - PageNumber = page, - PageSize = pageSize, - TotalCount = totalCount, - TotalPages = totalPages1 - }; - - return Ok(viewModel); + var result = await _workOrderService.GetFilteredWorkOrdersAsync( + assignto, location, priority, status, duedate, search, sort, sortby, page, pageSize); + return Ok(result); } - catch (Exception ex) + catch (Exception) { - // Handle exception and return a default result or an error response return StatusCode(500, "Internal Server Error"); } } @@ -438,201 +183,44 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("GetWorkorderById")] public async Task GetWorkorderById(int id) { - var exist = await _db.workOrders.Include(w => w.Locations).Include(w => w.Comments).Include(w => w.Quotes).Include(w => w.Dispatches).ThenInclude(d => d.Vendor).Where(w => w.Id == id).Select(s => new - { - s.Id, - s.InternalWONumber, - s.WorkerOrderNumber, - title = s.WorkerOrderTitle, - s.Description, - s.CreatedDate, - s.DueDate, - s.ScheduledDate, - s.CompletedDate, - s.Status, - assignee = s.AssignToUser != null ? s.AssignToUser.FirstName : "", - location = s.Locations != null ? s.Locations.Name : "", - locationId = s.LocationId, - locationZip = s.Locations != null ? (s.Locations.Zip ?? ExtractZip(s.Locations.Address1)) : "", - priority = s.Priority, - s.Problem, - s.Trade, - s.SubTrade, - s.VendorNTE, - s.Source, - s.PO, - s.TT, - poc = s.WorkOrderContacts.Select(c => new { c.Id, name = c.POC != null ? (c.POC.FirstName + " " + c.POC.LastName).Trim() : "", email = c.POC != null ? c.POC.Email : "", phone = c.POC != null ? c.POC.PhoneNumber : "" }), - category = s.WorkOrderCategories.Select(c => new { c.Id, name = c.Category != null ? c.Category.Name : "" }), - Attachments = s.workOrderAttachments.Select(a => new { a.Id, attachment = a.Attachments }), - s.BeforPhotoIssue, - s.BeforPhotoAttachment, - s.AfterPhotoIssue, - s.AfterPhotoAttachment, - s.SignOffName, - s.SignOffAttachment, - s.SignOffSignature, - Comments = s.Comments.Select(c => new { c.Id, c.CreatedDate, c.Commenttext, c.Commenter, c.RecordType, c.CommentType, c.Documents, userName = c.ApplicationUser != null ? c.ApplicationUser.FirstName : "" }), - quotes = s.Quotes.Select(q => new { code = q.QuotId, q.Id }), - auditLog = _db.WorkOrderAuditLogs - .Where(a => a.WorkOrderId == s.Id) - .OrderByDescending(a => a.CreatedAt) - .Select(a => new { - a.Id, - a.FieldName, - a.OldValue, - a.NewValue, - a.Action, - a.CreatedAt, - userName = a.User != null ? a.User.FirstName + " " + a.User.LastName : "" - }).ToList(), - dispatches = s.Dispatches.Select(d => new { - d.Id, - d.DispatchNumber, - d.PONumber, - d.NTEAmount, - d.Description, - d.Status, - d.ScheduledDate, - d.CompletedDate, - d.DispatchedAt, - d.EmailSent, - vendorName = d.Vendor != null ? d.Vendor.CompanyName : "", - vendorEmail = d.Vendor != null ? d.Vendor.Email : "", - vendorId = d.VendorId - }).ToList() - }).FirstOrDefaultAsync(); - if (exist == null) - { - + var detail = await _workOrderService.GetWorkOrderDetailAsync(id); + if (detail == null) return BadRequest(new Response { Status = "Error", Message = "ID not found!" }); - } - else - { - return Ok(exist); - } + return Ok(detail); } + [HttpGet] [Route("GetFilteredWorkorder2")] - public IActionResult GetFilteredWorkorder2([FromQuery] string[] assignto, [FromQuery] int[]? location, [FromQuery] string[]? priority, [FromQuery] string[]? status, [FromQuery] string[]? duedate, [FromQuery] string search = "", [FromQuery] string sort = "Sort Order", [FromQuery] string sortby = "Sort By", int page = 1, int pageSize = 10) + public async Task GetFilteredWorkorder2( + [FromQuery] string[] assignto, + [FromQuery] int[]? location, + [FromQuery] string[]? priority, + [FromQuery] string[]? status, + [FromQuery] string[]? duedate, + [FromQuery] string search = "", + [FromQuery] string sort = "Sort Order", + [FromQuery] string sortby = "Sort By", + int page = 1, + int pageSize = 10) { try { - var totalCount = _db.workOrders.Where(w => w.istemplate != true).Count(); - if (sort == "Sort Order" && sortby == "Sort By" && assignto.Count() == 0 && (duedate == null || duedate.Count() == 0) && location.Count() == 0 && (priority == null || priority.Count() == 0) && (status == null || status.Count() == 0) && search == "") + var result = await _workOrderService.GetFilteredWorkOrders2Async( + assignto, location, priority, status, duedate, search, sort, sortby, page, pageSize); + return Ok(result); + } + catch (Exception) + { + var totalCount = await _workOrderService.GetTotalWorkOrderCountAsync(); + return Ok(new Pagination_DTO { - var data1 = _db.workOrders.Where(w => w.istemplate != true).Include(w => w.Locations).ToListAsync(); - return Ok(data1); - } - //if (sort == "Sort Order" && sortby == "Sort By" && assignto.Count() == 0 && (duedate == null || duedate.Count() == 0) && location.Count() == 0 && (priority == null || priority.Count() == 0) && (status == null || status.Count() == 0) && search == "") - //{ - // var data1 = _db.workOrders.Where(w => w.istemplate != true).Include(w => w.Locations).Select(s => new {s.Id, s.WorkerOrderTitle, s.DueDate, s.Status, s.Priority, s.Locations.Name }).OrderByDescending(d => d.Name).ToList().Skip((page - 1) * pageSize) - // .Take(pageSize); - - // // Calculate the number of pages - // var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); - - // // Create a view model containing the paged items and pagination metadata - // var viewModel1 = new Pagination_DTO - // { - // Data = data1, - // PageNumber = page, - // PageSize = pageSize, - // TotalCount = totalCount, - // TotalPages = totalPages - // }; - // return Ok(viewModel1); - //} - - var currentDate = DateTime.UtcNow.Date; - var data = _db.workOrders.Include(w => w.Locations) - .Where(w => w.istemplate != true && (((w.WorkerOrderTitle ?? "").ToLower().Contains(search.ToLower()) || (w.WorkerOrderNumber ?? "").ToLower().Contains(search.ToLower()) || (w.Locations != null && (w.Locations.Title ?? "").ToLower().Contains(search.ToLower()))) && - (assignto.Count() == 0 || assignto.Contains(w.AssignTo)) && - (location.Count() == 0 || (w.LocationId.HasValue && location.Contains(w.LocationId.Value))) && - (priority.Count() == 0 || priority.Contains(w.Priority)) && - ((status.Count() == 0 || status.Contains(w.Status)) - ))) - .ToList(); - - // Perform client-side filtering for due dates - data = data.Where(w => - duedate.Count() == 0 || - duedate.Any(dd => w.DueDate.HasValue && CheckDueDate(dd, w.DueDate.Value.Date, currentDate))).ToList(); - - // Apply sorting - if (!string.IsNullOrEmpty(sort) && !string.IsNullOrEmpty(sortby) && sortby != "") - { - switch (sortby) - { - case "Name": - data = (sort.ToLower() == "ascending") ? data.OrderBy(w => w.WorkerOrderTitle).ToList() : data.OrderByDescending(w => w.WorkerOrderTitle).ToList(); - break; - case "Due Date": - data = (sort.ToLower() == "ascending") ? data.OrderBy(w => w.DueDate).ToList() : data.OrderByDescending(w => w.DueDate).ToList(); - break; - case "Severity": - data = (sort.ToLower() == "ascending") ? data.OrderBy(w => w.Priority).ToList() : data.OrderByDescending(w => w.Priority).ToList(); - break; - default: - // Handle default case or throw an exception - break; - } - } - var newdata = data.Select(s => new { s.Id, s.WorkerOrderTitle, s.DueDate, s.Status, s.Priority, Name = s.Locations != null ? s.Locations.Name : "" }).OrderByDescending(d => d.Name).ToList().Skip((page - 1) * pageSize) - .Take(pageSize); - var totalPages1 = (int)Math.Ceiling(totalCount / (double)pageSize); - - // Create a view model containing the paged items and pagination metadata - var viewModel = new Pagination_DTO - { - Data = newdata, + Data = Array.Empty(), PageNumber = page, PageSize = pageSize, TotalCount = totalCount, - TotalPages = totalPages1 - }; - return Ok(viewModel); + TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) + }); } - catch (Exception ex) - { - var totalCount = _db.workOrders.Where(w => w.istemplate != true).Count(); - var data1 = _db.workOrders.Where(w => w.istemplate != true).Include(w => w.Locations).Select(s => new - { - s.Id, - s.WorkerOrderTitle, - s.DueDate, - s.Status, - s.Priority, - Name = s.Locations != null ? s.Locations.Name : "" - }).OrderByDescending(d => d.Id).ToList().Skip((page - 1) * pageSize) - .Take(pageSize); - - // Calculate the number of pages - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); - - // Create a view model containing the paged items and pagination metadata - var viewModel = new Pagination_DTO - { - Data = data1, - PageNumber = page, - PageSize = pageSize, - TotalCount = totalCount, - TotalPages = totalPages - }; - return Ok(viewModel); - } - } - bool CheckDueDate(string dd, DateTime dueDate, DateTime currentDate) - { - return dd switch - { - "Today" => dueDate == currentDate, - "Tomorrow" => dueDate == currentDate.AddDays(1), - "Next Week" => dueDate >= currentDate.AddDays(1) && dueDate <= currentDate.AddDays(7), - "Next Month" => dueDate >= currentDate.AddDays(1) && dueDate <= currentDate.AddMonths(1), - "Over Due" => dueDate < currentDate, - _ => false - }; } [HttpPost] @@ -641,30 +229,12 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var workOrder = await _workOrderDataService.GetByIdAsync(id); - if (workOrder == null) + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _workOrderService.ChangeStatusAsync(id, status, userId!); + if (result == null) return BadRequest(new Response { Status = "Error", Message = "Work order not found" }); - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - - if (workOrder.Status != status) - { - await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog - { - WorkOrderId = id, - UserId = userId, - FieldName = "Status", - OldValue = workOrder.Status ?? "", - NewValue = status, - Action = AuditAction.StatusChange.ToString(), - CreatedAt = DateTime.UtcNow - }); - } - - workOrder.Status = status; - await _workOrderDataService.UpdateAsync(workOrder); - - return Ok(new DataResponse { Message = "Updated Successfully", Status = "200", Data = WorkOrderDTO.MapToModel(workOrder) }); + return Ok(new DataResponse { Message = "Updated Successfully", Status = "200", Data = result }); } catch (Exception ex) { @@ -678,35 +248,12 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var workOrder = await _workOrderDataService.GetByIdAsync(id); - if (workOrder == null) + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _workOrderService.ChangeAssignmentAsync(id, assignTo, userId!); + if (result == null) return BadRequest(new Response { Status = "Error", Message = "Work order not found" }); - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - - if (workOrder.AssignTo != assignTo) - { - var oldUser = workOrder.AssignTo != null ? await _userDataService.GetByIdAsync(workOrder.AssignTo) : null; - var newUser = assignTo != null ? await _userDataService.GetByIdAsync(assignTo) : null; - - await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog - { - WorkOrderId = id, - UserId = userId, - FieldName = "AssignedTo", - OldValue = oldUser != null ? (oldUser.FirstName + " " + oldUser.LastName).Trim() : "", - NewValue = newUser != null ? (newUser.FirstName + " " + newUser.LastName).Trim() : "Unassigned", - Action = AuditAction.AssignmentChange.ToString(), - CreatedAt = DateTime.UtcNow - }); - } - - workOrder.AssignTo = assignTo; - await _workOrderDataService.UpdateAsync(workOrder); - - var assignedUser = assignTo != null ? await _userDataService.GetByIdAsync(assignTo) : null; - var assignedName = assignedUser != null ? (assignedUser.FirstName + " " + assignedUser.LastName).Trim() : ""; - return Ok(new { message = "Updated Successfully", assignedTo = assignedName }); + return Ok(result); } catch (Exception ex) { @@ -720,23 +267,16 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var userId = _httpContext?.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? ""; - - var comment = new Comments + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var input = new AddCommentInput { - UserId = userId, - WorkerOrderId = model.WorkorderId, - Commenttext = model.Text, - CommentType = model.CommentType ?? "vendor" + WorkorderId = model.WorkorderId, + Text = model.Text, + CommentType = model.CommentType, + Document = model.Document }; - if (model.Document != null) - { - var fileUrl = await SaveFileAsync(model.Document); - comment.Documents = fileUrl; - } - - var saved = await _commentDataService.AddAsync(comment); + var saved = await _workOrderService.AddCommentAsync(input, userId!); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200", Data = saved }); } catch (Exception ex) @@ -751,26 +291,16 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var userId = _httpContext?.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? ""; - var comment = new Comments + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var input = new AddCommentInput { - UserId = userId, - WorkerOrderId = model.WorkorderId, - Commenttext = model.Text, - CommentType = model.CommentType ?? "internal", + WorkorderId = model.WorkorderId, + Text = model.Text, + CommentType = model.CommentType }; - var saved = await _commentDataService.AddAsync(comment); - var user = await _userDataService.GetByIdAsync(userId); - - return Ok(new - { - saved.Id, - saved.CreatedDate, - saved.Commenttext, - saved.CommentType, - userName = user != null ? (user.FirstName + " " + user.LastName).Trim() : "" - }); + var result = await _workOrderService.AddCommentJsonAsync(input, userId!); + return Ok(result); } catch (Exception ex) { @@ -782,14 +312,7 @@ namespace Api.SeaHavenIndustries.Controllers [Route("GetComments")] public async Task GetComments() { - var comments = await _commentDataService.GetAllAsync(); - var data = comments.Select(s => new - { - s.Id, - s.Commenttext, - FirstName = s.ApplicationUser != null ? s.ApplicationUser.FirstName : "", - s.Documents - }).ToList(); + var data = await _workOrderService.GetCommentsAsync(); return Ok(data); } @@ -797,176 +320,48 @@ namespace Api.SeaHavenIndustries.Controllers [Route("GetCommentsByWorkorderId")] public async Task GetCommentsByWorkorderId(int woid) { - var comments = await _commentDataService.GetByWorkOrderIdAsync(woid); - var data = comments.Select(s => new - { - s.Id, - s.Commenttext, - FirstName = s.ApplicationUser != null ? s.ApplicationUser.FirstName : "", - s.Documents - }).ToList(); + var data = await _workOrderService.GetCommentsByWorkorderIdAsync(woid); return Ok(data); } + [HttpGet] [Route("GetworkordersDD")] public async Task GetworkordersDD() { - var data = await _workOrderDataService.GetAllAsync(); - return Ok(data.Where(w => w.istemplate != true)); + var data = await _workOrderService.GetWorkordersDDAsync(); + return Ok(data); } + [HttpGet] [Route("Getworkorders")] public async Task Getworkorders() { - var workOrders = await _workOrderDataService.GetAllWithDetailsAsync(); - var data = workOrders.Select(s => new WorkorderFilterVM - { - Id = s.Id, - Priority = s.Priority, - Status = s.Status, - WorkerOrderNumber = s.WorkerOrderNumber, - WorkerOrderTitle = s.WorkerOrderTitle, - DueDate = s.DueDate, - AssignToUserName = s.AssignToUser != null ? s.AssignToUser.FirstName : "", - LocationName = s.Locations != null ? s.Locations.Name : "", - LocationId = s.LocationId, - PO = s.PO, - TT = s.TT, - }).ToList(); + var data = await _workOrderService.GetWorkordersAsync(); return Ok(data); } + [HttpPost] [Route("DispatchToVendor")] public async Task DispatchToVendor([FromBody] Dispatch_DTO model) { try { - var allWorkOrders = await _workOrderDataService.GetAllWithDetailsAsync(); - var workOrders = allWorkOrders.Where(w => model.WorkOrderIds.Contains(w.Id)).ToList(); - - if (workOrders.Count == 0) - return BadRequest(new Response { Status = "Error", Message = "No work orders found" }); - - var primaryWO = workOrders.First(); - - var lastPO = await _dispatchDataService.GenerateNextPONumberAsync(); - int nextPO = int.TryParse(lastPO, out var po) ? po : 1; - - var lastDSP = await _dispatchDataService.GenerateNextDispatchNumberAsync(); - int nextDSP = lastDSP.StartsWith("DSP-") && int.TryParse(lastDSP.Substring(4), out var dspNum) ? dspNum : 1; - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var currentUser = userId != null ? await _userDataService.GetByIdAsync(userId) : null; - var senderName = currentUser != null ? (currentUser.FirstName + " " + currentUser.LastName).Trim() : ""; - var dispatches = new List(); - foreach (var vendorId in model.VendorIds) + var input = new DispatchToVendorInput { - var vendor = await _vendorDataService.GetByIdAsync(vendorId); - if (vendor == null) continue; + WorkOrderIds = model.WorkOrderIds, + VendorIds = model.VendorIds, + NTEAmount = model.NTEAmount, + Description = model.Description, + ScheduledDate = model.ScheduledDate, + TaskListTemplateId = model.TaskListTemplateId, + CustomChecklistItems = model.CustomChecklistItems, + UserId = userId + }; - var poNumber = $"VPO-{nextPO:D5}"; - nextPO++; - var dispatchNumber = $"DSP-{nextDSP:D5}"; - nextDSP++; - - var replyTo = $"wo-{primaryWO.InternalWONumber}-{dispatchNumber}@int.seahaven.com"; - - var dispatch = new Dispatch - { - WorkOrderId = primaryWO.Id, - VendorId = vendorId, - DispatchNumber = dispatchNumber, - PONumber = poNumber, - NTEAmount = model.NTEAmount, - Description = model.Description, - Status = "Sent", - ScheduledDate = model.ScheduledDate, - DispatchedAt = DateTime.UtcNow, - ReplyToAddress = replyTo, - DispatchWorkOrders = workOrders.Select(wo => new DispatchWorkOrder { WorkOrderId = wo.Id }).ToList() - }; - - var checklistItems = new List(); - if (model.TaskListTemplateId.HasValue) - { - var template = await _taskListTemplateDataService.GetByIdWithItemsAsync(model.TaskListTemplateId.Value); - if (template?.Items != null) - { - checklistItems.AddRange(template.Items.OrderBy(i => i.SortOrder).Select((i, idx) => - new DispatchChecklistItem { ItemText = i.ItemText, SortOrder = idx })); - } - } - if (model.CustomChecklistItems != null) - { - var startIdx = checklistItems.Count; - checklistItems.AddRange(model.CustomChecklistItems.Select((text, idx) => - new DispatchChecklistItem { ItemText = text, SortOrder = startIdx + idx })); - } - dispatch.ChecklistItems = checklistItems; - - var woListHtml = string.Join("", workOrders.Select(wo => - $"{System.Net.WebUtility.HtmlEncode(wo.InternalWONumber ?? "")}" + - $"{System.Net.WebUtility.HtmlEncode(wo.WorkerOrderTitle ?? "")}" + - $"{System.Net.WebUtility.HtmlEncode(wo.Priority ?? "")}" + - $"{wo.DueDate?.ToString("yyyy-MM-dd") ?? "N/A"}")); - - var locationName = System.Net.WebUtility.HtmlEncode(primaryWO.Locations?.Name ?? ""); - var locationAddr = System.Net.WebUtility.HtmlEncode(primaryWO.Locations?.Address1 ?? ""); - var subject = $"[{dispatchNumber}] {(workOrders.Count > 1 ? $"{workOrders.Count} Work Orders" : primaryWO.WorkerOrderTitle)} at {primaryWO.Locations?.Name ?? ""}"; - var descriptionHtml = System.Net.WebUtility.HtmlEncode(model.Description ?? primaryWO.Description ?? ""); - var senderNameHtml = System.Net.WebUtility.HtmlEncode(senderName); - - var vendorToken = await _vendorTokens.GetOrCreateActiveTokenAsync(vendorId, HttpContext.RequestAborted); - var frontendBase = _config.GetValue("FrontendBaseUrl")?.TrimEnd('/') ?? ""; - var portalUrl = $"{frontendBase}/v/{vendorToken.Token}/dashboard"; - - var html = $@" -

Work Order Dispatch — {dispatchNumber}

-

Location: {locationName} {locationAddr}

-

Vendor PO: {poNumber}   NTE: ${model.NTEAmount?.ToString("F2") ?? "N/A"}

-

Work Orders

- - - - - - - - {woListHtml} -
WO #TitlePriorityDue Date
-

Description

-

{descriptionHtml}

-
- -

{senderNameHtml}
Sea Haven Industries

-

Reply to this email to communicate about this dispatch.

"; - - var emailSent = await _sendMessage.SendDispatchEmail(vendor.Email, subject, html, replyTo); - dispatch.EmailSent = emailSent; - - var savedDispatch = await _dispatchDataService.AddAsync(dispatch); - - foreach (var wo in workOrders) - { - await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog - { - WorkOrderId = wo.Id, - UserId = userId, - FieldName = "Dispatch", - OldValue = "", - NewValue = $"{vendor.CompanyName} ({dispatchNumber})", - Action = AuditAction.Dispatch.ToString(), - CreatedAt = DateTime.UtcNow - }); - } - - dispatches.Add(new { savedDispatch.Id, dispatchNumber, poNumber, vendorName = vendor.CompanyName, savedDispatch.Status, workOrderCount = workOrders.Count }); - } - - return Ok(new { message = "Dispatched", dispatches }); + var result = await _dispatchService.DispatchToVendorAsync(input); + return Ok(result); } catch (Exception ex) { @@ -977,97 +372,14 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("GetDispatches/{workOrderId}")] public async Task GetDispatches(int workOrderId) { - var dispatches = await _dispatchDataService.GetByWorkOrderIdAsync(workOrderId); - var data = dispatches.OrderByDescending(d => d.DispatchedAt).Select(d => new - { - d.Id, - d.DispatchNumber, - d.PONumber, - d.NTEAmount, - d.Description, - d.Status, - d.ScheduledDate, - d.CompletedDate, - d.DispatchedAt, - d.EmailSent, - vendorName = d.Vendor != null ? d.Vendor.CompanyName : "", - vendorEmail = d.Vendor != null ? d.Vendor.Email : "", - vendorId = d.VendorId - }).ToList(); + var data = await _dispatchService.GetDispatchesAsync(workOrderId); return Ok(data); } [HttpGet("GetDispatch/{id}")] public async Task GetDispatchById(int id) { - var d = await _db.Dispatches - .Include(x => x.Vendor) - .Include(x => x.WorkOrder) - .Where(x => x.Id == id) - .Select(x => new - { - x.Id, - x.DispatchNumber, - x.PONumber, - x.NTEAmount, - x.Description, - x.Status, - x.ScheduledDate, - x.CompletedDate, - x.DispatchedAt, - x.EmailSent, - x.WorkOrderId, - x.AcknowledgedAt, - x.VerifiedBy, - x.VerifiedAt, - woNumber = x.WorkOrder != null ? x.WorkOrder.InternalWONumber : "", - woTitle = x.WorkOrder != null ? x.WorkOrder.WorkerOrderTitle : "", - vendorName = x.Vendor != null ? x.Vendor.CompanyName : "", - vendorEmail = x.Vendor != null ? x.Vendor.Email : "", - vendorId = x.VendorId, - workOrders = _db.DispatchWorkOrders - .Where(dwo => dwo.DispatchId == x.Id) - .Select(dwo => new { - dwo.WorkOrderId, - woNumber = dwo.WorkOrder != null ? dwo.WorkOrder.InternalWONumber : "", - woTitle = dwo.WorkOrder != null ? dwo.WorkOrder.WorkerOrderTitle : "" - }).ToList(), - checklistItems = _db.DispatchChecklistItems - .Where(ci => ci.DispatchId == x.Id) - .OrderBy(ci => ci.SortOrder) - .Select(ci => new { - ci.Id, - ci.ItemText, - ci.IsCompleted, - ci.CompletedBy, - ci.CompletedAt, - ci.SortOrder, - ci.WorkOrderId - }).ToList(), - signoffs = _db.DispatchSignoffs - .Where(s => s.DispatchId == x.Id) - .Select(s => new { - s.Id, - s.SignoffType, - s.Name, - s.Signature, - s.SignatureMethod, - s.SignedAt - }).ToList(), - comments = _db.Comments - .Where(c => c.DispatchId == x.Id) - .OrderBy(c => c.CreatedDate) - .Select(c => new - { - c.Id, - c.CreatedDate, - c.Commenttext, - c.Commenter, - c.CommentType, - userName = c.ApplicationUser != null ? c.ApplicationUser.FirstName + " " + c.ApplicationUser.LastName : "" - }).ToList() - }).FirstOrDefaultAsync(); - + var d = await _dispatchService.GetDispatchDetailAsync(id); if (d == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); return Ok(d); @@ -1078,36 +390,22 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var dispatch = await _db.Dispatches.FindAsync(model.Id); - if (dispatch == null) + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var input = new UpdateDispatchInput + { + Id = model.Id, + Status = model.Status, + NTEAmount = model.NTEAmount, + ScheduledDate = model.ScheduledDate, + CompletedDate = model.CompletedDate, + Description = model.Description, + UserId = userId + }; + + var success = await _dispatchService.UpdateDispatchAsync(input); + if (!success) return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - - if (dispatch.Status != model.Status) - { - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog - { - WorkOrderId = dispatch.WorkOrderId ?? 0, - UserId = userId, - FieldName = $"Dispatch {dispatch.DispatchNumber} Status", - OldValue = dispatch.Status ?? "", - NewValue = model.Status ?? "", - Action = "dispatch_status_change", - CreatedAt = DateTime.UtcNow - }); - } - - dispatch.Status = model.Status; - dispatch.NTEAmount = model.NTEAmount; - dispatch.ScheduledDate = model.ScheduledDate; - dispatch.CompletedDate = model.CompletedDate; - dispatch.Description = model.Description; - - if (model.Status == "Completed" && dispatch.CompletedDate == null) - dispatch.CompletedDate = DateTime.UtcNow; - - await _db.SaveChangesAsync(); return Ok(new { message = "Dispatch updated" }); } catch (Exception ex) @@ -1121,42 +419,20 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var dispatch = await _db.Dispatches.Include(d => d.Vendor).Include(d => d.WorkOrder) - .FirstOrDefaultAsync(d => d.Id == model.DispatchId); - if (dispatch == null) + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var input = new AddDispatchCommentInput + { + DispatchId = model.DispatchId, + Text = model.Text, + SendEmail = model.SendEmail, + UserId = userId + }; + + var result = await _dispatchService.AddDispatchCommentAsync(input); + if (result == null) return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); - var userId = _httpContext?.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? ""; - var user = !string.IsNullOrEmpty(userId) ? await _db.Users.FindAsync(userId) : null; - var senderName = user != null ? (user.FirstName + " " + user.LastName).Trim() : ""; - - var comment = new Comments - { - CreatedDate = DateTime.UtcNow, - UserId = userId, - WorkerOrderId = dispatch.WorkOrderId, - DispatchId = dispatch.Id, - Commenttext = model.Text, - CommentType = "dispatcher", - Commenter = !string.IsNullOrWhiteSpace(senderName) ? senderName : null, - }; - _db.Comments.Add(comment); - await _db.SaveChangesAsync(); - - if (model.SendEmail && dispatch.Vendor?.Email != null && dispatch.WorkOrder != null) - { - var subject = $"[WO-{dispatch.WorkOrder.InternalWONumber}-{dispatch.DispatchNumber}] {dispatch.WorkOrder.WorkerOrderTitle}"; - var html = $"

{model.Text}


{senderName}
Sea Haven Industries


Reply to this email to respond.

"; - await _sendMessage.SendDispatchEmail(dispatch.Vendor.Email, subject, html, dispatch.ReplyToAddress); - } - return Ok(new - { - comment.Id, - comment.CreatedDate, - comment.Commenttext, - comment.CommentType, - userName = user != null ? (user.FirstName + " " + user.LastName).Trim() : "" - }); + return Ok(result); } catch (Exception ex) { @@ -1164,67 +440,21 @@ namespace Api.SeaHavenIndustries.Controllers } } - private static string? ExtractZip(string? address) - { - if (string.IsNullOrWhiteSpace(address)) return null; - var match = System.Text.RegularExpressions.Regex.Match(address, @"\b(\d{5})(?:-\d{4})?\b"); - return match.Success ? match.Groups[1].Value : null; - } - [HttpPost("VerifyDispatch")] public async Task VerifyDispatch(int dispatchId) { try { - var dispatch = await _db.Dispatches.FindAsync(dispatchId); - if (dispatch == null) + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _dispatchService.VerifyDispatchAsync(dispatchId, userId!); + if (result == null) return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); - var incompleteItems = await _db.DispatchChecklistItems - .Where(c => c.DispatchId == dispatchId && !c.IsCompleted) - .CountAsync(); - - var signoffCount = await _db.DispatchSignoffs - .Where(s => s.DispatchId == dispatchId) - .CountAsync(); - - var missing = new List(); - if (incompleteItems > 0) - missing.Add($"{incompleteItems} checklist item(s) incomplete"); - if (signoffCount < 2) - { - var existingTypes = await _db.DispatchSignoffs - .Where(s => s.DispatchId == dispatchId) - .Select(s => s.SignoffType) - .ToListAsync(); - if (!existingTypes.Contains("Customer")) missing.Add("Missing Customer sign-off"); - if (!existingTypes.Contains("Vendor")) missing.Add("Missing Vendor sign-off"); - } - - if (missing.Count > 0) - return BadRequest(new { status = "Error", message = "Cannot verify", missing }); - - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var user = userId != null ? await _db.Users.FindAsync(userId) : null; - var userName = user != null ? (user.FirstName + " " + user.LastName).Trim() : ""; - - dispatch.Status = "Verified"; - dispatch.VerifiedBy = userName; - dispatch.VerifiedAt = DateTime.UtcNow; - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog - { - WorkOrderId = dispatch.WorkOrderId ?? 0, - UserId = userId, - FieldName = $"Dispatch {dispatch.DispatchNumber}", - OldValue = dispatch.Status ?? "", - NewValue = "Verified", - Action = "dispatch_verified", - CreatedAt = DateTime.UtcNow - }); - - await _db.SaveChangesAsync(); - return Ok(new { message = "Dispatch verified", verifiedBy = userName, verifiedAt = dispatch.VerifiedAt }); + return Ok(result); + } + catch (DispatchVerificationException ex) + { + return BadRequest(new { status = "Error", message = "Cannot verify", missing = ex.Missing }); } catch (Exception ex) { @@ -1237,28 +467,24 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var dispatch = await _db.Dispatches.FindAsync(model.DispatchId); - if (dispatch == null) - return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); - - var existing = await _db.DispatchSignoffs - .AnyAsync(s => s.DispatchId == model.DispatchId && s.SignoffType == model.SignoffType); - if (existing) - return BadRequest(new Response { Status = "Error", Message = $"{model.SignoffType} sign-off already exists" }); - - var signoff = new DispatchSignoff + var input = new AddDispatchSignoffInput { DispatchId = model.DispatchId, SignoffType = model.SignoffType, Name = model.Name, Signature = model.Signature, - SignatureMethod = model.SignatureMethod, - SignedAt = DateTime.UtcNow + SignatureMethod = model.SignatureMethod }; - _db.DispatchSignoffs.Add(signoff); - await _db.SaveChangesAsync(); - return Ok(new { signoff.Id, signoff.SignoffType, signoff.Name, signoff.SignedAt, signoff.SignatureMethod }); + var result = await _dispatchService.AddDispatchSignoffAsync(input); + if (result == null) + return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); + + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); } catch (Exception ex) { @@ -1269,80 +495,32 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("UpdateChecklistItem")] public async Task UpdateChecklistItem([FromBody] ChecklistItemUpdate_DTO model) { - var item = await _dispatchDataService.GetChecklistItemByIdAsync(model.Id); - if (item == null) + var input = new ChecklistItemUpdateInput + { + Id = model.Id, + IsCompleted = model.IsCompleted, + CompletedBy = model.CompletedBy + }; + + var result = await _dispatchService.UpdateChecklistItemAsync(input); + if (result == null) return NotFound(new Response { Status = "Error", Message = "Checklist item not found" }); - item.IsCompleted = model.IsCompleted; - item.CompletedBy = model.IsCompleted ? model.CompletedBy : null; - item.CompletedAt = model.IsCompleted ? DateTime.UtcNow : null; - await _dispatchDataService.UpdateChecklistItemAsync(item); - return Ok(new { message = "Updated", item.Id, item.IsCompleted, item.CompletedBy, item.CompletedAt }); + return Ok(result); } [HttpPost("AddChecklistItem")] public async Task AddChecklistItem([FromBody] AddChecklistItem_DTO model) { - var checklistItems = await _dispatchDataService.GetChecklistItemsAsync(model.DispatchId); - var maxSort = checklistItems.Any() ? checklistItems.Max(c => c.SortOrder) : -1; - - var item = new DispatchChecklistItem + var input = new AddChecklistItemInput { DispatchId = model.DispatchId, WorkOrderId = model.WorkOrderId, - ItemText = model.ItemText, - SortOrder = maxSort + 1, - IsCompleted = false + ItemText = model.ItemText }; - await _dispatchDataService.AddChecklistItemAsync(item); - return Ok(new { item.Id, item.ItemText, item.SortOrder, item.IsCompleted }); - } - private async Task GenerateInternalWONumber() - { - return await _workOrderDataService.GenerateNextInternalWONumberAsync(); - } - - private async Task SaveFileAsync(IFormFile file) - { - var fileName = ContentDispositionHeaderValue.Parse(file.ContentDisposition).FileName.Trim('"'); - var uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; - var uploadPath = Path.Combine("Assets", "Documents"); - var fullPath = Path.Combine(_webHostEnvironment.WebRootPath, uploadPath, uniqueFileName); - - Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); - - using (var stream = System.IO.File.Create(fullPath)) - { - await file.CopyToAsync(stream); - } - - var request = _httpContext.HttpContext!.Request; - var domain = $"{request.Scheme}://{request.Host}"; - return $"{domain}/{uploadPath.Replace("\\", "/")}/{uniqueFileName}"; - } - - public class WorkorderFilterVM - { - public int? Id { get; set; } - public int? LocationId { get; set; } - public string? WorkerOrderNumber { get; set; } - public string? BillingAddress { get; set; } = "Amazon.com Services LLC 410 Terry Ave,North Seattle, Washington, 98109"; - - public string? WorkerOrderTitle { get; set; } - public string? LocationName { get; set; } - public string? AssignToUserName { get; set; } - public string? Priority { get; set; } - public string? Status { get; set; } - public DateTime? DueDate { get; set; } - public string FormattedDueDate - { - get { return DueDate.HasValue ? DueDate.Value.ToString("MM/dd/yyyy") : ""; } - } - public string? Dispatcher { get; set; } - public string? Scheduler { get; set; } - public string? PO { get; set; } - public string? TT { get; set; } + var result = await _dispatchService.AddChecklistItemAsync(input); + return Ok(result); } } } diff --git a/Api.SeaHavenIndustries/Infrastructure/DynamoSyncExternalSource.cs b/Api.SeaHavenIndustries/Infrastructure/DynamoSyncExternalSource.cs new file mode 100644 index 0000000..a9223b5 --- /dev/null +++ b/Api.SeaHavenIndustries/Infrastructure/DynamoSyncExternalSource.cs @@ -0,0 +1,90 @@ +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Infrastructure +{ + public class DynamoSyncExternalSource : ISyncExternalSource + { + private readonly AmazonDynamoDBClient _dynamo; + private const string WO_TABLE = "WorkOrders"; + private const string COMMENTS_TABLE = "WorkOrderComments"; + private const string VENDOR_REPLIES_TABLE = "VendorReplies"; + + public DynamoSyncExternalSource() + { + _dynamo = new AmazonDynamoDBClient(Amazon.RegionEndpoint.USEast1); + } + + public async IAsyncEnumerable> ScanWorkOrdersAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + Dictionary? lastKey = null; + do + { + cancellationToken.ThrowIfCancellationRequested(); + var request = new ScanRequest + { + TableName = WO_TABLE, + Limit = 100, + ExclusiveStartKey = lastKey + }; + var response = await _dynamo.ScanAsync(request); + foreach (var item in response.Items) + yield return ConvertItem(item); + lastKey = response.LastEvaluatedKey; + } while (lastKey != null && lastKey.Count > 0); + } + + public async IAsyncEnumerable> ScanCommentsAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + Dictionary? lastKey = null; + do + { + cancellationToken.ThrowIfCancellationRequested(); + var request = new ScanRequest + { + TableName = COMMENTS_TABLE, + Limit = 100, + ExclusiveStartKey = lastKey + }; + var response = await _dynamo.ScanAsync(request); + foreach (var item in response.Items) + yield return ConvertItem(item); + lastKey = response.LastEvaluatedKey; + } while (lastKey != null && lastKey.Count > 0); + } + + public async IAsyncEnumerable> ScanVendorRepliesAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + Dictionary? lastKey = null; + do + { + cancellationToken.ThrowIfCancellationRequested(); + var request = new ScanRequest + { + TableName = VENDOR_REPLIES_TABLE, + Limit = 100, + ExclusiveStartKey = lastKey + }; + var response = await _dynamo.ScanAsync(request); + foreach (var item in response.Items) + yield return ConvertItem(item); + lastKey = response.LastEvaluatedKey; + } while (lastKey != null && lastKey.Count > 0); + } + + private static Dictionary ConvertItem(Dictionary item) + { + var result = new Dictionary(); + foreach (var kvp in item) + { + if (kvp.Value.S != null) + result[kvp.Key] = kvp.Value.S; + } + return result; + } + } +} diff --git a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs new file mode 100644 index 0000000..02c6278 --- /dev/null +++ b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs @@ -0,0 +1,75 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Infrastructure +{ + public class FileStorageAdapter : IFileStoragePort + { + private readonly IWebHostEnvironment _webHostEnvironment; + private readonly IHttpContextAccessor _httpContext; + + public FileStorageAdapter(IWebHostEnvironment webHostEnvironment, IHttpContextAccessor httpContext) + { + _webHostEnvironment = webHostEnvironment; + _httpContext = httpContext; + } + + public async Task SaveFileAsync(IFormFile file) + { + var fileName = Path.GetFileName(file.FileName); + if (string.IsNullOrWhiteSpace(fileName)) + throw new InvalidDataException("The uploaded file must have a valid name."); + + var uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; + var uploadPath = Path.Combine("Assets", "Documents"); + var webRoot = _webHostEnvironment.WebRootPath + ?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot"); + var fullPath = Path.Combine(webRoot, uploadPath, uniqueFileName); + + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + + using (var stream = System.IO.File.Create(fullPath)) + { + await file.CopyToAsync(stream); + } + + var request = _httpContext.HttpContext?.Request + ?? throw new InvalidOperationException("An active HTTP request is required to build the file URL."); + var domain = $"{request.Scheme}://{request.Host}"; + return $"{domain}/{uploadPath.Replace("\\", "/")}/{uniqueFileName}"; + } + } + + public class DispatchEmailAdapter : IDispatchEmailPort + { + private readonly Helper.SendMessage _sendMessage; + + public DispatchEmailAdapter(Helper.SendMessage sendMessage) + { + _sendMessage = sendMessage; + } + + public async Task SendDispatchEmailAsync(string emailTo, string subject, string htmlBody, string? replyTo) + { + return await _sendMessage.SendDispatchEmail(emailTo, subject, htmlBody, replyTo); + } + } + + public class VendorTokenAdapter : IVendorTokenPort + { + private readonly IVendorPortalTokenService _tokenService; + + public VendorTokenAdapter(IVendorPortalTokenService tokenService) + { + _tokenService = tokenService; + } + + public async Task GetOrCreateActiveTokenAsync(int vendorId) + { + var token = await _tokenService.GetOrCreateActiveTokenAsync( + vendorId, CancellationToken.None); + return token.Token; + } + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index c3644e6..211429c 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -53,6 +53,10 @@ builder.Services.AddSingleton( builder.Services.AddDataServices(); builder.Services.AddBusinessServices(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddAuthentication(options => { diff --git a/SeaHaven.DataServices/Implementation/DispatchDataService.cs b/SeaHaven.DataServices/Implementation/DispatchDataService.cs index e5d60b6..0d5ad4d 100644 --- a/SeaHaven.DataServices/Implementation/DispatchDataService.cs +++ b/SeaHaven.DataServices/Implementation/DispatchDataService.cs @@ -2,6 +2,7 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Dto; using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Implementation { @@ -26,6 +27,14 @@ namespace SeaHaven.DataServices.Implementation .FirstOrDefaultAsync(d => d.Id == id); } + public async Task GetByIdWithVendorAndWorkOrderAsync(int id) + { + return await _context.Dispatches + .Include(dispatch => dispatch.Vendor) + .Include(dispatch => dispatch.WorkOrder) + .FirstOrDefaultAsync(dispatch => dispatch.Id == id); + } + public async Task> GetAllAsync() { return await _context.Dispatches.ToListAsync(); @@ -703,5 +712,90 @@ namespace SeaHaven.DataServices.Implementation { await _context.SaveChangesAsync(cancellationToken); } + + public async Task GetDispatchDetailAsync(int id) + { + return await _context.Dispatches + .AsNoTracking() + .Where(dispatch => dispatch.Id == id) + .Select(dispatch => new DispatchDetailReadModel + { + Id = dispatch.Id, + DispatchNumber = dispatch.DispatchNumber, + PONumber = dispatch.PONumber, + NTEAmount = dispatch.NTEAmount, + Description = dispatch.Description, + Status = dispatch.Status, + ScheduledDate = dispatch.ScheduledDate, + CompletedDate = dispatch.CompletedDate, + DispatchedAt = dispatch.DispatchedAt, + EmailSent = dispatch.EmailSent, + WorkOrderId = dispatch.WorkOrderId, + AcknowledgedAt = dispatch.AcknowledgedAt, + VerifiedBy = dispatch.VerifiedBy, + VerifiedAt = dispatch.VerifiedAt, + WoNumber = dispatch.WorkOrder != null + ? dispatch.WorkOrder.InternalWONumber + : "", + WoTitle = dispatch.WorkOrder != null + ? dispatch.WorkOrder.WorkerOrderTitle + : "", + VendorName = dispatch.Vendor != null ? dispatch.Vendor.CompanyName : "", + VendorEmail = dispatch.Vendor != null ? dispatch.Vendor.Email : "", + VendorId = dispatch.VendorId, + WorkOrders = _context.DispatchWorkOrders + .Where(link => link.DispatchId == dispatch.Id) + .Select(link => new DispatchWorkOrderReadModel + { + WorkOrderId = link.WorkOrderId, + WoNumber = link.WorkOrder != null + ? link.WorkOrder.InternalWONumber + : "", + WoTitle = link.WorkOrder != null + ? link.WorkOrder.WorkerOrderTitle + : "" + }).ToList(), + ChecklistItems = _context.DispatchChecklistItems + .Where(item => item.DispatchId == dispatch.Id) + .OrderBy(item => item.SortOrder) + .Select(item => new ChecklistItemReadModel + { + Id = item.Id, + ItemText = item.ItemText, + IsCompleted = item.IsCompleted, + CompletedBy = item.CompletedBy, + CompletedAt = item.CompletedAt, + SortOrder = item.SortOrder, + WorkOrderId = item.WorkOrderId + }).ToList(), + Signoffs = _context.DispatchSignoffs + .Where(signoff => signoff.DispatchId == dispatch.Id) + .Select(signoff => new SignoffReadModel + { + Id = signoff.Id, + SignoffType = signoff.SignoffType, + Name = signoff.Name, + Signature = signoff.Signature, + SignatureMethod = signoff.SignatureMethod, + SignedAt = signoff.SignedAt + }).ToList(), + Comments = _context.Comments + .Where(comment => comment.DispatchId == dispatch.Id) + .OrderBy(comment => comment.CreatedDate) + .Select(comment => new DispatchCommentReadModel + { + Id = comment.Id, + CreatedDate = comment.CreatedDate, + Commenttext = comment.Commenttext, + Commenter = comment.Commenter, + CommentType = comment.CommentType, + UserName = comment.ApplicationUser != null + ? comment.ApplicationUser.FirstName + " " + + comment.ApplicationUser.LastName + : "" + }).ToList() + }) + .FirstOrDefaultAsync(); + } } } diff --git a/SeaHaven.DataServices/Implementation/QuotesDataService.cs b/SeaHaven.DataServices/Implementation/QuotesDataService.cs index 44fc3f5..c0d4162 100644 --- a/SeaHaven.DataServices/Implementation/QuotesDataService.cs +++ b/SeaHaven.DataServices/Implementation/QuotesDataService.cs @@ -1,6 +1,7 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Implementation { @@ -71,5 +72,108 @@ namespace SeaHaven.DataServices.Implementation await _context.SaveChangesAsync(); } + + public async Task<(IReadOnlyList Items, int TotalCount)> GetQuotesPagedAsync( + int page, int pageSize, string search, CancellationToken cancellationToken) + { + var normalizedSearch = search.Trim(); + var query = _context.Quotes + .AsNoTracking() + .Where(w => normalizedSearch.Length == 0 || + (w.QuotId != null && w.QuotId.Contains(normalizedSearch)) || + (w.SiteName != null && w.SiteName.Contains(normalizedSearch)) || + (w.WorkOrder != null && w.WorkOrder.WorkerOrderTitle != null && + w.WorkOrder.WorkerOrderTitle.Contains(normalizedSearch))); + + var totalCount = await query.CountAsync(cancellationToken); + var data = await query + .OrderByDescending(s => s.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(s => new QuoteListItemReadModel + { + Id = s.Id, + QuoteId = "QO-" + s.QuotId, + WorkOrder = s.WorkOrder != null ? s.WorkOrder.WorkerOrderNumber : "", + ProjectName = s.ProjectName, + TotalAmount = s.LineItems!.Sum(l => l.Lineitem ?? 0), + SiteName = s.SiteName, + SiteId = s.SiteId, + PO = s.PO, + TT = s.TT, + BillingAddress = s.BillingAddress, + Status = s.Status, + ContactId = s.ContactId, + Date = s.Date, + Lineitems = s.LineItems!.Select(q => new QuoteLineItemReadModel + { + Descriptions = q.Descriptions, + Lineitem = q.Lineitem, + LineitemNumber = q.LineitemNumber + }).ToList(), + }) + .ToListAsync(cancellationToken); + + return (data, totalCount); + } + + public async Task GetQuoteDetailsByIdAsync( + int id, CancellationToken cancellationToken) + { + return await _context.Quotes + .Where(s => s.Id == id) + .AsNoTracking() + .Select(s => new QuoteDetailReadModel + { + Id = s.Id, + QuotId = s.QuotId, + Date = s.Date, + Name = s.Contacts != null ? (s.Contacts.FirstName + " " + s.Contacts.LastName).Trim() : "", + PO = s.PO, + TT = s.TT, + SiteName = s.SiteName, + ProjectName = s.ProjectName, + SiteId = s.SiteId, + BillingAddress = s.BillingAddress, + ShippingAddress = s.ShippingAddress, + Lineitems = s.LineItems!.Select(q => new QuoteLineItemReadModel + { + Descriptions = q.Descriptions, + Lineitem = q.Lineitem + }).ToList(), + TotalAmount = s.LineItems!.Sum(l => l.Lineitem ?? 0) + }) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task> GetWorkOrderQuoteDetailsAsync( + int woId, CancellationToken cancellationToken) + { + return await _context.workOrders + .AsNoTracking() + .Where(w => w.Id == woId) + .Select(s => new WorkOrderQuoteDetailReadModel + { + Id = s.Id, + PO = s.PO, + TT = s.TT, + Title = s.Locations != null ? s.Locations.Title : "", + Name = s.Locations != null ? s.Locations.Name : "" + }) + .ToListAsync(cancellationToken); + } + + public async Task DeleteLineItemsByQuoteIdAsync(int quoteId, CancellationToken cancellationToken) + { + var items = await _context.QuotesLines + .Where(w => w.QuoteId == quoteId) + .ToListAsync(cancellationToken); + + if (items.Any()) + { + _context.QuotesLines.RemoveRange(items); + await _context.SaveChangesAsync(cancellationToken); + } + } } } diff --git a/SeaHaven.DataServices/Implementation/SyncDataService.cs b/SeaHaven.DataServices/Implementation/SyncDataService.cs new file mode 100644 index 0000000..3ca083c --- /dev/null +++ b/SeaHaven.DataServices/Implementation/SyncDataService.cs @@ -0,0 +1,127 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class SyncDataService : ISyncDataService + { + private readonly ApplicationDbContext _context; + + public SyncDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task GetLastInternalWONumberAsync(CancellationToken cancellationToken) + { + return await _context.workOrders + .Where(w => w.InternalWONumber != null && w.InternalWONumber != "") + .OrderByDescending(w => w.InternalWONumber) + .Select(w => w.InternalWONumber) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task GetWorkOrderByExternalIdAsync(string externalId, CancellationToken cancellationToken) + { + return await _context.workOrders + .FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalId, cancellationToken); + } + + public void EnqueueWorkOrder(WorkOrder workOrder) + { + _context.workOrders.Add(workOrder); + } + + public void EnqueueComment(Comments comment) + { + _context.Comments.Add(comment); + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken) + { + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task ResolveLocationAsync(string? siteCode, string? building, string? address, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(siteCode) && string.IsNullOrWhiteSpace(building)) + return null; + + var matchCode = siteCode ?? building; + var existing = await _context.Locations + .FirstOrDefaultAsync(l => l.Name == matchCode || l.Title == matchCode, cancellationToken); + + if (existing != null) + return existing.Id; + + var location = new Locations + { + Name = matchCode, + Title = building, + Address1 = address, + Status = "Active" + }; + + _context.Locations.Add(location); + await _context.SaveChangesAsync(cancellationToken); + + return location.Id; + } + + public async Task GetLocationCountAsync(CancellationToken cancellationToken) + { + return await _context.Locations.CountAsync(cancellationToken); + } + + public async Task CommentExistsByExternalIdAsync(string externalId, CancellationToken cancellationToken) + { + return await _context.Comments + .AnyAsync(c => c.ExternalCommentId == externalId, cancellationToken); + } + + public async Task GetWorkOrderByInternalNumberAsync(string internalNumber, CancellationToken cancellationToken) + { + return await _context.workOrders + .FirstOrDefaultAsync(w => w.InternalWONumber == internalNumber, cancellationToken); + } + + public async Task GetDispatchByNumberAndWorkOrderIdAsync(string dispatchNumber, int workOrderId, CancellationToken cancellationToken) + { + return await _context.Dispatches + .FirstOrDefaultAsync(d => d.DispatchNumber == dispatchNumber && d.WorkOrderId == workOrderId, cancellationToken); + } + + public async Task> GetWorkOrdersMissingInternalNumberAsync(CancellationToken cancellationToken) + { + return await _context.workOrders + .Where(w => w.InternalWONumber == null || w.InternalWONumber == "") + .OrderBy(w => w.Id) + .ToListAsync(cancellationToken); + } + + public async Task> GetDispatchesMissingNumberAsync(CancellationToken cancellationToken) + { + return await _context.Dispatches + .Where(d => d.DispatchNumber == null || d.DispatchNumber == "") + .OrderBy(d => d.Id) + .ToListAsync(cancellationToken); + } + + public async Task ExecuteBackfillCommentTypesAsync(CancellationToken cancellationToken) + { + return await _context.Database.ExecuteSqlRawAsync( + "UPDATE Comments SET CommentType = 'customer' WHERE ExternalCommentId IS NOT NULL AND (CommentType IS NULL OR CommentType = '')", + cancellationToken); + } + + public async Task GetLastDispatchNumberAsync(CancellationToken cancellationToken) + { + return await _context.Dispatches + .Where(d => d.DispatchNumber != null && d.DispatchNumber != "") + .OrderByDescending(d => d.DispatchNumber) + .Select(d => d.DispatchNumber) + .FirstOrDefaultAsync(cancellationToken); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAuditDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAuditDataService.cs new file mode 100644 index 0000000..c057337 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderAuditDataService.cs @@ -0,0 +1,43 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderAuditDataService : IWorkOrderAuditDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderAuditDataService(ApplicationDbContext context) + { + _context = context; + } + + public void EnqueueAuditLog(WorkOrderAuditLog auditLog) + { + _context.WorkOrderAuditLogs.Add(auditLog); + } + + public async Task PersistAsync(CancellationToken cancellationToken) + { + try + { + await _context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex)) + { + foreach (var entry in ex.Entries.Where(e => e.Entity is WorkOrderFieldLock)) + entry.State = EntityState.Detached; + + await _context.SaveChangesAsync(cancellationToken); + } + } + + private static bool IsUniqueConstraintViolation(DbUpdateException ex) + { + var message = ex.InnerException?.Message ?? ex.Message; + return message.Contains("duplicate", StringComparison.OrdinalIgnoreCase) + || message.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs index 9509840..fe51046 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs @@ -1,6 +1,7 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Implementation { @@ -101,7 +102,7 @@ namespace SeaHaven.DataServices.Implementation return (items, totalCount); } - public async Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( + public async Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( int page, int pageSize, string? search = null, @@ -131,13 +132,12 @@ namespace SeaHaven.DataServices.Implementation if (!string.IsNullOrWhiteSpace(search)) { - var s = search.ToLower(); query = query.Where(w => - (w.InternalWONumber ?? "").ToLower().Contains(s) || - (w.WorkerOrderNumber ?? "").ToLower().Contains(s) || - (w.WorkerOrderTitle ?? "").ToLower().Contains(s) || - (w.Locations.Title ?? "").ToLower().Contains(s) || - (w.Locations.Name ?? "").ToLower().Contains(s) + (w.InternalWONumber != null && w.InternalWONumber.Contains(search)) || + (w.WorkerOrderNumber != null && w.WorkerOrderNumber.Contains(search)) || + (w.WorkerOrderTitle != null && w.WorkerOrderTitle.Contains(search)) || + (w.Locations != null && w.Locations.Title != null && w.Locations.Title.Contains(search)) || + (w.Locations != null && w.Locations.Name != null && w.Locations.Name.Contains(search)) ); } @@ -160,24 +160,24 @@ namespace SeaHaven.DataServices.Implementation var data = await ordered .Skip((page - 1) * pageSize) .Take(pageSize) - .Select(w => new + .Select(w => new WorkOrderListItemReadModel { - id = w.Id, - internalWONumber = w.InternalWONumber, - workOrderNumber = w.WorkerOrderNumber, - number = w.InternalWONumber ?? w.WorkerOrderNumber, - title = w.WorkerOrderTitle, - location = w.Locations != null ? w.Locations.Name : "", - locationName = w.Locations != null ? w.Locations.Name : "", - priority = w.Priority, - status = w.Status, - createdDate = w.CreatedDate, - dueDate = w.DueDate, - assignedTo = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "", - employeeName = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "", - lastUpdated = new[] { + Id = w.Id, + InternalWONumber = w.InternalWONumber, + WorkOrderNumber = w.WorkerOrderNumber, + Number = w.InternalWONumber ?? w.WorkerOrderNumber, + Title = w.WorkerOrderTitle, + Location = w.Locations != null ? w.Locations.Name : "", + LocationName = w.Locations != null ? w.Locations.Name : "", + Priority = w.Priority, + Status = w.Status, + CreatedDate = w.CreatedDate, + DueDate = w.DueDate, + AssignedTo = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "", + EmployeeName = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "", + LastUpdated = new[] { w.LastModificationTime, - w.Comments.Max(c => (DateTime?)c.CreatedDate), + w.Comments!.Max(c => (DateTime?)c.CreatedDate), _context.WorkOrderAuditLogs.Where(a => a.WorkOrderId == w.Id).Max(a => (DateTime?)a.CreatedAt) }.Where(d => d.HasValue).Max() }) @@ -349,6 +349,11 @@ namespace SeaHaven.DataServices.Implementation return await _context.workOrders.CountAsync(w => w.Status == status); } + public async Task CountByPriorityAsync(string priority) + { + return await _context.workOrders.CountAsync(w => w.Priority == priority); + } + public async Task CountByLocationAsync(int locationId) { return await _context.workOrders.CountAsync(w => w.LocationId == locationId); @@ -363,5 +368,121 @@ namespace SeaHaven.DataServices.Implementation .AsSplitQuery() .ToListAsync(); } + + public async Task GetWorkOrderDetailAsync(int id) + { + return await _context.workOrders + .AsNoTracking() + .Where(w => w.Id == id) + .Select(s => new WorkOrderDetailReadModel + { + Id = s.Id, + InternalWONumber = s.InternalWONumber, + WorkerOrderNumber = s.WorkerOrderNumber, + Title = s.WorkerOrderTitle, + Description = s.Description, + CreatedDate = s.CreatedDate, + DueDate = s.DueDate, + ScheduledDate = s.ScheduledDate, + CompletedDate = s.CompletedDate, + Status = s.Status, + Assignee = s.AssignToUser != null ? s.AssignToUser.FirstName : "", + Location = s.Locations != null ? s.Locations.Name : "", + LocationId = s.LocationId, + LocationZip = s.Locations != null ? (s.Locations.Zip ?? ExtractZip(s.Locations.Address1)) : "", + Priority = s.Priority, + Problem = s.Problem, + Trade = s.Trade, + SubTrade = s.SubTrade, + VendorNTE = s.VendorNTE, + Source = s.Source, + PO = s.PO, + TT = s.TT, + Poc = s.WorkOrderContacts!.Select(c => new ContactReadModel + { + Id = c.Id, + Name = c.POC != null ? (c.POC.FirstName + " " + c.POC.LastName).Trim() : "", + Email = c.POC != null ? c.POC.Email : "", + Phone = c.POC != null ? c.POC.PhoneNumber : "" + }).ToList(), + Category = s.WorkOrderCategories!.Select(c => new CategoryReadModel + { + Id = c.Id, + Name = c.Category != null ? c.Category.Name : "" + }).ToList(), + Attachments = s.workOrderAttachments!.Select(a => new AttachmentReadModel + { + Id = a.Id, + Attachment = a.Attachments + }).ToList(), + BeforPhotoIssue = s.BeforPhotoIssue, + BeforPhotoAttachment = s.BeforPhotoAttachment, + AfterPhotoIssue = s.AfterPhotoIssue, + AfterPhotoAttachment = s.AfterPhotoAttachment, + SignOffName = s.SignOffName, + SignOffAttachment = s.SignOffAttachment, + SignOffSignature = s.SignOffSignature, + Comments = s.Comments!.Select(c => new CommentReadModel + { + Id = c.Id, + CreatedDate = c.CreatedDate, + Commenttext = c.Commenttext, + Commenter = c.Commenter, + RecordType = c.RecordType, + CommentType = c.CommentType, + Documents = c.Documents, + UserName = c.ApplicationUser != null ? c.ApplicationUser.FirstName : "" + }).ToList(), + Quotes = s.Quotes!.Select(q => new QuoteRefReadModel + { + Code = q.QuotId, + Id = q.Id + }).ToList(), + AuditLog = _context.WorkOrderAuditLogs + .Where(a => a.WorkOrderId == s.Id) + .OrderByDescending(a => a.CreatedAt) + .Select(a => new AuditLogReadModel + { + Id = a.Id, + FieldName = a.FieldName, + OldValue = a.OldValue, + NewValue = a.NewValue, + Action = a.Action, + CreatedAt = a.CreatedAt, + UserName = a.User != null ? a.User.FirstName + " " + a.User.LastName : "" + }).ToList(), + Dispatches = s.Dispatches!.Select(d => new DispatchReadModel + { + Id = d.Id, + DispatchNumber = d.DispatchNumber, + PONumber = d.PONumber, + NTEAmount = d.NTEAmount, + Description = d.Description, + Status = d.Status, + ScheduledDate = d.ScheduledDate, + CompletedDate = d.CompletedDate, + DispatchedAt = d.DispatchedAt, + EmailSent = d.EmailSent, + VendorName = d.Vendor != null ? d.Vendor.CompanyName : "", + VendorEmail = d.Vendor != null ? d.Vendor.Email : "", + VendorId = d.VendorId + }).ToList() + }).FirstOrDefaultAsync(); + } + + public async Task> GetNonTemplateWorkOrdersWithLocationsAsync() + { + return await _context.workOrders + .Where(w => w.istemplate != true) + .Include(w => w.Locations) + .ToListAsync(); + } + + private static string? ExtractZip(string? address) + { + if (string.IsNullOrWhiteSpace(address)) return null; + var match = System.Text.RegularExpressions.Regex.Match(address, @"\b(\d{5})(?:-\d{4})?\b"); + return match.Success ? match.Groups[1].Value : null; + } } } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderFieldLockDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderFieldLockDataService.cs new file mode 100644 index 0000000..22792d0 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderFieldLockDataService.cs @@ -0,0 +1,44 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderFieldLockDataService : IWorkOrderFieldLockDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderFieldLockDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task IsLockedAsync(int workOrderId, string fieldName, CancellationToken cancellationToken) + { + if (IsTracked(workOrderId, fieldName)) + return true; + + return await _context.WorkOrderFieldLocks + .AnyAsync(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName, cancellationToken); + } + + public void EnqueueLock(int workOrderId, string fieldName, string? userId) + { + if (IsTracked(workOrderId, fieldName)) + return; + + _context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock + { + WorkOrderId = workOrderId, + FieldName = fieldName, + LockedAt = DateTime.UtcNow, + LockedByUserId = userId + }); + } + + private bool IsTracked(int workOrderId, string fieldName) + => _context.WorkOrderFieldLocks.Local.Any(l => + l.WorkOrderId == workOrderId && + string.Equals(l.FieldName, fieldName, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs b/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs index 5336898..efe7dcc 100644 --- a/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Interfaces { @@ -7,6 +8,7 @@ namespace SeaHaven.DataServices.Interfaces { Task GetByIdAsync(int id); Task GetByIdWithDetailsAsync(int id); + Task GetByIdWithVendorAndWorkOrderAsync(int id); Task> GetAllAsync(); Task> GetByVendorIdAsync(int vendorId); Task> GetByWorkOrderIdAsync(int workOrderId); @@ -33,6 +35,7 @@ namespace SeaHaven.DataServices.Interfaces Task GetListAsync(DispatchListQuery query, CancellationToken cancellationToken); Task GetDetailAsync(int id, CancellationToken cancellationToken); + Task GetDispatchDetailAsync(int id); Task> GetVendorDispatchSummariesAsync( int vendorId, string? status, CancellationToken cancellationToken); Task GetVendorDispatchDetailAsync( diff --git a/SeaHaven.DataServices/Interfaces/IQuotesDataService.cs b/SeaHaven.DataServices/Interfaces/IQuotesDataService.cs index 8357876..1f7a7da 100644 --- a/SeaHaven.DataServices/Interfaces/IQuotesDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IQuotesDataService.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Interfaces { @@ -12,5 +13,15 @@ namespace SeaHaven.DataServices.Interfaces Task DeleteAsync(int id); Task ExistsAsync(int id); Task UnlinkFromWorkOrderAsync(int workOrderId); + + Task<(IReadOnlyList Items, int TotalCount)> GetQuotesPagedAsync( + int page, int pageSize, string search, CancellationToken cancellationToken); + + Task GetQuoteDetailsByIdAsync(int id, CancellationToken cancellationToken); + + Task> GetWorkOrderQuoteDetailsAsync( + int woId, CancellationToken cancellationToken); + + Task DeleteLineItemsByQuoteIdAsync(int quoteId, CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/ISyncDataService.cs b/SeaHaven.DataServices/Interfaces/ISyncDataService.cs new file mode 100644 index 0000000..ee4d1be --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/ISyncDataService.cs @@ -0,0 +1,25 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface ISyncDataService + { + Task GetLastInternalWONumberAsync(CancellationToken cancellationToken); + Task GetWorkOrderByExternalIdAsync(string externalId, CancellationToken cancellationToken); + void EnqueueWorkOrder(WorkOrder workOrder); + void EnqueueComment(Comments comment); + Task SaveChangesAsync(CancellationToken cancellationToken); + + Task ResolveLocationAsync(string? siteCode, string? building, string? address, CancellationToken cancellationToken); + Task GetLocationCountAsync(CancellationToken cancellationToken); + + Task CommentExistsByExternalIdAsync(string externalId, CancellationToken cancellationToken); + Task GetWorkOrderByInternalNumberAsync(string internalNumber, CancellationToken cancellationToken); + Task GetDispatchByNumberAndWorkOrderIdAsync(string dispatchNumber, int workOrderId, CancellationToken cancellationToken); + + Task> GetWorkOrdersMissingInternalNumberAsync(CancellationToken cancellationToken); + Task> GetDispatchesMissingNumberAsync(CancellationToken cancellationToken); + Task ExecuteBackfillCommentTypesAsync(CancellationToken cancellationToken); + Task GetLastDispatchNumberAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderAuditDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderAuditDataService.cs new file mode 100644 index 0000000..d1bcef4 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderAuditDataService.cs @@ -0,0 +1,11 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderAuditDataService + { + void EnqueueAuditLog(WorkOrderAuditLog auditLog); + + Task PersistAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs index 3ce62e5..e298516 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Interfaces { @@ -23,7 +24,7 @@ namespace SeaHaven.DataServices.Interfaces string? priority = null); // Paged list with detailed information (for GetWorkOrderList endpoint) - Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( + Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( int page, int pageSize, string? search = null, @@ -66,7 +67,12 @@ namespace SeaHaven.DataServices.Interfaces // Statistics Task CountAsync(); Task CountByStatusAsync(string status); + Task CountByPriorityAsync(string priority); Task CountByLocationAsync(int locationId); Task> GetAllWithDetailsAsync(); + + // Complex read models + Task GetWorkOrderDetailAsync(int id); + Task> GetNonTemplateWorkOrdersWithLocationsAsync(); } } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderFieldLockDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderFieldLockDataService.cs new file mode 100644 index 0000000..2f63cea --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderFieldLockDataService.cs @@ -0,0 +1,11 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderFieldLockDataService + { + Task IsLockedAsync(int workOrderId, string fieldName, CancellationToken cancellationToken); + + void EnqueueLock(int workOrderId, string fieldName, string? userId); + } +} diff --git a/SeaHaven.DataServices/Models/ReadModels.cs b/SeaHaven.DataServices/Models/ReadModels.cs new file mode 100644 index 0000000..71a5c76 --- /dev/null +++ b/SeaHaven.DataServices/Models/ReadModels.cs @@ -0,0 +1,260 @@ +namespace SeaHaven.DataServices.Models +{ + public class WorkOrderListItemReadModel + { + public int Id { get; set; } + public string? InternalWONumber { get; set; } + public string? WorkOrderNumber { get; set; } + public string? Number { get; set; } + public string? Title { get; set; } + public string? Location { get; set; } + public string? LocationName { get; set; } + public string? Priority { get; set; } + public string? Status { get; set; } + public DateTime? CreatedDate { get; set; } + public DateTime? DueDate { get; set; } + public string? AssignedTo { get; set; } + public string? EmployeeName { get; set; } + public DateTime? LastUpdated { get; set; } + } + + public class WorkOrderDetailReadModel + { + public int Id { get; set; } + public string? InternalWONumber { get; set; } + public string? WorkerOrderNumber { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public DateTime? CreatedDate { get; set; } + public DateTime? DueDate { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public string? Status { get; set; } + public string? Assignee { get; set; } + public string? Location { get; set; } + public int? LocationId { get; set; } + public string? LocationZip { get; set; } + public string? Priority { get; set; } + public string? Problem { get; set; } + public string? Trade { get; set; } + public string? SubTrade { get; set; } + public decimal? VendorNTE { get; set; } + public string? Source { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public List Poc { get; set; } = new(); + public List Category { get; set; } = new(); + public List Attachments { get; set; } = new(); + public string? BeforPhotoIssue { get; set; } + public string? BeforPhotoAttachment { get; set; } + public string? AfterPhotoIssue { get; set; } + public string? AfterPhotoAttachment { get; set; } + public string? SignOffName { get; set; } + public string? SignOffAttachment { get; set; } + public string? SignOffSignature { get; set; } + public List Comments { get; set; } = new(); + public List Quotes { get; set; } = new(); + public List AuditLog { get; set; } = new(); + public List Dispatches { get; set; } = new(); + } + + public class ContactReadModel + { + public int Id { get; set; } + public string? Name { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + } + + public class CategoryReadModel + { + public int Id { get; set; } + public string? Name { get; set; } + } + + public class AttachmentReadModel + { + public int Id { get; set; } + public string? Attachment { get; set; } + } + + public class CommentReadModel + { + public int Id { get; set; } + public DateTime? CreatedDate { get; set; } + public string? Commenttext { get; set; } + public string? Commenter { get; set; } + public string? RecordType { get; set; } + public string? CommentType { get; set; } + public string? Documents { get; set; } + public string? UserName { get; set; } + } + + public class QuoteRefReadModel + { + public string? Code { get; set; } + public int Id { get; set; } + } + + public class AuditLogReadModel + { + public int Id { get; set; } + public string? FieldName { get; set; } + public string? OldValue { get; set; } + public string? NewValue { get; set; } + public string? Action { get; set; } + public DateTime CreatedAt { get; set; } + public string? UserName { get; set; } + } + + public class DispatchReadModel + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public decimal? NTEAmount { get; set; } + public string? Description { get; set; } + public string? Status { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public DateTime? DispatchedAt { get; set; } + public bool? EmailSent { get; set; } + public string? VendorName { get; set; } + public string? VendorEmail { get; set; } + public int? VendorId { get; set; } + } + + public class DispatchDetailReadModel + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public decimal? NTEAmount { get; set; } + public string? Description { get; set; } + public string? Status { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public DateTime? DispatchedAt { get; set; } + public bool? EmailSent { get; set; } + public int? WorkOrderId { get; set; } + public DateTime? AcknowledgedAt { get; set; } + public string? VerifiedBy { get; set; } + public DateTime? VerifiedAt { get; set; } + public string? WoNumber { get; set; } + public string? WoTitle { get; set; } + public string? VendorName { get; set; } + public string? VendorEmail { get; set; } + public int? VendorId { get; set; } + public List WorkOrders { get; set; } = new(); + public List ChecklistItems { get; set; } = new(); + public List Signoffs { get; set; } = new(); + public List Comments { get; set; } = new(); + } + + public class DispatchWorkOrderReadModel + { + public int? WorkOrderId { get; set; } + public string? WoNumber { get; set; } + public string? WoTitle { get; set; } + } + + public class ChecklistItemReadModel + { + public int Id { get; set; } + public string? ItemText { get; set; } + public bool? IsCompleted { get; set; } + public string? CompletedBy { get; set; } + public DateTime? CompletedAt { get; set; } + public int? SortOrder { get; set; } + public int? WorkOrderId { get; set; } + } + + public class SignoffReadModel + { + public int Id { get; set; } + public string? SignoffType { get; set; } + public string? Name { get; set; } + public string? Signature { get; set; } + public string? SignatureMethod { get; set; } + public DateTime? SignedAt { get; set; } + } + + public class DispatchCommentReadModel + { + public int Id { get; set; } + public DateTime? CreatedDate { get; set; } + public string? Commenttext { get; set; } + public string? Commenter { get; set; } + public string? CommentType { get; set; } + public string? UserName { get; set; } + } + + public class FilteredWorkOrderReadModel + { + public int Id { get; set; } + public string? WorkerOrderTitle { get; set; } + public DateTime? DueDate { get; set; } + public string? Status { get; set; } + public string? Priority { get; set; } + public string? Name { get; set; } + } + + public class CommentListItemReadModel + { + public int Id { get; set; } + public string? Commenttext { get; set; } + public string? FirstName { get; set; } + public string? Documents { get; set; } + } + + public class QuoteListItemReadModel + { + public int Id { get; set; } + public string? QuoteId { get; set; } + public string? WorkOrder { get; set; } + public string? ProjectName { get; set; } + public decimal TotalAmount { get; set; } + public string? SiteName { get; set; } + public string? SiteId { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? BillingAddress { get; set; } + public string? Status { get; set; } + public int? ContactId { get; set; } + public DateTime? Date { get; set; } + public List Lineitems { get; set; } = new(); + } + + public class QuoteDetailReadModel + { + public int Id { get; set; } + public string? QuotId { get; set; } + public DateTime? Date { get; set; } + public string? Name { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? SiteName { get; set; } + public string? ProjectName { get; set; } + public string? SiteId { get; set; } + public string? BillingAddress { get; set; } + public string? ShippingAddress { get; set; } + public decimal TotalAmount { get; set; } + public List Lineitems { get; set; } = new(); + } + + public class QuoteLineItemReadModel + { + public string? Descriptions { get; set; } + public decimal? Lineitem { get; set; } + public int? LineitemNumber { get; set; } + } + + public class WorkOrderQuoteDetailReadModel + { + public int Id { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? Title { get; set; } + public string? Name { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/DispatchServiceDTOs.cs b/SeaHaven.Services/DTOs/DispatchServiceDTOs.cs new file mode 100644 index 0000000..ede579e --- /dev/null +++ b/SeaHaven.Services/DTOs/DispatchServiceDTOs.cs @@ -0,0 +1,131 @@ +namespace SeaHaven.Services.DTOs +{ + public class DispatchVerificationException : Exception + { + public List Missing { get; } + public DispatchVerificationException(List missing) : base("Cannot verify") + { + Missing = missing; + } + } + + public class DispatchToVendorInput + { + public List WorkOrderIds { get; set; } = new(); + public List VendorIds { get; set; } = new(); + public decimal? NTEAmount { get; set; } + public string? Description { get; set; } + public DateTime? ScheduledDate { get; set; } + public int? TaskListTemplateId { get; set; } + public List? CustomChecklistItems { get; set; } + public string? UserId { get; set; } + public string? SenderName { get; set; } + } + + public class DispatchResultItem + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public string? VendorName { get; set; } + public string? Status { get; set; } + public int WorkOrderCount { get; set; } + } + + public class DispatchToVendorResult + { + public string Message { get; set; } = ""; + public List Dispatches { get; set; } = new(); + } + + public class UpdateDispatchInput + { + public int Id { get; set; } + public string? Status { get; set; } + public decimal? NTEAmount { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public string? Description { get; set; } + public string? UserId { get; set; } + } + + public class AddDispatchCommentInput + { + public int DispatchId { get; set; } + public string? Text { get; set; } + public bool SendEmail { get; set; } + public string? UserId { get; set; } + } + + public class AddDispatchCommentResult + { + public int Id { get; set; } + public DateTime? CreatedDate { get; set; } + public string? Commenttext { get; set; } + public string? CommentType { get; set; } + public string? UserName { get; set; } + } + + public class AddDispatchSignoffInput + { + public int DispatchId { get; set; } + public string? SignoffType { get; set; } + public string? Name { get; set; } + public string? Signature { get; set; } + public string? SignatureMethod { get; set; } + } + + public class AddDispatchSignoffResult + { + public int Id { get; set; } + public string? SignoffType { get; set; } + public string? Name { get; set; } + public DateTime? SignedAt { get; set; } + public string? SignatureMethod { get; set; } + } + + public class VerifyDispatchResult + { + public string Message { get; set; } = ""; + public string? VerifiedBy { get; set; } + public DateTime? VerifiedAt { get; set; } + } + + public class VerifyDispatchError + { + public string Status { get; set; } = "Error"; + public string Message { get; set; } = ""; + public List Missing { get; set; } = new(); + } + + public class ChecklistItemUpdateInput + { + public int Id { get; set; } + public bool IsCompleted { get; set; } + public string? CompletedBy { get; set; } + } + + public class ChecklistItemUpdateResult + { + public string Message { get; set; } = ""; + public int Id { get; set; } + public bool? IsCompleted { get; set; } + public string? CompletedBy { get; set; } + public DateTime? CompletedAt { get; set; } + } + + public class AddChecklistItemInput + { + public int DispatchId { get; set; } + public int? WorkOrderId { get; set; } + public string? ItemText { get; set; } + } + + public class AddChecklistItemResult + { + public int Id { get; set; } + public string? ItemText { get; set; } + public int? SortOrder { get; set; } + public bool? IsCompleted { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/QuoteDTOs.cs b/SeaHaven.Services/DTOs/QuoteDTOs.cs new file mode 100644 index 0000000..ef7364c --- /dev/null +++ b/SeaHaven.Services/DTOs/QuoteDTOs.cs @@ -0,0 +1,108 @@ +namespace SeaHaven.Services.DTOs +{ + public class CreateQuoteInput + { + public string? QuotId { get; set; } + public int? ContactId { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? ProjectName { get; set; } + public DateTime? Date { get; set; } + public int? WorkorderId { get; set; } + public string? SiteName { get; set; } + public string? SiteId { get; set; } + public string? BillingAddress { get; set; } + public List? LineItems { get; set; } + } + + public class UpdateQuoteInput + { + public int Id { get; set; } + public string? QuotId { get; set; } + public int? ContactId { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? ProjectName { get; set; } + public DateTime? Date { get; set; } + public int? WorkorderId { get; set; } + public string? SiteName { get; set; } + public string? SiteId { get; set; } + public string? BillingAddress { get; set; } + public List? LineItems { get; set; } + } + + public class QuoteLineItemInput + { + public int? LineitemNumber { get; set; } + public decimal? Lineitem { get; set; } + public string? Descriptions { get; set; } + } + + public class PagedQuotesResult + { + public IReadOnlyList Items { get; set; } = Array.Empty(); + public int TotalCount { get; set; } + } + + public class QuoteListItemDTO + { + public int Id { get; set; } + public string? QuoteId { get; set; } + public string? WorkOrder { get; set; } + public string? ProjectName { get; set; } + public string? TotalAmount { get; set; } + public string? SiteName { get; set; } + public string? SiteId { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? BillingAddress { get; set; } + public string? Status { get; set; } + public int? ContactId { get; set; } + public DateTime? Date { get; set; } + public IReadOnlyList Lineitems { get; set; } = Array.Empty(); + } + + public class QuoteDetailDTO + { + public int Id { get; set; } + public string? QuotId { get; set; } + public DateTime? Date { get; set; } + public string? Name { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? SiteName { get; set; } + public string? ProjectName { get; set; } + public string? SiteId { get; set; } + public string? BillingAddress { get; set; } + public string? ShippingAddress { get; set; } + public string? TotalAmount { get; set; } + public IReadOnlyList Lineitems { get; set; } = Array.Empty(); + } + + public class QuoteLineItemDTO + { + public string? Descriptions { get; set; } + public decimal? Lineitem { get; set; } + public int? LineitemNumber { get; set; } + } + + public class WorkOrderQuoteDetailDTO + { + public int Id { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? Title { get; set; } + public string? Name { get; set; } + } + + public class QuoteReportingData + { + public int TotalCreatedWorkOrder { get; set; } + public int TotalCompleted { get; set; } + public int TotalSev1 { get; set; } + public int TotalSev2 { get; set; } + public int TotalSev3 { get; set; } + public int TotalSev4 { get; set; } + public int TotalSev5 { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/SyncDTOs.cs b/SeaHaven.Services/DTOs/SyncDTOs.cs new file mode 100644 index 0000000..5973aaa --- /dev/null +++ b/SeaHaven.Services/DTOs/SyncDTOs.cs @@ -0,0 +1,28 @@ +namespace SeaHaven.Services.DTOs +{ + public class SyncWorkOrdersResult + { + public int Synced { get; set; } + public int Created { get; set; } + public int Updated { get; set; } + public int LocationsCreated { get; set; } + } + + public class SyncCommentsResult + { + public int Synced { get; set; } + public int Created { get; set; } + public int Skipped { get; set; } + } + + public class BackfillResult + { + public int Updated { get; set; } + } + + public class SyncAllResult + { + public SyncWorkOrdersResult? WorkOrders { get; set; } + public SyncCommentsResult? Comments { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs new file mode 100644 index 0000000..705306f --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs @@ -0,0 +1,115 @@ +using Microsoft.AspNetCore.Http; +using SeaHaven.DataServices.Models; + +namespace SeaHaven.Services.DTOs +{ + public class CreateWorkOrderWithDetailsInput + { + public string WorkerOrderTitle { get; set; } = ""; + public string? Description { get; set; } + public string Status { get; set; } = "Open"; + public string? Priority { get; set; } + public int? LocationId { get; set; } + public string? AssignTo { get; set; } + public DateTime? DueDate { get; set; } + public List? ContactIds { get; set; } + public List? CategoryIds { get; set; } + public List? Attachments { get; set; } + public IFormFile? BeforPhotoAttachment { get; set; } + public IFormFile? AfterPhotoAttachment { get; set; } + public IFormFile? SignOffAttachment { get; set; } + } + + public class UpdateWorkOrderWithDetailsInput + { + public int Id { get; set; } + public string? WorkerOrderTitle { get; set; } + public string? Description { get; set; } + public string? Status { get; set; } + public string? Priority { get; set; } + public int? LocationId { get; set; } + public string? AssignTo { get; set; } + public DateTime? DueDate { get; set; } + public DateTime? CompletedDate { get; set; } + public List? ContactIds { get; set; } + public List? CategoryIds { get; set; } + public List? Attachments { get; set; } + public IFormFile? BeforPhotoAttachment { get; set; } + public IFormFile? AfterPhotoAttachment { get; set; } + public IFormFile? SignOffAttachment { get; set; } + } + + public class AddCommentInput + { + public int WorkorderId { get; set; } + public string? Text { get; set; } + public string? CommentType { get; set; } + public IFormFile? Document { get; set; } + } + + public class CommentResult + { + public int Id { get; set; } + public DateTime? CreatedDate { get; set; } + public string? Commenttext { get; set; } + public string? CommentType { get; set; } + public string? UserName { get; set; } + public string? Documents { get; set; } + } + + public class ChangeStatusResult + { + public int Id { get; set; } + public string? InternalWONumber { get; set; } + public string? WorkerOrderNumber { get; set; } + public string? WorkerOrderTitle { get; set; } + public string? Status { get; set; } + public string? Priority { get; set; } + } + + public class ChangeAssignmentResult + { + public string Message { get; set; } = ""; + public string AssignedTo { get; set; } = ""; + } + + public class WorkorderDDItem + { + public int Id { get; set; } + public string? InternalWONumber { get; set; } + public string? WorkerOrderNumber { get; set; } + public string? WorkerOrderTitle { get; set; } + public string? Status { get; set; } + public string? Priority { get; set; } + public bool istemplate { get; set; } + } + + public class WorkorderFilterVM + { + public int? Id { get; set; } + public int? LocationId { get; set; } + public string? WorkerOrderNumber { get; set; } + public string? BillingAddress { get; set; } = "Amazon.com Services LLC 410 Terry Ave,North Seattle, Washington, 98109"; + public string? WorkerOrderTitle { get; set; } + public string? LocationName { get; set; } + public string? AssignToUserName { get; set; } + public string? Priority { get; set; } + public string? Status { get; set; } + public DateTime? DueDate { get; set; } + public string FormattedDueDate => DueDate.HasValue ? DueDate.Value.ToString("MM/dd/yyyy") : ""; + public string? Dispatcher { get; set; } + public string? Scheduler { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + } + + public class FilteredWorkOrderPageDTO + { + public IReadOnlyList Data { get; set; } = + Array.Empty(); + public int PageNumber { get; set; } + public int PageSize { get; set; } + public int TotalCount { get; set; } + public int TotalPages { get; set; } + } +} diff --git a/SeaHaven.Services/Implementation/QuotesService.cs b/SeaHaven.Services/Implementation/QuotesService.cs new file mode 100644 index 0000000..a401ce9 --- /dev/null +++ b/SeaHaven.Services/Implementation/QuotesService.cs @@ -0,0 +1,191 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class QuotesService : IQuotesService + { + private readonly IQuotesDataService _quotesData; + private readonly IWorkOrderDataService _workOrderData; + + public QuotesService(IQuotesDataService quotesData, IWorkOrderDataService workOrderData) + { + _quotesData = quotesData; + _workOrderData = workOrderData; + } + + public async Task CreateQuoteAsync(CreateQuoteInput input, CancellationToken cancellationToken) + { + var rnd = new Random(); + var id = rnd.Next(1, 99999); + + var quote = new Quotes + { + QuotId = id.ToString(), + ContactId = input.ContactId, + PO = input.PO, + TT = input.TT, + ProjectName = input.ProjectName, + Date = input.Date, + WorkorderId = input.WorkorderId, + SiteName = input.SiteName, + SiteId = input.SiteId, + BillingAddress = input.BillingAddress, + LineItems = new List() + }; + + if (input.LineItems != null && input.LineItems.Any()) + { + foreach (var li in input.LineItems) + { + quote.LineItems.Add(new QuotesLineItems + { + LineitemNumber = li.LineitemNumber, + Lineitem = li.Lineitem, + Descriptions = li.Descriptions + }); + } + } + + await _quotesData.AddAsync(quote); + } + + public async Task UpdateQuoteAsync(UpdateQuoteInput input, CancellationToken cancellationToken) + { + var quote = await _quotesData.GetByIdAsync(input.Id); + if (quote == null) + return false; + + quote.QuotId = input.QuotId; + quote.ContactId = input.ContactId; + quote.PO = input.PO; + quote.TT = input.TT; + quote.ProjectName = input.ProjectName; + quote.Date = input.Date; + quote.WorkorderId = input.WorkorderId; + quote.SiteName = input.SiteName; + quote.SiteId = input.SiteId; + quote.BillingAddress = input.BillingAddress; + + await _quotesData.DeleteLineItemsByQuoteIdAsync(input.Id, cancellationToken); + + quote.LineItems ??= new List(); + + if (input.LineItems != null && input.LineItems.Any()) + { + foreach (var li in input.LineItems) + { + quote.LineItems.Add(new QuotesLineItems + { + LineitemNumber = li.LineitemNumber, + Lineitem = li.Lineitem, + Descriptions = li.Descriptions + }); + } + } + + await _quotesData.UpdateAsync(quote); + return true; + } + + public async Task DeleteQuoteAsync(int id, CancellationToken cancellationToken) + { + var quote = await _quotesData.GetByIdAsync(id); + if (quote == null) + return false; + + await _quotesData.DeleteLineItemsByQuoteIdAsync(id, cancellationToken); + await _quotesData.DeleteAsync(id); + return true; + } + + public async Task GetQuotesPagedAsync(int page, int pageSize, string search, CancellationToken cancellationToken) + { + var (items, totalCount) = await _quotesData.GetQuotesPagedAsync(page, pageSize, search, cancellationToken); + + return new PagedQuotesResult + { + Items = items.Select(item => new QuoteListItemDTO + { + Id = item.Id, + QuoteId = item.QuoteId, + WorkOrder = item.WorkOrder, + ProjectName = item.ProjectName, + TotalAmount = FormatAmount(item.TotalAmount), + SiteName = item.SiteName, + SiteId = item.SiteId, + PO = item.PO, + TT = item.TT, + BillingAddress = item.BillingAddress, + Status = item.Status, + ContactId = item.ContactId, + Date = item.Date, + Lineitems = item.Lineitems.Select(MapLineItem).ToList() + }).ToList(), + TotalCount = totalCount + }; + } + + public async Task> GetWorkOrderQuoteDetailsAsync( + int woId, CancellationToken cancellationToken) + { + var items = await _quotesData.GetWorkOrderQuoteDetailsAsync(woId, cancellationToken); + return items.Select(item => new WorkOrderQuoteDetailDTO + { + Id = item.Id, + PO = item.PO, + TT = item.TT, + Title = item.Title, + Name = item.Name + }).ToList(); + } + + public async Task GetQuoteDetailsByIdAsync( + int id, CancellationToken cancellationToken) + { + var item = await _quotesData.GetQuoteDetailsByIdAsync(id, cancellationToken); + return item == null ? null : new QuoteDetailDTO + { + Id = item.Id, + QuotId = item.QuotId, + Date = item.Date, + Name = item.Name, + PO = item.PO, + TT = item.TT, + SiteName = item.SiteName, + ProjectName = item.ProjectName, + SiteId = item.SiteId, + BillingAddress = item.BillingAddress, + ShippingAddress = item.ShippingAddress, + TotalAmount = FormatAmount(item.TotalAmount), + Lineitems = item.Lineitems.Select(MapLineItem).ToList() + }; + } + + public async Task GetReportingAsync(CancellationToken cancellationToken) + { + return new QuoteReportingData + { + TotalCreatedWorkOrder = await _workOrderData.CountAsync(), + TotalCompleted = await _workOrderData.CountByStatusAsync("Done"), + TotalSev1 = await _workOrderData.CountByPriorityAsync("Sev 1"), + TotalSev2 = await _workOrderData.CountByPriorityAsync("Sev 2"), + TotalSev3 = await _workOrderData.CountByPriorityAsync("Sev 3"), + TotalSev4 = await _workOrderData.CountByPriorityAsync("Sev 4"), + TotalSev5 = await _workOrderData.CountByPriorityAsync("Sev 5") + }; + } + + private static QuoteLineItemDTO MapLineItem( + SeaHaven.DataServices.Models.QuoteLineItemReadModel item) => new() + { + Descriptions = item.Descriptions, + Lineitem = item.Lineitem, + LineitemNumber = item.LineitemNumber + }; + + private static string FormatAmount(decimal amount) => $"${amount}"; + } +} diff --git a/SeaHaven.Services/Implementation/SyncService.cs b/SeaHaven.Services/Implementation/SyncService.cs new file mode 100644 index 0000000..f9268c6 --- /dev/null +++ b/SeaHaven.Services/Implementation/SyncService.cs @@ -0,0 +1,304 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class SyncService : ISyncService + { + private readonly ISyncDataService _data; + private readonly ISyncExternalSource _external; + + public SyncService(ISyncDataService data, ISyncExternalSource external) + { + _data = data; + _external = external; + } + + public async Task SyncWorkOrdersAsync(CancellationToken cancellationToken) + { + var synced = 0; + var created = 0; + var updated = 0; + + var lastInternalWO = await _data.GetLastInternalWONumberAsync(cancellationToken); + int nextInternal = 10000001; + if (lastInternalWO != null && int.TryParse(lastInternalWO, out var parsed)) + nextInternal = parsed + 1; + + await foreach (var item in _external.ScanWorkOrdersAsync(cancellationToken)) + { + var externalId = Get(item, "work_order_id"); + if (string.IsNullOrEmpty(externalId)) continue; + + var existing = await _data.GetWorkOrderByExternalIdAsync(externalId, cancellationToken); + + var locationId = await _data.ResolveLocationAsync( + Get(item, "site_code"), + Get(item, "building"), + Get(item, "address"), + cancellationToken); + + if (existing == null) + { + var wo = new WorkOrder + { + InternalWONumber = (nextInternal++).ToString("D8"), + ExternalWorkOrderId = externalId, + WorkerOrderNumber = externalId, + WorkerOrderTitle = Get(item, "description"), + Description = Get(item, "description"), + Status = MapStatus(Get(item, "wo_status")), + Priority = MapSeverityToPriority(Get(item, "severity")), + Severity = Get(item, "severity"), + Customer = Get(item, "customer"), + SiteCode = Get(item, "site_code"), + Building = Get(item, "building"), + LocationId = locationId, + DueDate = ParseDate(Get(item, "due_date")), + DateReported = ParseDate(Get(item, "date_reported")), + ScheduledStart = ParseDate(Get(item, "scheduled_start")), + AssignTo = null, + SourceEmailS3Key = Get(item, "source_email_s3_key"), + CreatedDate = ParseDate(Get(item, "created_at")) ?? DateTime.UtcNow, + istemplate = false + }; + _data.EnqueueWorkOrder(wo); + created++; + } + else + { + existing.WorkerOrderTitle = Get(item, "description") ?? existing.WorkerOrderTitle; + existing.Description = Get(item, "description") ?? existing.Description; + existing.Status = MapStatus(Get(item, "wo_status")) ?? existing.Status; + existing.Priority = MapSeverityToPriority(Get(item, "severity")) ?? existing.Priority; + existing.Severity = Get(item, "severity") ?? existing.Severity; + existing.SiteCode = Get(item, "site_code") ?? existing.SiteCode; + existing.Building = Get(item, "building") ?? existing.Building; + existing.LocationId = locationId ?? existing.LocationId; + existing.DueDate = ParseDate(Get(item, "due_date")) ?? existing.DueDate; + existing.DateReported = ParseDate(Get(item, "date_reported")) ?? existing.DateReported; + existing.ScheduledStart = ParseDate(Get(item, "scheduled_start")) ?? existing.ScheduledStart; + existing.SourceEmailS3Key = Get(item, "source_email_s3_key") ?? existing.SourceEmailS3Key; + updated++; + } + + synced++; + + if (synced % 100 == 0) + await _data.SaveChangesAsync(cancellationToken); + } + + await _data.SaveChangesAsync(cancellationToken); + + return new SyncWorkOrdersResult + { + Synced = synced, + Created = created, + Updated = updated, + LocationsCreated = await _data.GetLocationCountAsync(cancellationToken) + }; + } + + public async Task SyncCommentsAsync(CancellationToken cancellationToken) + { + var synced = 0; + var created = 0; + var skipped = 0; + + await foreach (var item in _external.ScanCommentsAsync(cancellationToken)) + { + var externalCommentId = Get(item, "comment_id"); + var externalWoId = Get(item, "work_order_id"); + + if (string.IsNullOrEmpty(externalCommentId) || string.IsNullOrEmpty(externalWoId)) + continue; + + var alreadyExists = await _data.CommentExistsByExternalIdAsync(externalCommentId, cancellationToken); + + if (alreadyExists) + { + skipped++; + synced++; + continue; + } + + var workOrder = await _data.GetWorkOrderByExternalIdAsync(externalWoId, cancellationToken); + + if (workOrder == null) + { + skipped++; + continue; + } + + var comment = new Comments + { + ExternalCommentId = externalCommentId, + WorkerOrderId = workOrder.Id, + Commenttext = Get(item, "text"), + Commenter = Get(item, "commenter"), + RecordType = Get(item, "record_type"), + CommentType = "customer", + CreatedDate = ParseDate(Get(item, "created_at")) ?? DateTime.UtcNow, + }; + + _data.EnqueueComment(comment); + created++; + synced++; + + if (synced % 100 == 0) + await _data.SaveChangesAsync(cancellationToken); + } + + await _data.SaveChangesAsync(cancellationToken); + + return new SyncCommentsResult { Synced = synced, Created = created, Skipped = skipped }; + } + + public async Task BackfillInternalWONumbersAsync(CancellationToken cancellationToken) + { + var wosMissing = await _data.GetWorkOrdersMissingInternalNumberAsync(cancellationToken); + + if (wosMissing.Count == 0) + return new BackfillResult { Updated = 0 }; + + var lastWO = await _data.GetLastInternalWONumberAsync(cancellationToken); + + int next = 10000001; + if (lastWO != null && int.TryParse(lastWO, out var lastNum)) + next = lastNum + 1; + + foreach (var wo in wosMissing) + { + wo.InternalWONumber = next.ToString("D8"); + next++; + } + + await _data.SaveChangesAsync(cancellationToken); + return new BackfillResult { Updated = wosMissing.Count }; + } + + public async Task BackfillDispatchNumbersAsync(CancellationToken cancellationToken) + { + var missing = await _data.GetDispatchesMissingNumberAsync(cancellationToken); + if (missing.Count == 0) return new BackfillResult { Updated = 0 }; + + int next = 1; + var last = await _data.GetLastDispatchNumberAsync(cancellationToken); + if (last != null && last.StartsWith("DSP-") && int.TryParse(last.Substring(4), out var n)) + next = n + 1; + + foreach (var d in missing) + { + d.DispatchNumber = $"DSP-{next:D5}"; + next++; + } + await _data.SaveChangesAsync(cancellationToken); + return new BackfillResult { Updated = missing.Count }; + } + + public async Task SyncVendorRepliesAsync(CancellationToken cancellationToken) + { + var synced = 0; + var created = 0; + var skipped = 0; + + await foreach (var item in _external.ScanVendorRepliesAsync(cancellationToken)) + { + var replyId = Get(item, "reply_id"); + var woNumber = Get(item, "internal_wo_number"); + var dispatchNumber = Get(item, "dispatch_number"); + + if (string.IsNullOrEmpty(replyId) || string.IsNullOrEmpty(woNumber)) + continue; + + var alreadyExists = await _data.CommentExistsByExternalIdAsync(replyId, cancellationToken); + if (alreadyExists) { skipped++; synced++; continue; } + + var workOrder = await _data.GetWorkOrderByInternalNumberAsync(woNumber, cancellationToken); + if (workOrder == null) { skipped++; continue; } + + int? dispatchId = null; + if (!string.IsNullOrWhiteSpace(dispatchNumber)) + { + var dispatch = await _data.GetDispatchByNumberAndWorkOrderIdAsync(dispatchNumber, workOrder.Id, cancellationToken); + dispatchId = dispatch?.Id; + } + + var comment = new Comments + { + ExternalCommentId = replyId, + WorkerOrderId = workOrder.Id, + DispatchId = dispatchId, + Commenttext = Get(item, "reply_text"), + Commenter = Get(item, "sender_email"), + CommentType = "vendor", + RecordType = "vendor_reply", + CreatedDate = ParseDate(Get(item, "received_at")) ?? DateTime.UtcNow, + }; + + _data.EnqueueComment(comment); + created++; + synced++; + + if (synced % 50 == 0) + await _data.SaveChangesAsync(cancellationToken); + } + + await _data.SaveChangesAsync(cancellationToken); + return new SyncCommentsResult { Synced = synced, Created = created, Skipped = skipped }; + } + + public async Task BackfillCommentTypesAsync(CancellationToken cancellationToken) + { + var updated = await _data.ExecuteBackfillCommentTypesAsync(cancellationToken); + return new BackfillResult { Updated = updated }; + } + + public async Task SyncAllAsync(CancellationToken cancellationToken) + { + var woResult = await SyncWorkOrdersAsync(cancellationToken); + var commentResult = await SyncCommentsAsync(cancellationToken); + + return new SyncAllResult + { + WorkOrders = woResult, + Comments = commentResult + }; + } + + private static string? Get(Dictionary item, string key) + { + return item.TryGetValue(key, out var val) ? val : null; + } + + private static DateTime? ParseDate(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + if (DateTime.TryParse(value, out var dt)) return dt; + return null; + } + + private static string? MapStatus(string? dynamoStatus) + { + return dynamoStatus switch + { + "new" => "Open", + "assigned" => "Open", + "in_progress" => "In Progress", + "on_hold" => "On Hold", + "completed" => "Done", + "cancelled" => "Cancelled", + "unknown" => "Open", + _ => "Open" + }; + } + + private static string? MapSeverityToPriority(string? severity) + { + if (string.IsNullOrWhiteSpace(severity)) return null; + return $"Sev {severity}"; + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs index 19df98b..cfb3476 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs @@ -1,6 +1,6 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; -using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Constants; using SeaHaven.Services.Interfaces; @@ -8,12 +8,12 @@ namespace SeaHaven.Services.Implementation { public class WorkOrderAuditService : IWorkOrderAuditService { - private readonly ApplicationDbContext _context; + private readonly IWorkOrderAuditDataService _auditData; private readonly IWorkOrderFieldLockService _fieldLocks; - public WorkOrderAuditService(ApplicationDbContext context, IWorkOrderFieldLockService fieldLocks) + public WorkOrderAuditService(IWorkOrderAuditDataService auditData, IWorkOrderFieldLockService fieldLocks) { - _context = context; + _auditData = auditData; _fieldLocks = fieldLocks; } @@ -41,7 +41,7 @@ namespace SeaHaven.Services.Implementation int? dispatchId = null, string? correlationId = null) { - _context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + _auditData.EnqueueAuditLog(new WorkOrderAuditLog { WorkOrderId = workOrderId, UserId = actorId, @@ -64,29 +64,7 @@ namespace SeaHaven.Services.Implementation await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId); } - await SaveChangesIdempotentAsync(); - } - - private async Task SaveChangesIdempotentAsync() - { - try - { - await _context.SaveChangesAsync(); - } - catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex)) - { - foreach (var entry in ex.Entries.Where(e => e.Entity is WorkOrderFieldLock)) - entry.State = EntityState.Detached; - - await _context.SaveChangesAsync(); - } - } - - private static bool IsUniqueConstraintViolation(DbUpdateException ex) - { - var message = ex.InnerException?.Message ?? ex.Message; - return message.Contains("duplicate", StringComparison.OrdinalIgnoreCase) - || message.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase); + await _auditData.PersistAsync(CancellationToken.None); } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderDispatchService.cs b/SeaHaven.Services/Implementation/WorkOrderDispatchService.cs new file mode 100644 index 0000000..9226c84 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderDispatchService.cs @@ -0,0 +1,411 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.Extensions.Configuration; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderDispatchService : IWorkOrderDispatchService + { + private readonly IWorkOrderDataService _workOrderDataService; + private readonly IDispatchDataService _dispatchDataService; + private readonly IVendorDataService _vendorDataService; + private readonly IUserDataService _userDataService; + private readonly ITaskListTemplateDataService _taskListTemplateDataService; + private readonly ICommentDataService _commentDataService; + private readonly IDispatchEmailPort _emailPort; + private readonly IVendorTokenPort _vendorTokenPort; + private readonly IConfiguration _configuration; + + public WorkOrderDispatchService( + IWorkOrderDataService workOrderDataService, + IDispatchDataService dispatchDataService, + IVendorDataService vendorDataService, + IUserDataService userDataService, + ITaskListTemplateDataService taskListTemplateDataService, + ICommentDataService commentDataService, + IDispatchEmailPort emailPort, + IVendorTokenPort vendorTokenPort, + IConfiguration configuration) + { + _workOrderDataService = workOrderDataService; + _dispatchDataService = dispatchDataService; + _vendorDataService = vendorDataService; + _userDataService = userDataService; + _taskListTemplateDataService = taskListTemplateDataService; + _commentDataService = commentDataService; + _emailPort = emailPort; + _vendorTokenPort = vendorTokenPort; + _configuration = configuration; + } + + public async Task DispatchToVendorAsync(DispatchToVendorInput input) + { + var allWorkOrders = await _workOrderDataService.GetAllWithDetailsAsync(); + var workOrders = allWorkOrders.Where(w => input.WorkOrderIds.Contains(w.Id)).ToList(); + + if (workOrders.Count == 0) + throw new InvalidOperationException("No work orders found"); + + var primaryWO = workOrders.First(); + + var lastPO = await _dispatchDataService.GenerateNextPONumberAsync(); + int nextPO = int.TryParse(lastPO, out var po) ? po : 1; + + var lastDSP = await _dispatchDataService.GenerateNextDispatchNumberAsync(); + int nextDSP = lastDSP.StartsWith("DSP-") && int.TryParse(lastDSP.Substring(4), out var dspNum) ? dspNum : 1; + + var currentUser = input.UserId != null ? await _userDataService.GetByIdAsync(input.UserId) : null; + var senderName = currentUser != null ? (currentUser.FirstName + " " + currentUser.LastName).Trim() : ""; + + var dispatches = new List(); + + foreach (var vendorId in input.VendorIds) + { + var vendor = await _vendorDataService.GetByIdAsync(vendorId); + if (vendor == null) continue; + + var poNumber = $"VPO-{nextPO:D5}"; + nextPO++; + var dispatchNumber = $"DSP-{nextDSP:D5}"; + nextDSP++; + + var replyTo = $"wo-{primaryWO.InternalWONumber}-{dispatchNumber}@int.seahaven.com"; + + var dispatch = new Dispatch + { + WorkOrderId = primaryWO.Id, + VendorId = vendorId, + DispatchNumber = dispatchNumber, + PONumber = poNumber, + NTEAmount = input.NTEAmount, + Description = input.Description, + Status = "Sent", + ScheduledDate = input.ScheduledDate, + DispatchedAt = DateTime.UtcNow, + ReplyToAddress = replyTo, + DispatchWorkOrders = workOrders.Select(wo => new DispatchWorkOrder { WorkOrderId = wo.Id }).ToList() + }; + + var checklistItems = new List(); + if (input.TaskListTemplateId.HasValue) + { + var template = await _taskListTemplateDataService.GetByIdWithItemsAsync(input.TaskListTemplateId.Value); + if (template?.Items != null) + { + checklistItems.AddRange(template.Items.OrderBy(i => i.SortOrder).Select((i, idx) => + new DispatchChecklistItem { ItemText = i.ItemText, SortOrder = idx })); + } + } + if (input.CustomChecklistItems != null) + { + var startIdx = checklistItems.Count; + checklistItems.AddRange(input.CustomChecklistItems.Select((text, idx) => + new DispatchChecklistItem { ItemText = text, SortOrder = startIdx + idx })); + } + dispatch.ChecklistItems = checklistItems; + + var woListHtml = string.Join("", workOrders.Select(wo => + $"{System.Net.WebUtility.HtmlEncode(wo.InternalWONumber ?? "")}" + + $"{System.Net.WebUtility.HtmlEncode(wo.WorkerOrderTitle ?? "")}" + + $"{System.Net.WebUtility.HtmlEncode(wo.Priority ?? "")}" + + $"{wo.DueDate?.ToString("yyyy-MM-dd") ?? "N/A"}")); + + var locationName = System.Net.WebUtility.HtmlEncode(primaryWO.Locations?.Name ?? ""); + var locationAddr = System.Net.WebUtility.HtmlEncode(primaryWO.Locations?.Address1 ?? ""); + var subject = $"[{dispatchNumber}] {(workOrders.Count > 1 ? $"{workOrders.Count} Work Orders" : primaryWO.WorkerOrderTitle)} at {primaryWO.Locations?.Name ?? ""}"; + var descriptionHtml = System.Net.WebUtility.HtmlEncode(input.Description ?? primaryWO.Description ?? ""); + var senderNameHtml = System.Net.WebUtility.HtmlEncode(senderName); + + var vendorToken = await _vendorTokenPort.GetOrCreateActiveTokenAsync(vendorId); + var frontendBase = _configuration.GetValue("FrontendBaseUrl")?.TrimEnd('/') ?? ""; + var portalUrl = $"{frontendBase}/v/{vendorToken}/dashboard"; + + var html = $@" +

Work Order Dispatch — {dispatchNumber}

+

Location: {locationName} {locationAddr}

+

Vendor PO: {poNumber}   NTE: ${input.NTEAmount?.ToString("F2") ?? "N/A"}

+

Work Orders

+ + + + + + + + {woListHtml} +
WO #TitlePriorityDue Date
+

Description

+

{descriptionHtml}

+
+ +

{senderNameHtml}
Sea Haven Industries

+

Reply to this email to communicate about this dispatch.

"; + + var emailSent = await _emailPort.SendDispatchEmailAsync(vendor.Email!, subject, html, replyTo); + dispatch.EmailSent = emailSent; + + var savedDispatch = await _dispatchDataService.AddAsync(dispatch); + + foreach (var wo in workOrders) + { + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = wo.Id, + UserId = input.UserId, + FieldName = "Dispatch", + OldValue = "", + NewValue = $"{vendor.CompanyName} ({dispatchNumber})", + Action = AuditAction.Dispatch.ToString(), + CreatedAt = DateTime.UtcNow + }); + } + + dispatches.Add(new DispatchResultItem + { + Id = savedDispatch.Id, + DispatchNumber = dispatchNumber, + PONumber = poNumber, + VendorName = vendor.CompanyName, + Status = savedDispatch.Status, + WorkOrderCount = workOrders.Count + }); + } + + return new DispatchToVendorResult { Message = "Dispatched", Dispatches = dispatches }; + } + + public async Task> GetDispatchesAsync(int workOrderId) + { + var dispatches = await _dispatchDataService.GetByWorkOrderIdAsync(workOrderId); + return dispatches.OrderByDescending(d => d.DispatchedAt).Select(d => new DispatchReadModel + { + Id = d.Id, + DispatchNumber = d.DispatchNumber, + PONumber = d.PONumber, + NTEAmount = d.NTEAmount, + Description = d.Description, + Status = d.Status, + ScheduledDate = d.ScheduledDate, + CompletedDate = d.CompletedDate, + DispatchedAt = d.DispatchedAt, + EmailSent = d.EmailSent, + VendorName = d.Vendor != null ? d.Vendor.CompanyName : "", + VendorEmail = d.Vendor != null ? d.Vendor.Email : "", + VendorId = d.VendorId + }).ToList(); + } + + public async Task GetDispatchDetailAsync(int id) + { + return await _dispatchDataService.GetDispatchDetailAsync(id); + } + + public async Task UpdateDispatchAsync(UpdateDispatchInput input) + { + var dispatch = await _dispatchDataService.GetByIdAsync(input.Id); + if (dispatch == null) + return false; + + if (dispatch.Status != input.Status) + { + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + UserId = input.UserId, + FieldName = $"Dispatch {dispatch.DispatchNumber} Status", + OldValue = dispatch.Status ?? "", + NewValue = input.Status ?? "", + Action = "dispatch_status_change", + CreatedAt = DateTime.UtcNow + }); + } + + dispatch.Status = input.Status; + dispatch.NTEAmount = input.NTEAmount; + dispatch.ScheduledDate = input.ScheduledDate; + dispatch.CompletedDate = input.CompletedDate; + dispatch.Description = input.Description; + + if (input.Status == "Completed" && dispatch.CompletedDate == null) + dispatch.CompletedDate = DateTime.UtcNow; + + await _dispatchDataService.UpdateAsync(dispatch); + return true; + } + + public async Task AddDispatchCommentAsync(AddDispatchCommentInput input) + { + var dispatch = await _dispatchDataService.GetByIdWithVendorAndWorkOrderAsync(input.DispatchId); + if (dispatch == null) + return null; + + var user = !string.IsNullOrEmpty(input.UserId) ? await _userDataService.GetByIdAsync(input.UserId) : null; + var senderName = user != null ? (user.FirstName + " " + user.LastName).Trim() : ""; + + var comment = new Comments + { + CreatedDate = DateTime.UtcNow, + UserId = input.UserId, + WorkerOrderId = dispatch.WorkOrderId, + DispatchId = dispatch.Id, + Commenttext = input.Text, + CommentType = "dispatcher", + Commenter = !string.IsNullOrWhiteSpace(senderName) ? senderName : null, + }; + + await _commentDataService.AddAsync(comment); + + if (input.SendEmail && dispatch.Vendor?.Email != null && dispatch.WorkOrder != null) + { + var subject = $"[WO-{dispatch.WorkOrder.InternalWONumber}-{dispatch.DispatchNumber}] {dispatch.WorkOrder.WorkerOrderTitle}"; + var html = $"

{input.Text}


{senderName}
Sea Haven Industries


Reply to this email to respond.

"; + await _emailPort.SendDispatchEmailAsync(dispatch.Vendor.Email, subject, html, dispatch.ReplyToAddress); + } + + return new AddDispatchCommentResult + { + Id = comment.Id, + CreatedDate = comment.CreatedDate, + Commenttext = comment.Commenttext, + CommentType = comment.CommentType, + UserName = user != null ? (user.FirstName + " " + user.LastName).Trim() : "" + }; + } + + public async Task VerifyDispatchAsync(int dispatchId, string userId) + { + var dispatch = await _dispatchDataService.GetByIdAsync(dispatchId); + if (dispatch == null) + return null; + + var checklistItems = await _dispatchDataService.GetChecklistItemsAsync(dispatchId); + var incompleteItems = checklistItems.Count(c => !c.IsCompleted); + + var signoffs = await _dispatchDataService.GetSignoffsAsync(dispatchId); + var signoffList = signoffs.ToList(); + var signoffCount = signoffList.Count; + var existingTypes = signoffList.Select(s => s.SignoffType).ToList(); + + var missing = new List(); + if (incompleteItems > 0) + missing.Add($"{incompleteItems} checklist item(s) incomplete"); + if (signoffCount < 2) + { + if (!existingTypes.Contains("Customer")) missing.Add("Missing Customer sign-off"); + if (!existingTypes.Contains("Vendor")) missing.Add("Missing Vendor sign-off"); + } + + if (missing.Count > 0) + throw new DispatchVerificationException(missing); + + var user = userId != null ? await _userDataService.GetByIdAsync(userId) : null; + var userName = user != null ? (user.FirstName + " " + user.LastName).Trim() : ""; + + dispatch.Status = "Verified"; + dispatch.VerifiedBy = userName; + dispatch.VerifiedAt = DateTime.UtcNow; + + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + UserId = userId, + FieldName = $"Dispatch {dispatch.DispatchNumber}", + OldValue = dispatch.Status ?? "", + NewValue = "Verified", + Action = "dispatch_verified", + CreatedAt = DateTime.UtcNow + }); + + await _dispatchDataService.UpdateAsync(dispatch); + + return new VerifyDispatchResult + { + Message = "Dispatch verified", + VerifiedBy = userName, + VerifiedAt = dispatch.VerifiedAt + }; + } + + public async Task AddDispatchSignoffAsync(AddDispatchSignoffInput input) + { + var dispatch = await _dispatchDataService.GetByIdAsync(input.DispatchId); + if (dispatch == null) + return null; + + var existing = await _dispatchDataService.HasSignoffTypeAsync(input.DispatchId, input.SignoffType!); + if (existing) + throw new InvalidOperationException($"{input.SignoffType} sign-off already exists"); + + var signoff = new DispatchSignoff + { + DispatchId = input.DispatchId, + SignoffType = input.SignoffType, + Name = input.Name, + Signature = input.Signature, + SignatureMethod = input.SignatureMethod, + SignedAt = DateTime.UtcNow + }; + await _dispatchDataService.AddSignoffAsync(signoff); + + return new AddDispatchSignoffResult + { + Id = signoff.Id, + SignoffType = signoff.SignoffType, + Name = signoff.Name, + SignedAt = signoff.SignedAt, + SignatureMethod = signoff.SignatureMethod + }; + } + + public async Task UpdateChecklistItemAsync(ChecklistItemUpdateInput input) + { + var item = await _dispatchDataService.GetChecklistItemByIdAsync(input.Id); + if (item == null) + return null; + + item.IsCompleted = input.IsCompleted; + item.CompletedBy = input.IsCompleted ? input.CompletedBy : null; + item.CompletedAt = input.IsCompleted ? DateTime.UtcNow : null; + await _dispatchDataService.UpdateChecklistItemAsync(item); + + return new ChecklistItemUpdateResult + { + Message = "Updated", + Id = item.Id, + IsCompleted = item.IsCompleted, + CompletedBy = item.CompletedBy, + CompletedAt = item.CompletedAt + }; + } + + public async Task AddChecklistItemAsync(AddChecklistItemInput input) + { + var checklistItems = await _dispatchDataService.GetChecklistItemsAsync(input.DispatchId); + var maxSort = checklistItems.Any() ? checklistItems.Max(c => c.SortOrder) : -1; + + var item = new DispatchChecklistItem + { + DispatchId = input.DispatchId, + WorkOrderId = input.WorkOrderId, + ItemText = input.ItemText, + SortOrder = maxSort + 1, + IsCompleted = false + }; + await _dispatchDataService.AddChecklistItemAsync(item); + + return new AddChecklistItemResult + { + Id = item.Id, + ItemText = item.ItemText, + SortOrder = item.SortOrder, + IsCompleted = item.IsCompleted + }; + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs b/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs index 442fbb3..5bb006c 100644 --- a/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs @@ -1,47 +1,24 @@ -using Data.SeaHavenIndustries; -using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation { public class WorkOrderFieldLockService : IWorkOrderFieldLockService { - private readonly ApplicationDbContext _context; + private readonly IWorkOrderFieldLockDataService _dataService; - public WorkOrderFieldLockService(ApplicationDbContext context) + public WorkOrderFieldLockService(IWorkOrderFieldLockDataService dataService) { - _context = context; + _dataService = dataService; } - public async Task IsLockedAsync(int workOrderId, string fieldName) + public Task IsLockedAsync(int workOrderId, string fieldName) + => _dataService.IsLockedAsync(workOrderId, fieldName, CancellationToken.None); + + public Task LockFieldAsync(int workOrderId, string fieldName, string? userId) { - if (IsTracked(workOrderId, fieldName)) - return true; - - return await _context.WorkOrderFieldLocks - .AnyAsync(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName); + _dataService.EnqueueLock(workOrderId, fieldName, userId); + return Task.CompletedTask; } - - public async Task LockFieldAsync(int workOrderId, string fieldName, string? userId) - { - if (IsTracked(workOrderId, fieldName)) - return; - - if (await IsLockedAsync(workOrderId, fieldName)) - return; - - _context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock - { - WorkOrderId = workOrderId, - FieldName = fieldName, - LockedAt = DateTime.UtcNow, - LockedByUserId = userId - }); - } - - private bool IsTracked(int workOrderId, string fieldName) - => _context.WorkOrderFieldLocks.Local.Any(l => - l.WorkOrderId == workOrderId && - string.Equals(l.FieldName, fieldName, StringComparison.OrdinalIgnoreCase)); } } diff --git a/SeaHaven.Services/Implementation/WorkOrderService.cs b/SeaHaven.Services/Implementation/WorkOrderService.cs index 19fefc3..88632dc 100644 --- a/SeaHaven.Services/Implementation/WorkOrderService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderService.cs @@ -2,6 +2,7 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentValidation; using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; using SeaHaven.Services.Validation; @@ -11,15 +12,27 @@ namespace SeaHaven.Services.Implementation public class WorkOrderService : IWorkOrderService { private readonly IWorkOrderDataService _workOrderDataService; + private readonly ICommentDataService _commentDataService; + private readonly IUserDataService _userDataService; + private readonly IQuotesDataService _quotesDataService; + private readonly IFileStoragePort _fileStorage; private readonly ICreateWorkOrderValidation _createValidator; private readonly IUpdateWorkOrderValidation _updateValidator; public WorkOrderService( IWorkOrderDataService workOrderDataService, + ICommentDataService commentDataService, + IUserDataService userDataService, + IQuotesDataService quotesDataService, + IFileStoragePort fileStorage, ICreateWorkOrderValidation createValidator, IUpdateWorkOrderValidation updateValidator) { _workOrderDataService = workOrderDataService; + _commentDataService = commentDataService; + _userDataService = userDataService; + _quotesDataService = quotesDataService; + _fileStorage = fileStorage; _createValidator = createValidator; _updateValidator = updateValidator; } @@ -53,13 +66,12 @@ namespace SeaHaven.Services.Implementation PageSize = pageSize }; } + public async Task CreateWorkOrderAsync(CreateWorkOrderDTO dto, string userId) { var validationResult = await _createValidator.ValidateAsync(dto); if (!validationResult.IsValid) - { throw new ValidationException(validationResult.Errors); - } var workOrder = CreateWorkOrderDTO.MapToEntity(dto); workOrder.createdby = userId; @@ -67,9 +79,7 @@ namespace SeaHaven.Services.Implementation workOrder.InternalWONumber = await _workOrderDataService.GenerateNextInternalWONumberAsync(); var saved = await _workOrderDataService.AddAsync(workOrder); - await LogAuditAsync(saved.Id, userId, AuditAction.Create, "Work order created"); - return saved.Id; } @@ -77,9 +87,7 @@ namespace SeaHaven.Services.Implementation { var validationResult = await _updateValidator.ValidateAsync(dto); if (!validationResult.IsValid) - { throw new ValidationException(validationResult.Errors); - } var existingWorkOrder = await _workOrderDataService.GetByIdAsync(id); if (existingWorkOrder == null) @@ -92,9 +100,7 @@ namespace SeaHaven.Services.Implementation await _workOrderDataService.UpdateAsync(existingWorkOrder); if (changes.Any()) - { await LogAuditAsync(id, userId, AuditAction.Edit, string.Join(", ", changes)); - } return true; } @@ -108,61 +114,502 @@ namespace SeaHaven.Services.Implementation await _workOrderDataService.DeleteWorkOrderContactsAsync(id); await _workOrderDataService.DeleteWorkOrderCategoriesAsync(id); await _workOrderDataService.DeleteAsync(id); - await LogAuditAsync(id, userId, AuditAction.Delete, "Work order deleted"); + return true; + } + + public async Task WorkOrderExistsAsync(int id) => await _workOrderDataService.ExistsAsync(id); + public async Task GenerateNextInternalWONumberAsync() => await _workOrderDataService.GenerateNextInternalWONumberAsync(); + public async Task GetTotalWorkOrderCountAsync() => await _workOrderDataService.CountAsync(); + + public async Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( + int page, int pageSize, string? search, string? status, string? assignee, + int? locationId, string? sortBy, string? sortDir) + { + return await _workOrderDataService.GetWorkOrderListPagedAsync( + page, pageSize, search, status, assignee, locationId, sortBy, sortDir); + } + + public async Task GetWorkOrderDetailAsync(int id) + { + return await _workOrderDataService.GetWorkOrderDetailAsync(id); + } + + public async Task GetFilteredWorkOrdersAsync( + string[] assignto, int[]? location, string[]? priority, + string[]? status, string[]? duedate, string search, + string sort, string sortby, int page, int pageSize) + { + var allWorkOrders = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(); + + var data = allWorkOrders.AsEnumerable(); + + if (!string.IsNullOrEmpty(search)) + { + var s = search.ToLower(); + data = data.Where(w => + (w.WorkerOrderTitle ?? "").ToLower().Contains(s) || + (w.WorkerOrderNumber ?? "").ToLower().Contains(s) || + (w.Locations != null && (w.Locations.Title ?? "").ToLower().Contains(s))); + } + + if (assignto != null && assignto.Length > 0 && assignto[0] != "" && assignto[0] != null) + data = data.Where(w => assignto.Contains(w.AssignTo)); + + if (location != null && location.Length > 0 && location[0] != 0) + data = data.Where(w => location.Contains(w.LocationId ?? 0)); + + if (priority != null && priority.Length > 0 && priority[0] != "" && priority[0] != null) + data = data.Where(w => priority.Contains(w.Priority)); + + if (status != null && status.Length > 0 && status[0] != "" && status[0] != null) + data = data.Where(w => status.Contains(w.Status)); + + var dataList = data.ToList(); + + dataList = dataList.Where(w => + duedate == null || duedate.Length == 0 || duedate[0] == null || + duedate.Any(dd => w.DueDate.HasValue && CheckDueDate(dd, w.DueDate.Value.Date, DateTime.UtcNow.Date))).ToList(); + + if (!string.IsNullOrEmpty(sort) && !string.IsNullOrEmpty(sortby) && sortby != "") + { + switch (sortby) + { + case "Name": + dataList = sort.ToLower() == "ascending" + ? dataList.OrderBy(w => w.WorkerOrderTitle).ToList() + : dataList.OrderByDescending(w => w.WorkerOrderTitle).ToList(); + break; + case "Due Date": + dataList = sort.ToLower() == "ascending" + ? dataList.OrderBy(w => w.DueDate).ToList() + : dataList.OrderByDescending(w => w.DueDate).ToList(); + break; + case "Severity": + dataList = sort.ToLower() == "ascending" + ? dataList.OrderBy(w => w.Priority).ToList() + : dataList.OrderByDescending(w => w.Priority).ToList(); + break; + } + } + + var totalCount = dataList.Count; + var pagedData = dataList + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(s => new FilteredWorkOrderReadModel + { + Id = s.Id, + WorkerOrderTitle = s.WorkerOrderTitle, + DueDate = s.DueDate, + Status = s.Status, + Priority = s.Priority, + Name = s.Locations != null ? s.Locations.Name : "" + }) + .OrderByDescending(d => d.Name) + .ToList(); + + return new FilteredWorkOrderPageDTO + { + Data = pagedData, + PageNumber = page, + PageSize = pageSize, + TotalCount = totalCount, + TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) + }; + } + + public async Task GetFilteredWorkOrders2Async( + string[] assignto, int[]? location, string[]? priority, + string[]? status, string[]? duedate, string search, + string sort, string sortby, int page, int pageSize) + { + location ??= Array.Empty(); + priority ??= Array.Empty(); + status ??= Array.Empty(); + duedate ??= Array.Empty(); + search ??= string.Empty; + + var allWorkOrders = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(); + var totalCount = allWorkOrders.Count; + + var currentDate = DateTime.UtcNow.Date; + var data = allWorkOrders.Where(w => + (((w.WorkerOrderTitle ?? "").Contains(search, StringComparison.OrdinalIgnoreCase) || + (w.WorkerOrderNumber ?? "").Contains(search, StringComparison.OrdinalIgnoreCase) || + (w.Locations != null && + (w.Locations.Title ?? "").Contains(search, StringComparison.OrdinalIgnoreCase))) && + (assignto.Length == 0 || assignto.Contains(w.AssignTo)) && + (location.Length == 0 || (w.LocationId.HasValue && location.Contains(w.LocationId.Value))) && + (priority.Length == 0 || priority.Contains(w.Priority)) && + (status.Length == 0 || status.Contains(w.Status)))).ToList(); + + data = data.Where(w => + duedate.Length == 0 || + duedate.Any(dd => w.DueDate.HasValue && CheckDueDate(dd, w.DueDate.Value.Date, currentDate))).ToList(); + + if (!string.IsNullOrEmpty(sort) && !string.IsNullOrEmpty(sortby) && sortby != "") + { + switch (sortby) + { + case "Name": + data = sort.ToLower() == "ascending" + ? data.OrderBy(w => w.WorkerOrderTitle).ToList() + : data.OrderByDescending(w => w.WorkerOrderTitle).ToList(); + break; + case "Due Date": + data = sort.ToLower() == "ascending" + ? data.OrderBy(w => w.DueDate).ToList() + : data.OrderByDescending(w => w.DueDate).ToList(); + break; + case "Severity": + data = sort.ToLower() == "ascending" + ? data.OrderBy(w => w.Priority).ToList() + : data.OrderByDescending(w => w.Priority).ToList(); + break; + } + } + + var newdata = data + .Select(s => new FilteredWorkOrderReadModel + { + Id = s.Id, + WorkerOrderTitle = s.WorkerOrderTitle, + DueDate = s.DueDate, + Status = s.Status, + Priority = s.Priority, + Name = s.Locations != null ? s.Locations.Name : "" + }) + .OrderByDescending(d => d.Name) + .ToList() + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToList(); + + return new FilteredWorkOrderPageDTO + { + Data = newdata, + PageNumber = page, + PageSize = pageSize, + TotalCount = totalCount, + TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) + }; + } + + public async Task ChangeStatusAsync(int id, string status, string userId) + { + var workOrder = await _workOrderDataService.GetByIdAsync(id); + if (workOrder == null) return null; + + if (workOrder.Status != status) + { + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = id, + UserId = userId, + FieldName = "Status", + OldValue = workOrder.Status ?? "", + NewValue = status, + Action = AuditAction.StatusChange.ToString(), + CreatedAt = DateTime.UtcNow + }); + } + + workOrder.Status = status; + await _workOrderDataService.UpdateAsync(workOrder); + + return WorkOrderDTO.MapToModel(workOrder); + } + + public async Task ChangeAssignmentAsync(int id, string? assignTo, string userId) + { + var workOrder = await _workOrderDataService.GetByIdAsync(id); + if (workOrder == null) return null; + + if (workOrder.AssignTo != assignTo) + { + var oldUser = workOrder.AssignTo != null ? await _userDataService.GetByIdAsync(workOrder.AssignTo) : null; + var newUser = assignTo != null ? await _userDataService.GetByIdAsync(assignTo) : null; + + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = id, + UserId = userId, + FieldName = "AssignedTo", + OldValue = oldUser != null ? (oldUser.FirstName + " " + oldUser.LastName).Trim() : "", + NewValue = newUser != null ? (newUser.FirstName + " " + newUser.LastName).Trim() : "Unassigned", + Action = AuditAction.AssignmentChange.ToString(), + CreatedAt = DateTime.UtcNow + }); + } + + workOrder.AssignTo = assignTo; + await _workOrderDataService.UpdateAsync(workOrder); + + var assignedUser = assignTo != null ? await _userDataService.GetByIdAsync(assignTo) : null; + return new ChangeAssignmentResult + { + Message = "Updated Successfully", + AssignedTo = assignedUser != null ? (assignedUser.FirstName + " " + assignedUser.LastName).Trim() : "" + }; + } + + public async Task CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, string userId) + { + var createDto = new CreateWorkOrderDTO + { + WorkerOrderTitle = input.WorkerOrderTitle, + Description = input.Description, + Status = WorkOrderStatus.Open, + Priority = Enum.TryParse(input.Priority, out var p) ? p : WorkOrderPriority.Medium, + LocationId = input.LocationId, + AssignTo = input.AssignTo, + DueDate = input.DueDate + }; + + var workOrderId = await CreateWorkOrderAsync(createDto, userId); + + if (input.ContactIds != null && input.ContactIds.Any()) + await _workOrderDataService.AddWorkOrderContactsAsync(workOrderId, input.ContactIds); + + if (input.CategoryIds != null && input.CategoryIds.Any()) + await _workOrderDataService.AddWorkOrderCategoriesAsync(workOrderId, input.CategoryIds); + + await SaveAttachmentsAndPhotos(workOrderId, input.Attachments, input.BeforPhotoAttachment, + input.AfterPhotoAttachment, input.SignOffAttachment); + + return workOrderId; + } + + public async Task UpdateWorkOrderWithDetailsAsync(UpdateWorkOrderWithDetailsInput input, string userId) + { + var updateDto = new UpdateWorkOrderDTO + { + WorkerOrderTitle = input.WorkerOrderTitle, + Description = input.Description, + Status = Enum.TryParse(input.Status, out var st) ? st : null, + Priority = Enum.TryParse(input.Priority, out var pr) ? pr : null, + LocationId = input.LocationId, + AssignTo = input.AssignTo, + DueDate = input.DueDate, + CompletedDate = input.CompletedDate + }; + + var success = await UpdateWorkOrderAsync(input.Id, updateDto, userId); + if (!success) return false; + + await _workOrderDataService.DeleteWorkOrderContactsAsync(input.Id); + await _workOrderDataService.DeleteWorkOrderCategoriesAsync(input.Id); + + if (input.ContactIds != null && input.ContactIds.Any()) + await _workOrderDataService.AddWorkOrderContactsAsync(input.Id, input.ContactIds); + + if (input.CategoryIds != null && input.CategoryIds.Any()) + await _workOrderDataService.AddWorkOrderCategoriesAsync(input.Id, input.CategoryIds); + + await SaveAttachmentsAndPhotos(input.Id, input.Attachments, input.BeforPhotoAttachment, + input.AfterPhotoAttachment, input.SignOffAttachment); return true; } - public async Task WorkOrderExistsAsync(int id) + public async Task DeleteWorkOrderCascadeAsync(int id, string userId) { - return await _workOrderDataService.ExistsAsync(id); + var success = await DeleteWorkOrderAsync(id, userId); + if (!success) return false; + + await _quotesDataService.UnlinkFromWorkOrderAsync(id); + + var comments = await _commentDataService.GetByWorkOrderIdAsync(id); + foreach (var comment in comments) + await _commentDataService.DeleteAsync(comment.Id); + + return true; } - public async Task GenerateNextInternalWONumberAsync() + public async Task AddCommentAsync(AddCommentInput input, string userId) { - return await _workOrderDataService.GenerateNextInternalWONumberAsync(); + var comment = new Comments + { + UserId = userId, + WorkerOrderId = input.WorkorderId, + Commenttext = input.Text, + CommentType = input.CommentType ?? "vendor" + }; + + if (input.Document != null) + { + var fileUrl = await _fileStorage.SaveFileAsync(input.Document); + comment.Documents = fileUrl; + } + + return await _commentDataService.AddAsync(comment); } - public async Task GetTotalWorkOrderCountAsync() + public async Task AddCommentJsonAsync(AddCommentInput input, string userId) { - return await _workOrderDataService.CountAsync(); + var comment = new Comments + { + UserId = userId, + WorkerOrderId = input.WorkorderId, + Commenttext = input.Text, + CommentType = input.CommentType ?? "internal" + }; + + var saved = await _commentDataService.AddAsync(comment); + var user = await _userDataService.GetByIdAsync(userId); + + return new CommentResult + { + Id = saved.Id, + CreatedDate = saved.CreatedDate, + Commenttext = saved.Commenttext, + CommentType = saved.CommentType, + UserName = user != null ? (user.FirstName + " " + user.LastName).Trim() : "" + }; + } + + public async Task> GetCommentsAsync() + { + var comments = await _commentDataService.GetAllAsync(); + return comments.Select(s => new CommentListItemReadModel + { + Id = s.Id, + Commenttext = s.Commenttext, + FirstName = s.ApplicationUser != null ? s.ApplicationUser.FirstName : "", + Documents = s.Documents + }).ToList(); + } + + public async Task> GetCommentsByWorkorderIdAsync(int woid) + { + var comments = await _commentDataService.GetByWorkOrderIdAsync(woid); + return comments.Select(s => new CommentListItemReadModel + { + Id = s.Id, + Commenttext = s.Commenttext, + FirstName = s.ApplicationUser != null ? s.ApplicationUser.FirstName : "", + Documents = s.Documents + }).ToList(); + } + + public async Task> GetWorkordersDDAsync() + { + var data = await _workOrderDataService.GetAllAsync(); + return data.Where(w => w.istemplate != true).ToList(); + } + + public async Task> GetWorkordersAsync() + { + var workOrders = await _workOrderDataService.GetAllWithDetailsAsync(); + return workOrders.Select(s => new WorkorderFilterVM + { + Id = s.Id, + Priority = s.Priority, + Status = s.Status, + WorkerOrderNumber = s.WorkerOrderNumber, + WorkerOrderTitle = s.WorkerOrderTitle, + DueDate = s.DueDate, + AssignToUserName = s.AssignToUser != null ? s.AssignToUser.FirstName : "", + LocationName = s.Locations != null ? s.Locations.Name : "", + LocationId = s.LocationId, + PO = s.PO, + TT = s.TT + }).ToList(); + } + + private async Task SaveAttachmentsAndPhotos(int workOrderId, + List? attachments, + Microsoft.AspNetCore.Http.IFormFile? beforPhoto, + Microsoft.AspNetCore.Http.IFormFile? afterPhoto, + Microsoft.AspNetCore.Http.IFormFile? signOffPhoto) + { + if (attachments != null) + { + foreach (var attachment in attachments) + { + var fileUrl = await _fileStorage.SaveFileAsync(attachment); + await _workOrderDataService.AddWorkOrderAttachmentAsync(new WorkOrderAttachments + { + WorkorderId = workOrderId, + Attachments = fileUrl + }); + } + } + + if (beforPhoto != null) + { + var fileUrl = await _fileStorage.SaveFileAsync(beforPhoto); + var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); + if (workOrder != null) + { + workOrder.BeforPhotoAttachment = fileUrl; + await _workOrderDataService.UpdateAsync(workOrder); + } + } + + if (afterPhoto != null) + { + var fileUrl = await _fileStorage.SaveFileAsync(afterPhoto); + var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); + if (workOrder != null) + { + workOrder.AfterPhotoAttachment = fileUrl; + await _workOrderDataService.UpdateAsync(workOrder); + } + } + + if (signOffPhoto != null) + { + var fileUrl = await _fileStorage.SaveFileAsync(signOffPhoto); + var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); + if (workOrder != null) + { + workOrder.SignOffAttachment = fileUrl; + await _workOrderDataService.UpdateAsync(workOrder); + } + } + } + + private static bool CheckDueDate(string dd, DateTime dueDate, DateTime currentDate) + { + return dd switch + { + "Today" => dueDate == currentDate, + "Tomorrow" => dueDate == currentDate.AddDays(1), + "Next Week" => dueDate >= currentDate.AddDays(1) && dueDate <= currentDate.AddDays(7), + "Next Month" => dueDate >= currentDate.AddDays(1) && dueDate <= currentDate.AddMonths(1), + "Over Due" => dueDate < currentDate, + _ => false + }; } private List TrackChanges(WorkOrder existing, UpdateWorkOrderDTO dto) { var changes = new List(); - if (dto.WorkerOrderTitle != null && dto.WorkerOrderTitle != existing.WorkerOrderTitle) changes.Add($"Title: {existing.WorkerOrderTitle} → {dto.WorkerOrderTitle}"); - if (dto.Status.HasValue && dto.Status.ToString() != existing.Status) changes.Add($"Status: {existing.Status} → {dto.Status}"); - if (dto.Priority.HasValue && dto.Priority.ToString() != existing.Priority) changes.Add($"Priority: {existing.Priority} → {dto.Priority}"); - if (dto.AssignTo != null && dto.AssignTo != existing.AssignTo) changes.Add($"Assigned To: {existing.AssignTo} → {dto.AssignTo}"); - if (dto.DueDate.HasValue && dto.DueDate != existing.DueDate) changes.Add($"Due Date: {existing.DueDate:yyyy-MM-dd} → {dto.DueDate:yyyy-MM-dd}"); - return changes; } private async Task LogAuditAsync(int workOrderId, string userId, AuditAction action, string description) { - var auditLog = new WorkOrderAuditLog + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = workOrderId, Action = action.ToString(), FieldName = description, UserId = userId, CreatedAt = DateTime.UtcNow - }; - - await _workOrderDataService.AddAuditLogAsync(auditLog); + }); } } -} \ No newline at end of file +} diff --git a/SeaHaven.Services/Interfaces/IQuotesService.cs b/SeaHaven.Services/Interfaces/IQuotesService.cs new file mode 100644 index 0000000..13c2074 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IQuotesService.cs @@ -0,0 +1,16 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IQuotesService + { + Task CreateQuoteAsync(CreateQuoteInput input, CancellationToken cancellationToken); + Task UpdateQuoteAsync(UpdateQuoteInput input, CancellationToken cancellationToken); + Task DeleteQuoteAsync(int id, CancellationToken cancellationToken); + Task GetQuotesPagedAsync(int page, int pageSize, string search, CancellationToken cancellationToken); + Task> GetWorkOrderQuoteDetailsAsync( + int woId, CancellationToken cancellationToken); + Task GetQuoteDetailsByIdAsync(int id, CancellationToken cancellationToken); + Task GetReportingAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IServicePorts.cs b/SeaHaven.Services/Interfaces/IServicePorts.cs new file mode 100644 index 0000000..8050cc5 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IServicePorts.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Http; + +namespace SeaHaven.Services.Interfaces +{ + public interface IFileStoragePort + { + Task SaveFileAsync(IFormFile file); + } + + public interface IDispatchEmailPort + { + Task SendDispatchEmailAsync(string emailTo, string subject, string htmlBody, string? replyTo); + } + + public interface IVendorTokenPort + { + Task GetOrCreateActiveTokenAsync(int vendorId); + } +} diff --git a/SeaHaven.Services/Interfaces/ISyncExternalSource.cs b/SeaHaven.Services/Interfaces/ISyncExternalSource.cs new file mode 100644 index 0000000..849927b --- /dev/null +++ b/SeaHaven.Services/Interfaces/ISyncExternalSource.cs @@ -0,0 +1,9 @@ +namespace SeaHaven.Services.Interfaces +{ + public interface ISyncExternalSource + { + IAsyncEnumerable> ScanWorkOrdersAsync(CancellationToken cancellationToken); + IAsyncEnumerable> ScanCommentsAsync(CancellationToken cancellationToken); + IAsyncEnumerable> ScanVendorRepliesAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/ISyncService.cs b/SeaHaven.Services/Interfaces/ISyncService.cs new file mode 100644 index 0000000..b8f0759 --- /dev/null +++ b/SeaHaven.Services/Interfaces/ISyncService.cs @@ -0,0 +1,15 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface ISyncService + { + Task SyncWorkOrdersAsync(CancellationToken cancellationToken); + Task SyncCommentsAsync(CancellationToken cancellationToken); + Task BackfillInternalWONumbersAsync(CancellationToken cancellationToken); + Task BackfillDispatchNumbersAsync(CancellationToken cancellationToken); + Task SyncVendorRepliesAsync(CancellationToken cancellationToken); + Task BackfillCommentTypesAsync(CancellationToken cancellationToken); + Task SyncAllAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderDispatchService.cs b/SeaHaven.Services/Interfaces/IWorkOrderDispatchService.cs new file mode 100644 index 0000000..d69b2c7 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderDispatchService.cs @@ -0,0 +1,18 @@ +using SeaHaven.DataServices.Models; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderDispatchService + { + Task DispatchToVendorAsync(DispatchToVendorInput input); + Task> GetDispatchesAsync(int workOrderId); + Task GetDispatchDetailAsync(int id); + Task UpdateDispatchAsync(UpdateDispatchInput input); + Task AddDispatchCommentAsync(AddDispatchCommentInput input); + Task VerifyDispatchAsync(int dispatchId, string userId); + Task AddDispatchSignoffAsync(AddDispatchSignoffInput input); + Task UpdateChecklistItemAsync(ChecklistItemUpdateInput input); + Task AddChecklistItemAsync(AddChecklistItemInput input); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderService.cs b/SeaHaven.Services/Interfaces/IWorkOrderService.cs index 913adcf..3c60e46 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderService.cs @@ -1,3 +1,7 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Http; +using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; namespace SeaHaven.Services.Interfaces @@ -14,5 +18,27 @@ namespace SeaHaven.Services.Interfaces Task WorkOrderExistsAsync(int id); Task GenerateNextInternalWONumberAsync(); Task GetTotalWorkOrderCountAsync(); + + Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( + int page, int pageSize, string? search, string? status, string? assignee, + int? locationId, string? sortBy, string? sortDir); + Task GetWorkOrderDetailAsync(int id); + Task GetFilteredWorkOrdersAsync( + string[] assignto, int[]? location, string[]? priority, + string[]? status, string[]? duedate, string search, string sort, string sortby, int page, int pageSize); + Task GetFilteredWorkOrders2Async( + string[] assignto, int[]? location, string[]? priority, + string[]? status, string[]? duedate, string search, string sort, string sortby, int page, int pageSize); + Task ChangeStatusAsync(int id, string status, string userId); + Task ChangeAssignmentAsync(int id, string? assignTo, string userId); + Task CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, string userId); + Task UpdateWorkOrderWithDetailsAsync(UpdateWorkOrderWithDetailsInput input, string userId); + Task DeleteWorkOrderCascadeAsync(int id, string userId); + Task AddCommentAsync(AddCommentInput input, string userId); + Task AddCommentJsonAsync(AddCommentInput input, string userId); + Task> GetCommentsAsync(); + Task> GetCommentsByWorkorderIdAsync(int woid); + Task> GetWorkordersDDAsync(); + Task> GetWorkordersAsync(); } } diff --git a/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs b/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs index e9831d7..1271cdb 100644 --- a/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs +++ b/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs @@ -1,6 +1,7 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; using SeaHaven.Services.Constants; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; @@ -17,6 +18,12 @@ public class SyncFieldMergePolicyTests return new ApplicationDbContext(options); } + private static WorkOrderFieldLockService CreateLocks(ApplicationDbContext context) + => new(new WorkOrderFieldLockDataService(context)); + + private static WorkOrderAuditService CreateAudit(ApplicationDbContext context, WorkOrderFieldLockService locks) + => new(new WorkOrderAuditDataService(context), locks); + [Fact] public async Task TryApply_WhenFieldLocked_RejectsAndAudits() { @@ -31,8 +38,8 @@ public class SyncFieldMergePolicyTests }); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, locks); + var locks = CreateLocks(context); + var audit = CreateAudit(context, locks); var policy = new SyncFieldMergePolicy(locks); var syncContext = new WorkOrderSyncContext @@ -61,8 +68,8 @@ public class SyncFieldMergePolicyTests context.workOrders.Add(wo); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, locks); + var locks = CreateLocks(context); + var audit = CreateAudit(context, locks); var policy = new SyncFieldMergePolicy(locks); var syncContext = new WorkOrderSyncContext @@ -86,8 +93,8 @@ public class SyncFieldMergePolicyTests context.workOrders.Add(wo); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, locks); + var locks = CreateLocks(context); + var audit = CreateAudit(context, locks); var policy = new SyncFieldMergePolicy(locks); var syncContext = new WorkOrderSyncContext @@ -113,8 +120,8 @@ public class SyncFieldMergePolicyTests context.workOrders.Add(wo); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, locks); + var locks = CreateLocks(context); + var audit = CreateAudit(context, locks); var policy = new SyncFieldMergePolicy(locks); var syncContext = new WorkOrderSyncContext @@ -141,7 +148,7 @@ public class SyncFieldMergePolicyTests }); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); + var locks = CreateLocks(context); var policy = new SyncFieldMergePolicy(locks); var canOverwrite = await policy.CanSyncOverwriteAsync(20, WorkOrderFieldNames.AssignTo); diff --git a/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs index f7df5ed..7cc862c 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs @@ -1,6 +1,6 @@ using Data.SeaHavenIndustries; -using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; using SeaHaven.Services.Constants; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; @@ -17,6 +17,15 @@ public class WorkOrderAuditServiceTests return new ApplicationDbContext(options); } + private static (WorkOrderAuditService audit, ApplicationDbContext context) CreateAudit(ApplicationDbContext context) + { + var lockData = new WorkOrderFieldLockDataService(context); + var locks = new WorkOrderFieldLockService(lockData); + var auditData = new WorkOrderAuditDataService(context); + var audit = new WorkOrderAuditService(auditData, locks); + return (audit, context); + } + [Fact] public async Task LogStatusChanged_CreatesSingleAuditWithManualEventType() { @@ -24,8 +33,7 @@ public class WorkOrderAuditServiceTests context.workOrders.Add(new WorkOrder { Id = 1, Status = "Open" }); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, locks); + var (audit, _) = CreateAudit(context); await audit.LogStatusChangedAsync(1, "Open", "In Progress", "user-1"); @@ -44,8 +52,7 @@ public class WorkOrderAuditServiceTests context.workOrders.Add(new WorkOrder { Id = 2, Status = "Open" }); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, locks); + var (audit, _) = CreateAudit(context); await audit.LogStatusChangedAsync(2, "Open", "Scheduled", "user-1"); diff --git a/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs index b598a4d..d9a6a39 100644 --- a/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; using SeaHaven.Services.Constants; using SeaHaven.Services.Implementation; @@ -19,7 +20,8 @@ public class WorkOrderFieldLockServiceTests public async Task LockFieldAsync_DuplicateCalls_EnqueuesSingleLock() { await using var context = CreateContext(); - var locks = new WorkOrderFieldLockService(context); + var lockData = new WorkOrderFieldLockDataService(context); + var locks = new WorkOrderFieldLockService(lockData); await locks.LockFieldAsync(1, WorkOrderFieldNames.Description, "user-1"); await locks.LockFieldAsync(1, WorkOrderFieldNames.Description, "user-2"); @@ -43,7 +45,8 @@ public class WorkOrderFieldLockServiceTests }); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); + var lockData = new WorkOrderFieldLockDataService(context); + var locks = new WorkOrderFieldLockService(lockData); await locks.LockFieldAsync(2, WorkOrderFieldNames.AssignTo, "user-2"); await context.SaveChangesAsync();