diff --git a/SeaHaven.Services/Helpers/WorkOrderPocSiteContact.cs b/SeaHaven.Services/Helpers/WorkOrderPocSiteContact.cs
index 6fafb0d..207a46b 100644
--- a/SeaHaven.Services/Helpers/WorkOrderPocSiteContact.cs
+++ b/SeaHaven.Services/Helpers/WorkOrderPocSiteContact.cs
@@ -6,7 +6,7 @@ namespace SeaHaven.Services.Helpers
/// SH-379 / SH-190: single resolution for "the Site's live contact" used by the
/// manual POC override. The same rules back the board projection fallback, the
/// completion snapshot fallback, the create-time baseline, and the
- /// "changing away from the Site's live contact" lock comparison.
+ /// "changing away from the contact the work order follows" lock comparison.
///
public static class WorkOrderPocSiteContact
{
@@ -28,24 +28,21 @@ namespace SeaHaven.Services.Helpers
: Normalize(((contact.FirstName ?? "") + " " + (contact.LastName ?? "")).Trim());
///
- /// SH-190: a manual POC equal to one of the Site's live contacts (trimmed
- /// name + phone) is not a change away from the Site and must not lock.
+ /// SH-190: true when a manual POC (trimmed name + phone) equals the single
+ /// contact the work order currently follows — the linked WorkOrderContacts
+ /// POC, or else the Site primary. Such an edit is not a change away from
+ /// that contact, so it must not lock or store an override. Comparing against
+ /// only the followed contact (rather than any live Site contact) means an
+ /// edit to a different live contact is still stored, since the work order
+ /// only ever displays the one it follows.
///
- public static bool MatchesAnySiteContact(
- string? name,
- string? phone,
- IEnumerable? contacts)
+ public static bool Matches(string? name, string? phone, Contacts? contact)
{
- if (contacts == null)
+ if (contact == null)
return false;
- var normalizedName = Normalize(name);
- var normalizedPhone = Normalize(phone);
-
- return contacts
- .Where(c => c.IsDeleted != true)
- .Any(c => string.Equals(Normalize(((c.FirstName ?? "") + " " + (c.LastName ?? "")).Trim()), normalizedName, StringComparison.Ordinal)
- && string.Equals(Normalize(c.PhoneNumber), normalizedPhone, StringComparison.Ordinal));
+ return string.Equals(DisplayName(contact), Normalize(name), StringComparison.Ordinal)
+ && string.Equals(Normalize(contact.PhoneNumber), Normalize(phone), StringComparison.Ordinal);
}
private static string? Normalize(string? value)
diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
index a7fa4bc..dd2d06f 100644
--- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
@@ -67,11 +67,21 @@ namespace SeaHaven.Services.Implementation
var pocName = TrimOrNull(request.PocName);
var pocPhone = TrimOrNull(request.PocPhone);
var pocNotes = TrimOrNull(request.PocNotes);
- // SH-190 AC3: a create-time POC equal to one of the Site's live contacts
- // is not an override. Store nothing so the work order keeps following
- // the Site's live contact until a dispatcher edits away from it.
+ // SH-190 AC3: a create-time POC equal to the single contact the work
+ // order will follow is not an override. Store nothing so the work order
+ // keeps following that contact until a dispatcher edits away from it.
+ // The followed contact is the linked POC (PocContactId) when one is
+ // supplied, otherwise the Site primary. Comparing against the followed
+ // contact rather than any live Site contact means a create-time POC that
+ // matches a *different* Site contact is stored, since the board only
+ // shows the one the work order follows. A supplied PocContactId that is
+ // not among the Site's live contacts leaves no follow target, so the
+ // typed POC is stored.
var siteContacts = await _pocData.GetSiteContactsAsync(request.LocationId, cancellationToken);
- if (WorkOrderPocSiteContact.MatchesAnySiteContact(pocName, pocPhone, siteContacts))
+ var followContact = request.PocContactId.HasValue
+ ? siteContacts.FirstOrDefault(c => c.Id == request.PocContactId.Value)
+ : WorkOrderPocSiteContact.ResolvePrimary(siteContacts);
+ if (WorkOrderPocSiteContact.Matches(pocName, pocPhone, followContact))
{
pocName = null;
pocPhone = null;
diff --git a/SeaHaven.Services/Implementation/WorkOrderPocService.cs b/SeaHaven.Services/Implementation/WorkOrderPocService.cs
index ba47748..866dddc 100644
--- a/SeaHaven.Services/Implementation/WorkOrderPocService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderPocService.cs
@@ -13,7 +13,8 @@ namespace SeaHaven.Services.Implementation
/// value is stored in the WO-level PocName/PocPhone/PocNotes fields, staged as
/// FieldChanged audit entries (which also write field locks so later syncs never
/// overwrite a manual POC, SH-190 AC2), and captured by the completion freeze.
- /// An edit equal to one of the Site's live contacts stores no override, so a
+ /// An edit equal to the contact the work order currently follows (its linked
+ /// WorkOrderContacts POC, or else the Site primary) stores no override, so a
/// never-overridden work order keeps following the Site (SH-190 AC3).
///
public class WorkOrderPocService : IWorkOrderPocService
@@ -75,13 +76,19 @@ namespace SeaHaven.Services.Implementation
var newPhone = TrimOrNull(request.PocPhone);
var newNotes = TrimOrNull(request.PocNotes);
- // SH-190: changing away from the Site's live contact locks the POC.
- // An edit that equals one of the Site's live contacts (and blanking
- // both fields) stores no override, so the work order follows the Site.
+ // SH-190: changing away from the contact the work order follows
+ // locks the POC. An edit that equals that single contact — the
+ // linked WorkOrderContacts POC, or else the Site primary — (and
+ // blanking both fields) stores no override, so the work order keeps
+ // following it. Comparing against the followed contact rather than
+ // any live Site contact means editing to a *different* Site contact
+ // is stored as an override, since the board only ever shows the one
+ // the work order follows.
+ var followContact = linkedContact ?? sitePrimary;
var overrideName = newName;
var overridePhone = newPhone;
if ((overrideName != null || overridePhone != null)
- && WorkOrderPocSiteContact.MatchesAnySiteContact(overrideName, overridePhone, workOrder.Locations?.Contacts))
+ && WorkOrderPocSiteContact.Matches(overrideName, overridePhone, followContact))
{
overrideName = null;
overridePhone = null;
diff --git a/SeaHavenIndustries.Tests/WorkOrderPocServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderPocServiceTests.cs
index 8ac314f..ad1ffa2 100644
--- a/SeaHavenIndustries.Tests/WorkOrderPocServiceTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderPocServiceTests.cs
@@ -402,6 +402,129 @@ public class WorkOrderPocServiceTests
Assert.True(row.PocCustomized);
}
+ [Fact]
+ public async Task EditToSecondSiteContactStoresOverride()
+ {
+ await using var context = CreateContext();
+ await SeedScopeAsync(context);
+ var workOrder = NewWorkOrder(context);
+ await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100", order: 0);
+ await SeedSiteContactAsync(context, firstName: "Bob", lastName: "Backup", phone: "312-555-0200", order: 1);
+ await context.SaveChangesAsync();
+
+ // The WO follows the Site primary (Alice). Editing to the second live Site
+ // contact (Bob) is a change away from what the board shows and must be
+ // stored and locked, not silently cleared back to Alice.
+ var service = CreatePocService(context);
+ var row = await service.UpdatePocAsync(
+ workOrder.Id,
+ Poc("Bob Backup", "312-555-0200", null, workOrder),
+ WorkOrderAccountTestHelpers.AccountUser(),
+ "actor-1");
+
+ var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
+ Assert.Equal("Bob Backup", persisted.PocName);
+ Assert.Equal("312-555-0200", persisted.PocPhone);
+ Assert.Equal("Bob Backup", row.PocName);
+ Assert.Equal("312-555-0200", row.PocPhone);
+ Assert.True(row.PocCustomized);
+
+ var auditName = await context.WorkOrderAuditLogs.SingleAsync(a =>
+ a.WorkOrderId == workOrder.Id && a.FieldName == "PocName");
+ Assert.Equal("Alice Site", auditName.OldValue);
+ Assert.Equal("Bob Backup", auditName.NewValue);
+ Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l =>
+ l.WorkOrderId == workOrder.Id && l.FieldName == "PocName"));
+ Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l =>
+ l.WorkOrderId == workOrder.Id && l.FieldName == "PocPhone"));
+ }
+
+ [Fact]
+ public async Task EditToSitePrimaryWhenLinkedContactDiffersStoresOverride()
+ {
+ await using var context = CreateContext();
+ await SeedScopeAsync(context);
+ var workOrder = NewWorkOrder(context);
+ await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100", order: 0);
+ var linked = new Contacts
+ {
+ FirstName = "Carol",
+ LastName = "Linked",
+ PhoneNumber = "312-555-0300"
+ };
+ context.Contacts.Add(linked);
+ await context.SaveChangesAsync();
+ context.WorkOrderContacts.Add(new WorkOrderContacts
+ {
+ WorkorderId = workOrder.Id,
+ ContactId = linked.Id
+ });
+ await context.SaveChangesAsync();
+
+ // The WO follows its linked contact (Carol). Typing the Site's primary
+ // (Alice) is a change away from what the board shows and must be stored,
+ // not cleared back to Carol.
+ var service = CreatePocService(context);
+ var row = await service.UpdatePocAsync(
+ workOrder.Id,
+ Poc("Alice Site", "312-555-0100", null, workOrder),
+ WorkOrderAccountTestHelpers.AccountUser(),
+ "actor-1");
+
+ var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
+ Assert.Equal("Alice Site", persisted.PocName);
+ Assert.Equal("312-555-0100", persisted.PocPhone);
+ Assert.Equal("Alice Site", row.PocName);
+ Assert.Equal("312-555-0100", row.PocPhone);
+ Assert.True(row.PocCustomized);
+
+ var auditName = await context.WorkOrderAuditLogs.SingleAsync(a =>
+ a.WorkOrderId == workOrder.Id && a.FieldName == "PocName");
+ Assert.Equal("Carol Linked", auditName.OldValue);
+ Assert.Equal("Alice Site", auditName.NewValue);
+ }
+
+ [Fact]
+ public async Task BoardCreateWithPocEqualToSecondSiteContactIsStored()
+ {
+ await using var context = CreateContext();
+ WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
+ context.Contacts.Add(new Contacts
+ {
+ FirstName = "Alice",
+ LastName = "Site",
+ PhoneNumber = "312-555-0100",
+ LocationId = 1,
+ SiteContactOrder = 0
+ });
+ context.Contacts.Add(new Contacts
+ {
+ FirstName = "Bob",
+ LastName = "Backup",
+ PhoneNumber = "312-555-0200",
+ LocationId = 1,
+ SiteContactOrder = 1
+ });
+ await context.SaveChangesAsync();
+
+ // Create-time POC equal to the second Site contact (Bob) is not the contact
+ // the WO would follow (the primary, Alice), so it must be stored.
+ var service = CreateCreateService(context);
+ var row = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
+ {
+ WorkOrderType = WorkOrderType.PM,
+ SiteCode = "BK5",
+ LocationId = 1,
+ PocName = "Bob Backup",
+ PocPhone = "312-555-0200"
+ }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
+
+ var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
+ Assert.Equal("Bob Backup", persisted.PocName);
+ Assert.Equal("312-555-0200", persisted.PocPhone);
+ Assert.True(row.PocCustomized);
+ }
+
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder()