Merge pull request #113 from Sea-Haven-Industries/feat/SH-191-completion-freeze
Some checks failed
Validate and deploy / Validate deployable source bundle (push) Has been cancelled
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Has been cancelled
Validate and deploy / Deploy shoc-backend-staging to Elastic Beanstalk (push) Has been cancelled

feat(work-orders): capture Site/Vendor/POC snapshot on Completed
This commit is contained in:
Arthur Bassi 2026-09-14 11:03:06 -03:00 • committed by GitHub
commit eb9b7eb50e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 634 additions and 11 deletions

View file

@ -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<string>(
name: "FrozenCompany",
table: "workOrders",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FrozenPoc",
table: "workOrders",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
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");
}
}
}

View file

@ -2494,6 +2494,15 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(7)
.HasColumnType("nvarchar(7)");
b.Property<string>("FrozenCompany")
.HasColumnType("nvarchar(max)");
b.Property<string>("FrozenPoc")
.HasColumnType("nvarchar(max)");
b.Property<string>("FrozenSite")
.HasColumnType("nvarchar(max)");
b.Property<string>("InternalWONumber")
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");

View file

@ -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<WorkOrderFrozenPocContact> Contacts { get; set; } = new();
public string? Notes { get; set; }
}
public sealed class WorkOrderFrozenPocContact
{
public string? Name { get; set; }
public string? Phone { get; set; }
}
}

View file

@ -93,6 +93,12 @@ namespace Data.SeaHavenIndustries
// --- Completion slice ---
public DocStatus? DocStatus { get; set; }
/// <summary>JSON site snapshot captured on the first transition to Completed.</summary>
public string? FrozenSite { get; set; }
/// <summary>JSON live-vendor snapshot captured on the first transition to Completed.</summary>
public string? FrozenCompany { get; set; }
/// <summary>JSON effective POC snapshot captured on the first transition to Completed.</summary>
public string? FrozenPoc { get; set; }
/// <summary>Board flag color (#RRGGBB from fixed palette). Null = no flag.</summary>
[MaxLength(7)]

View file

@ -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<IReadOnlyList<WorkOrderBoardRawRow>> ProjectRowsAsync(
ApplicationDbContext context,
IQueryable<WorkOrder> 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<WorkOrderFrozenSite>(w.FrozenSite)
: null;
var frozenCompany = w.LifecycleStatus == LifecycleStatus.Completed
? Deserialize<WorkOrderFrozenCompany>(w.FrozenCompany)
: null;
var frozenPoc = w.LifecycleStatus == LifecycleStatus.Completed
? Deserialize<WorkOrderFrozenPoc>(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<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return default;
try
{
return JsonSerializer.Deserialize<T>(json, SnapshotJsonOptions);
}
catch (JsonException)
{
return default;
}
}
/// <summary>
/// SH-188: one round-trip for live uplifts on WOs in <paramref name="workOrders"/>
/// (primary WorkOrderId and multi-WO DispatchWorkOrders links). Uses the same

View file

@ -20,7 +20,13 @@ namespace SeaHaven.DataServices.Implementation
=> await _context.workOrders.MaxAsync(w => (int?)w.Id, cancellationToken) ?? 0;
public Task<WorkOrder?> 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);

View file

@ -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<WorkOrderBoardRawRow> ScheduledRows,

View file

@ -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<WorkOrderFrozenPocContactDto> 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<WorkOrderAdditionalContactDto>? 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; }

View file

@ -0,0 +1,151 @@
using System.Globalization;
using System.Text.Json;
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Helpers;
using SeaHaven.Services.DTOs;
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;
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<WorkOrderFrozenPocContact>();
AddContact(contacts, pocName, pocPhone);
foreach (var additional in WorkOrderAdditionalContactsMapper.ParseJson(workOrder.AdditionalContacts)
?? Enumerable.Empty<WorkOrderAdditionalContactDto>())
{
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 = location == null ? null : 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<WorkOrderFrozenSite>(json);
public static WorkOrderFrozenCompany? ParseCompany(string? json)
=> Deserialize<WorkOrderFrozenCompany>(json);
public static WorkOrderFrozenPoc? ParsePoc(string? json)
=> Deserialize<WorkOrderFrozenPoc>(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<WorkOrderFrozenPocContact> 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<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return default;
try
{
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
catch (JsonException)
{
return default;
}
}
}
}

View file

@ -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<WorkOrderAdditionalContactDto>? 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;

View file

@ -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)

View file

@ -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,

View file

@ -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();

View file

@ -0,0 +1,207 @@
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 FirstCompletedTransitionWithoutSiteStillCapturesAvailableSnapshot()
{
await using var context = CreateContext();
var service = CreateService(context);
var workOrder = new WorkOrder
{
Id = 1,
InternalWONumber = "00000012345",
SiteCode = "BK5",
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = DateTime.UtcNow.Date.AddDays(1),
RowVersion = Version()
};
context.Add(workOrder);
await context.SaveChangesAsync();
var completed = await service.PatchFieldAsync(
workOrder.Id,
Patch(WorkOrderBoardFieldNames.LifecycleStatus, "Complete", workOrder),
"actor-1");
var persisted = await context.workOrders.SingleAsync();
Assert.Equal(LifecycleStatus.Completed, completed.LifecycleStatus);
Assert.Equal("BK5", completed.FrozenSite?.Code);
Assert.Equal("BK5", completed.FrozenSite?.Label);
Assert.Null(completed.FrozenSite?.City);
Assert.Null(completed.FrozenCompany);
Assert.False(string.IsNullOrWhiteSpace(persisted.FrozenSite));
Assert.Null(persisted.FrozenCompany);
}
[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<SeaHaven.DataServices.Interfaces.WorkOrderBoardRawRow>(raw), DateTime.UtcNow);
Assert.Equal("Live Site", dto.LocationName);
Assert.Null(dto.FrozenSite);
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.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 };
}