mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
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:
parent
503faa8c25
commit
582af8fb2c
4 changed files with 161 additions and 24 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue