From e0139d4601008b7fb927664838427529f892b90e Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 10 Sep 2026 15:46:25 -0300 Subject: [PATCH] feat(work-orders): capture Site/Vendor/POC snapshot on Completed Freeze effective live values on first Completed transition and project them on GET. --- ...60910143000_WorkOrderCompletionSnapshot.cs | 41 ++++ .../ApplicationDbContextModelSnapshot.cs | 9 + .../Models/WorkOrderCompletionSnapshot.cs | 40 ++++ Data.SeaHavenIndustries/Models/WorkerOrder.cs | 6 + .../Helpers/WorkOrderBoardProjection.cs | 55 +++++- .../WorkOrderBoardMutationDataService.cs | 8 +- .../Interfaces/WorkOrderBoardModels.cs | 6 +- SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 41 ++++ .../WorkOrderCompletionSnapshotMapper.cs | 155 ++++++++++++++++ .../Implementation/WorkOrderBoardService.cs | 71 ++++++- .../WorkOrderBoardUpdateService.cs | 3 + .../Implementation/WorkOrderDetailService.cs | 3 + .../WorkOrderBoardUpdateServiceTests.cs | 4 + .../WorkOrderCompletionFreezeTests.cs | 175 ++++++++++++++++++ 14 files changed, 606 insertions(+), 11 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260910143000_WorkOrderCompletionSnapshot.cs create mode 100644 Data.SeaHavenIndustries/Models/WorkOrderCompletionSnapshot.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderCompletionSnapshotMapper.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderCompletionFreezeTests.cs diff --git a/Data.SeaHavenIndustries/Migrations/20260910143000_WorkOrderCompletionSnapshot.cs b/Data.SeaHavenIndustries/Migrations/20260910143000_WorkOrderCompletionSnapshot.cs new file mode 100644 index 0000000..cc1f531 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260910143000_WorkOrderCompletionSnapshot.cs @@ -0,0 +1,41 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260910143000_WorkOrderCompletionSnapshot")] + public partial class WorkOrderCompletionSnapshot : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FrozenCompany", + table: "workOrders", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "FrozenPoc", + table: "workOrders", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "FrozenSite", + table: "workOrders", + type: "nvarchar(max)", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "FrozenCompany", table: "workOrders"); + migrationBuilder.DropColumn(name: "FrozenPoc", table: "workOrders"); + migrationBuilder.DropColumn(name: "FrozenSite", table: "workOrders"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 5d86b49..bc39339 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2494,6 +2494,15 @@ namespace Data.SeaHavenIndustries.Migrations .HasMaxLength(7) .HasColumnType("nvarchar(7)"); + b.Property("FrozenCompany") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenPoc") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenSite") + .HasColumnType("nvarchar(max)"); + b.Property("InternalWONumber") .HasMaxLength(11) .HasColumnType("nvarchar(11)"); diff --git a/Data.SeaHavenIndustries/Models/WorkOrderCompletionSnapshot.cs b/Data.SeaHavenIndustries/Models/WorkOrderCompletionSnapshot.cs new file mode 100644 index 0000000..98e02ae --- /dev/null +++ b/Data.SeaHavenIndustries/Models/WorkOrderCompletionSnapshot.cs @@ -0,0 +1,40 @@ +namespace Data.SeaHavenIndustries +{ + public sealed class WorkOrderFrozenSite + { + public string? Code { get; set; } + public string? Label { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? Maps { get; set; } + } + + public sealed class WorkOrderFrozenCompany + { + public string? Name { get; set; } + public string? ContactName { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? Maps { get; set; } + } + + public sealed class WorkOrderFrozenPoc + { + public List Contacts { get; set; } = new(); + public string? Notes { get; set; } + } + + public sealed class WorkOrderFrozenPocContact + { + public string? Name { get; set; } + public string? Phone { get; set; } + } +} diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 5e87a2e..3f71f87 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -93,6 +93,12 @@ namespace Data.SeaHavenIndustries // --- Completion slice --- public DocStatus? DocStatus { get; set; } + /// JSON site snapshot captured on the first transition to Completed. + public string? FrozenSite { get; set; } + /// JSON live-vendor snapshot captured on the first transition to Completed. + public string? FrozenCompany { get; set; } + /// JSON effective POC snapshot captured on the first transition to Completed. + public string? FrozenPoc { get; set; } /// Board flag color (#RRGGBB from fixed palette). Null = no flag. [MaxLength(7)] diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index fcc94b5..04beecf 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -2,11 +2,15 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; +using System.Text.Json; namespace SeaHaven.DataServices.Helpers { public static class WorkOrderBoardProjection { + private static readonly JsonSerializerOptions SnapshotJsonOptions = + new(JsonSerializerDefaults.Web); + public static async Task> ProjectRowsAsync( ApplicationDbContext context, IQueryable query, @@ -81,6 +85,9 @@ namespace SeaHaven.DataServices.Helpers w.AdditionalContacts, w.DocStatus, w.CompletedDate, + w.FrozenSite, + w.FrozenCompany, + w.FrozenPoc, w.FlagColor, w.PrimaryDispatchId, w.RowVersion, @@ -96,9 +103,22 @@ namespace SeaHaven.DataServices.Helpers return rows.Select(w => { - var pocName = !string.IsNullOrWhiteSpace(w.WoPocName) ? w.WoPocName : w.ContactPoc?.Name; - var pocPhone = !string.IsNullOrWhiteSpace(w.WoPocPhone) ? w.WoPocPhone : w.ContactPoc?.PhoneNumber; - var pocNotes = !string.IsNullOrWhiteSpace(w.WoPocNotes) ? w.WoPocNotes : w.ContactPoc?.Notes; + var frozenSite = w.LifecycleStatus == LifecycleStatus.Completed + ? Deserialize(w.FrozenSite) + : null; + var frozenCompany = w.LifecycleStatus == LifecycleStatus.Completed + ? Deserialize(w.FrozenCompany) + : null; + var frozenPoc = w.LifecycleStatus == LifecycleStatus.Completed + ? Deserialize(w.FrozenPoc) + : null; + var primaryFrozenPoc = frozenPoc?.Contacts.FirstOrDefault(); + var pocName = primaryFrozenPoc?.Name + ?? (!string.IsNullOrWhiteSpace(w.WoPocName) ? w.WoPocName : w.ContactPoc?.Name); + var pocPhone = primaryFrozenPoc?.Phone + ?? (!string.IsNullOrWhiteSpace(w.WoPocPhone) ? w.WoPocPhone : w.ContactPoc?.PhoneNumber); + var pocNotes = frozenPoc?.Notes + ?? (!string.IsNullOrWhiteSpace(w.WoPocNotes) ? w.WoPocNotes : w.ContactPoc?.Notes); var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone) ? w.DispatchTechPhone : (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone); @@ -106,6 +126,7 @@ namespace SeaHaven.DataServices.Helpers w.VendorId, w.VendorName, w.PrimaryDispatchStatus); + vendorName = frozenCompany?.Name ?? vendorName; upliftByWorkOrder.TryGetValue(w.Id, out var uplift); return new WorkOrderBoardRawRow( @@ -115,8 +136,8 @@ namespace SeaHaven.DataServices.Helpers w.CarriedOver, w.IsAddOn, w.WorkOrderType, - w.SiteCode, - w.LocationName, + frozenSite?.Code ?? w.SiteCode, + frozenSite?.Label ?? w.LocationName, pocName, pocPhone, pocNotes, @@ -135,8 +156,8 @@ namespace SeaHaven.DataServices.Helpers w.ScheduleWeekOnly, vendorId, vendorName, - w.TechName, - techPhone, + frozenCompany?.ContactName ?? w.TechName, + frozenCompany?.Phone ?? techPhone, w.DispatchApptDate, w.Trade ?? w.Problem, w.Problem, @@ -160,10 +181,28 @@ namespace SeaHaven.DataServices.Helpers w.OriginalWeek, isUnscheduled, w.Severity, - w.MediaCount); + w.MediaCount, + frozenSite, + frozenCompany, + frozenPoc); }).ToList(); } + private static T? Deserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return default; + + try + { + return JsonSerializer.Deserialize(json, SnapshotJsonOptions); + } + catch (JsonException) + { + return default; + } + } + /// /// SH-188: one round-trip for live uplifts on WOs in /// (primary WorkOrderId and multi-WO DispatchWorkOrders links). Uses the same diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs index 3ad3869..81e0fda 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs @@ -20,7 +20,13 @@ namespace SeaHaven.DataServices.Implementation => await _context.workOrders.MaxAsync(w => (int?)w.Id, cancellationToken) ?? 0; public Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) - => _context.workOrders.FirstOrDefaultAsync( + => _context.workOrders + .Include(workOrder => workOrder.Locations) + .Include(workOrder => workOrder.WorkOrderContacts!) + .ThenInclude(contact => contact.POC) + .Include(workOrder => workOrder.PrimaryDispatch!) + .ThenInclude(dispatch => dispatch.Vendor) + .FirstOrDefaultAsync( w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null), cancellationToken); diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index 6e4348b..bd19e54 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -1,3 +1,4 @@ +using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; namespace SeaHaven.DataServices.Interfaces @@ -65,7 +66,10 @@ namespace SeaHaven.DataServices.Interfaces DateOnly? OriginalWeek, bool IsUnscheduled, string? Severity = null, - int MediaCount = 0); + int MediaCount = 0, + WorkOrderFrozenSite? FrozenSite = null, + WorkOrderFrozenCompany? FrozenCompany = null, + WorkOrderFrozenPoc? FrozenPoc = null); public record WorkOrderBoardQueryResult( IReadOnlyList ScheduledRows, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index 4ef1c62..2a408f7 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -34,6 +34,44 @@ namespace SeaHaven.Services.DTOs public decimal? Amount { get; set; } } + public class WorkOrderFrozenSiteDto + { + public string? Code { get; set; } + public string? Label { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? Maps { get; set; } + } + + public class WorkOrderFrozenCompanyDto + { + public string? Name { get; set; } + public string? ContactName { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? Maps { get; set; } + } + + public class WorkOrderFrozenPocDto + { + public List Contacts { get; set; } = new(); + public string? Notes { get; set; } + } + + public class WorkOrderFrozenPocContactDto + { + public string? Name { get; set; } + public string? Phone { get; set; } + } + public class WorkOrderBoardRowDto { public int Id { get; set; } @@ -53,6 +91,9 @@ namespace SeaHaven.Services.DTOs public string? PocPhone { get; set; } public string? PocNotes { get; set; } public List? AdditionalContacts { get; set; } + public WorkOrderFrozenSiteDto? FrozenSite { get; set; } + public WorkOrderFrozenCompanyDto? FrozenCompany { get; set; } + public WorkOrderFrozenPocDto? FrozenPoc { get; set; } public LifecycleStatus? LifecycleStatus { get; set; } public string? LifecycleStatusLabel { get; set; } public string? LegacyStatus { get; set; } diff --git a/SeaHaven.Services/Helpers/WorkOrderCompletionSnapshotMapper.cs b/SeaHaven.Services/Helpers/WorkOrderCompletionSnapshotMapper.cs new file mode 100644 index 0000000..cdcfffc --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderCompletionSnapshotMapper.cs @@ -0,0 +1,155 @@ +using System.Globalization; +using System.Text.Json; +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Helpers; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderCompletionSnapshotMapper + { + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public static void Capture(WorkOrder workOrder) + { + var location = workOrder.Locations + ?? throw new WorkOrderBoardValidationException( + "CompletionSnapshotUnavailable", + "Site details are required before marking the work order Completed."); + + var contact = workOrder.WorkOrderContacts? + .OrderBy(item => item.Id) + .FirstOrDefault(); + var pocName = FirstNotBlank( + workOrder.PocName, + JoinName(contact?.POC?.FirstName, contact?.POC?.MiddleName, contact?.POC?.LastName)); + var pocPhone = FirstNotBlank(workOrder.PocPhone, contact?.POC?.PhoneNumber); + var pocNotes = FirstNotBlank(workOrder.PocNotes, contact?.Notes); + + var contacts = new List(); + AddContact(contacts, pocName, pocPhone); + foreach (var additional in WorkOrderAdditionalContactsMapper.ParseJson(workOrder.AdditionalContacts) + ?? Enumerable.Empty()) + { + AddContact(contacts, additional.Name, additional.Phone); + } + + workOrder.FrozenSite = JsonSerializer.Serialize( + new WorkOrderFrozenSite + { + Code = workOrder.SiteCode, + Label = FirstNotBlank(location.Name, location.Title, workOrder.SiteCode), + Address = JoinAddress(location.Address1, location.Address2), + City = location.City, + State = location.State, + Zip = location.Zip, + Phone = location.PhoneNumber, + Email = location.Email, + Maps = BuildMapsUrl(location) + }, + JsonOptions); + + var liveVendor = ResolveLiveVendor(workOrder.PrimaryDispatch); + workOrder.FrozenCompany = liveVendor == null + ? null + : JsonSerializer.Serialize( + new WorkOrderFrozenCompany + { + Name = liveVendor.CompanyName, + ContactName = liveVendor.ContactName, + Address = liveVendor.Address, + City = liveVendor.City, + State = liveVendor.State, + Zip = liveVendor.Zip, + Phone = FirstNotBlank(liveVendor.CompanyPhone, liveVendor.Phone), + Email = liveVendor.Email, + Maps = liveVendor.GoogleMapsUrl + }, + JsonOptions); + + workOrder.FrozenPoc = JsonSerializer.Serialize( + new WorkOrderFrozenPoc + { + Contacts = contacts, + Notes = pocNotes + }, + JsonOptions); + } + + public static WorkOrderFrozenSite? ParseSite(string? json) + => Deserialize(json); + + public static WorkOrderFrozenCompany? ParseCompany(string? json) + => Deserialize(json); + + public static WorkOrderFrozenPoc? ParsePoc(string? json) + => Deserialize(json); + + private static Vendor? ResolveLiveVendor(Dispatch? dispatch) + { + var (vendorId, _) = WorkOrderBoardDispatchAssignment.LiveVendor( + dispatch?.VendorId, + dispatch?.Vendor?.CompanyName, + dispatch?.Status); + return vendorId.HasValue ? dispatch?.Vendor : null; + } + + private static string? BuildMapsUrl(Locations location) + { + if (!double.TryParse(location.Latitude, NumberStyles.Float, CultureInfo.InvariantCulture, out var latitude) + || !double.TryParse(location.Longitude, NumberStyles.Float, CultureInfo.InvariantCulture, out var longitude)) + { + return null; + } + + return FormattableString.Invariant( + $"https://www.google.com/maps/search/?api=1&query={latitude},{longitude}"); + } + + private static string? JoinAddress(params string?[] parts) + { + var value = string.Join(", ", parts.Where(part => !string.IsNullOrWhiteSpace(part))); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static string? JoinName(params string?[] parts) + { + var value = string.Join(" ", parts.Where(part => !string.IsNullOrWhiteSpace(part))); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static string? FirstNotBlank(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim(); + + private static void AddContact( + ICollection contacts, + string? name, + string? phone) + { + if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(phone)) + return; + + contacts.Add(new WorkOrderFrozenPocContact + { + Name = name?.Trim(), + Phone = phone?.Trim() + }); + } + + private static T? Deserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return default; + + try + { + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (JsonException) + { + return default; + } + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 3d73bde..1f19a3b 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; @@ -113,6 +114,9 @@ namespace SeaHaven.Services.Implementation PocName = row.PocName, PocPhone = row.PocPhone, PocNotes = row.PocNotes, + FrozenSite = MapFrozenSite(row.FrozenSite), + FrozenCompany = MapFrozenCompany(row.FrozenCompany), + FrozenPoc = MapFrozenPoc(row.FrozenPoc), LifecycleStatus = row.LifecycleStatus, LifecycleStatusLabel = WorkOrderDerivedFields.GetLifecycleStatusLabel(row.LifecycleStatus), LegacyStatus = row.LegacyStatus, @@ -135,7 +139,7 @@ namespace SeaHaven.Services.Implementation Pm = row.Trade ?? row.Problem, ServiceNotes = row.ServiceNotes, ExtraServices = ParseExtraServices(row.ExtraServices), - AdditionalContacts = WorkOrderAdditionalContactsMapper.ParseJson(row.AdditionalContacts), + AdditionalContacts = MapAdditionalContacts(row), DocStatus = row.DocStatus, MediaCount = row.MediaCount, CompletedDate = row.CompletedDate, @@ -149,6 +153,71 @@ namespace SeaHaven.Services.Implementation }; } + private static List? MapAdditionalContacts( + WorkOrderBoardRawRow row) + { + if (row.FrozenPoc == null) + return WorkOrderAdditionalContactsMapper.ParseJson(row.AdditionalContacts); + + var additional = row.FrozenPoc.Contacts + .Skip(1) + .Where(contact => !string.IsNullOrWhiteSpace(contact.Name) + || !string.IsNullOrWhiteSpace(contact.Phone)) + .Select(contact => new WorkOrderAdditionalContactDto( + contact.Name ?? "", + contact.Phone ?? "", + null)) + .ToList(); + return additional.Count > 0 ? additional : null; + } + + private static WorkOrderFrozenSiteDto? MapFrozenSite(WorkOrderFrozenSite? site) + => site == null + ? null + : new WorkOrderFrozenSiteDto + { + Code = site.Code, + Label = site.Label, + Address = site.Address, + City = site.City, + State = site.State, + Zip = site.Zip, + Phone = site.Phone, + Email = site.Email, + Maps = site.Maps + }; + + private static WorkOrderFrozenCompanyDto? MapFrozenCompany(WorkOrderFrozenCompany? company) + => company == null + ? null + : new WorkOrderFrozenCompanyDto + { + Name = company.Name, + ContactName = company.ContactName, + Address = company.Address, + City = company.City, + State = company.State, + Zip = company.Zip, + Phone = company.Phone, + Email = company.Email, + Maps = company.Maps + }; + + private static WorkOrderFrozenPocDto? MapFrozenPoc(WorkOrderFrozenPoc? poc) + => poc == null + ? null + : new WorkOrderFrozenPocDto + { + Notes = poc.Notes, + Contacts = poc.Contacts + .Select(contact => new WorkOrderFrozenPocContactDto + { + Name = contact.Name, + Phone = contact.Phone + }) + .ToList() + }; + private static bool HasClockTime(DateTime? value) => value.HasValue && value.Value.TimeOfDay != TimeSpan.Zero; diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 49a63cb..18604b2 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -530,6 +530,9 @@ namespace SeaHaven.Services.Implementation var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status; if (workOrder.LifecycleStatus != parsed) { + if (parsed == LifecycleStatus.Completed) + WorkOrderCompletionSnapshotMapper.Capture(workOrder); + workOrder.LifecycleStatus = parsed; workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(parsed); if (workOrder.LegacyStatus == null && workOrder.Status != null) diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs index 641c26e..9e77a69 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -106,6 +106,9 @@ namespace SeaHaven.Services.Implementation PocPhone = row.PocPhone, PocNotes = row.PocNotes, AdditionalContacts = row.AdditionalContacts, + FrozenSite = row.FrozenSite, + FrozenCompany = row.FrozenCompany, + FrozenPoc = row.FrozenPoc, LifecycleStatus = row.LifecycleStatus, LifecycleStatusLabel = row.LifecycleStatusLabel, LegacyStatus = row.LegacyStatus, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 7f29f3b..975c58a 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -397,11 +397,13 @@ public class WorkOrderBoardUpdateServiceTests var wo = new WorkOrder { Id = 1, + LocationId = 10, LifecycleStatus = LifecycleStatus.Scheduled, DueDate = DateTime.UtcNow.Date.AddDays(-3), ScheduledDate = DateTime.UtcNow.Date.AddDays(-1), RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }; + context.Locations.Add(new Locations { Id = 10, Name = "Test Site" }); context.workOrders.Add(wo); await context.SaveChangesAsync(); @@ -435,12 +437,14 @@ public class WorkOrderBoardUpdateServiceTests var wo = new WorkOrder { Id = 1, + LocationId = 10, InternalWONumber = "SH00001", WorkerOrderNumber = "SH00001", LifecycleStatus = LifecycleStatus.Scheduled, ScheduledDate = DateTime.UtcNow.Date.AddDays(1), RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }; + context.Locations.Add(new Locations { Id = 10, Name = "Test Site" }); context.workOrders.Add(wo); await context.SaveChangesAsync(); diff --git a/SeaHavenIndustries.Tests/WorkOrderCompletionFreezeTests.cs b/SeaHavenIndustries.Tests/WorkOrderCompletionFreezeTests.cs new file mode 100644 index 0000000..c41d1b7 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderCompletionFreezeTests.cs @@ -0,0 +1,175 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Constants; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderCompletionFreezeTests +{ + [Fact] + public async Task FirstCompletedTransitionCapturesSnapshotAndLaterPatchKeepsIt() + { + await using var context = CreateContext(); + var service = CreateService(context); + var location = new Locations + { + Id = 10, + Name = "BK5 Chicago", + Address1 = "100 Main St", + City = "Chicago", + State = "IL", + Zip = "60601", + PhoneNumber = "312-555-0100", + Email = "site@example.com" + }; + var vendor = new Vendor + { + Id = 20, + CompanyName = "Frozen Vendor", + ContactName = "Frozen Tech", + Address = "200 Vendor Ave", + City = "Chicago", + State = "IL", + Zip = "60602", + CompanyPhone = "312-555-0200", + Email = "vendor@example.com" + }; + var contact = new Contacts + { + Id = 30, + FirstName = "Frozen", + LastName = "POC", + PhoneNumber = "312-555-0300" + }; + var dispatch = new Dispatch + { + Id = 40, + WorkOrderId = 1, + VendorId = vendor.Id, + Vendor = vendor, + Status = "Sent" + }; + var workOrder = new WorkOrder + { + Id = 1, + InternalWONumber = "00000012345", + SiteCode = "BK5", + LocationId = location.Id, + Locations = location, + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = DateTime.UtcNow.Date.AddDays(1), + PrimaryDispatchId = dispatch.Id, + PrimaryDispatch = dispatch, + PocNotes = "Call first", + AdditionalContacts = """[{"name":"Backup","phone":"312-555-0400"}]""", + RowVersion = Version() + }; + var workOrderContact = new WorkOrderContacts + { + Id = 50, + WorkorderId = workOrder.Id, + WorkOrder = workOrder, + ContactId = contact.Id, + POC = contact + }; + workOrder.WorkOrderContacts = new[] { workOrderContact }; + context.AddRange(location, vendor, contact, dispatch, workOrder, workOrderContact); + await context.SaveChangesAsync(); + + var completed = await service.PatchFieldAsync( + workOrder.Id, + Patch(WorkOrderBoardFieldNames.LifecycleStatus, "Complete", workOrder), + "actor-1"); + var persisted = await context.workOrders.SingleAsync(); + var frozenSiteJson = persisted.FrozenSite; + var frozenCompanyJson = persisted.FrozenCompany; + var frozenPocJson = persisted.FrozenPoc; + + Assert.Equal(LifecycleStatus.Completed, completed.LifecycleStatus); + Assert.Equal("BK5 Chicago", completed.LocationName); + Assert.Equal("Frozen Vendor", completed.VendorName); + Assert.Equal("Frozen POC", completed.PocName); + Assert.Equal("Chicago", completed.FrozenSite?.City); + Assert.Equal("Frozen Vendor", completed.FrozenCompany?.Name); + Assert.Equal(2, completed.FrozenPoc?.Contacts.Count); + + location.Name = "Changed Site"; + location.City = "Milwaukee"; + vendor.CompanyName = "Changed Vendor"; + contact.FirstName = "Changed"; + await context.SaveChangesAsync(); + + var afterFlag = await service.PatchFieldAsync( + workOrder.Id, + Patch(WorkOrderBoardFieldNames.FlagColor, "#EF4444", persisted), + "actor-1"); + + Assert.Equal(frozenSiteJson, persisted.FrozenSite); + Assert.Equal(frozenCompanyJson, persisted.FrozenCompany); + Assert.Equal(frozenPocJson, persisted.FrozenPoc); + Assert.Equal("BK5 Chicago", afterFlag.LocationName); + Assert.Equal("Frozen Vendor", afterFlag.VendorName); + Assert.Equal("Frozen POC", afterFlag.PocName); + } + + [Fact] + public async Task OpenWorkOrderProjectionKeepsUsingLiveLocation() + { + await using var context = CreateContext(); + var location = new Locations { Id = 10, Name = "Live Site", City = "Chicago" }; + var workOrder = new WorkOrder + { + Id = 1, + SiteCode = "BK5", + LocationId = location.Id, + Locations = location, + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = DateTime.UtcNow.Date.AddDays(1), + FrozenSite = """{"code":"BK5","label":"Frozen Site","city":"Old City"}""", + RowVersion = Version() + }; + context.AddRange(location, workOrder); + await context.SaveChangesAsync(); + + var raw = await new WorkOrderBoardDataService(context).GetBoardRowByIdAsync(workOrder.Id); + var dto = WorkOrderBoardService.MapRawRow(Assert.IsType(raw), DateTime.UtcNow); + + Assert.Equal("Live Site", dto.LocationName); + Assert.Null(dto.FrozenSite); + } + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrderBoardUpdateService CreateService(ApplicationDbContext context) + { + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + return new WorkOrderBoardUpdateService( + new WorkOrderBoardDataService(context), + new WorkOrderBoardMutationDataService(context), + new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks)); + } + + private static WorkOrderBoardPatchRequestDto Patch( + string field, + string value, + WorkOrder workOrder) + => new() + { + Field = field, + Value = value, + WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion ?? Version()) + }; + + private static byte[] Version() => new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }; +}