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