fix(workorders): compare manual POC against the followed contact, not any site contact

The manual POC "follow the site" clear-rule compared the edit against every
live Site contact. A work order only ever displays one of them, so editing to
a different live contact (Site has Alice primary and Bob; WO shows Alice; edit
to Bob) matched, cleared the override, and left the row showing Alice with no
audit row written — the SH-379 symptom on a different input. A work order with
a linked WorkOrderContacts POC hit the same bug when the dispatcher typed the
Site's primary: the override cleared and the linked contact showed instead.

Compare the edit against the single contact the work order actually follows —
the linked WorkOrderContacts POC, or else the Site primary (ResolvePrimary) —
matching the board projection's override -> linked -> site precedence, and
store the override whenever the edit differs from it. The create path in
WorkOrderBoardCreateService had the same any-contact rule and gets the same
fix; a supplied PocContactId that is not a live Site contact leaves no follow
target, so the typed POC is stored.

Replaces MatchesAnySiteContact with Matches(name, phone, contact); comment and
PR-body wording updated to state the followed-contact rule. Adds tests for the
second-site-contact edit, the linked-contact-differs edit, and the
second-site-contact create case.
This commit is contained in:
Alexandre Brandizzi 2026-09-18 18:56:29 -03:00
parent 503faa8c25
commit 582af8fb2c
4 changed files with 161 additions and 24 deletions

View file

@ -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.
/// </summary>
public static class WorkOrderPocSiteContact
{
@ -28,24 +28,21 @@ namespace SeaHaven.Services.Helpers
: Normalize(((contact.FirstName ?? "") + " " + (contact.LastName ?? "")).Trim());
/// <summary>
/// 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.
/// </summary>
public static bool MatchesAnySiteContact(
string? name,
string? phone,
IEnumerable<Contacts>? 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)

View file

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

View file

@ -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).
/// </summary>
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;

View file

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