mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-03 21:23:21 +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
|
/// 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
|
/// manual POC override. The same rules back the board projection fallback, the
|
||||||
/// completion snapshot fallback, the create-time baseline, and 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>
|
/// </summary>
|
||||||
public static class WorkOrderPocSiteContact
|
public static class WorkOrderPocSiteContact
|
||||||
{
|
{
|
||||||
|
|
@ -28,24 +28,21 @@ namespace SeaHaven.Services.Helpers
|
||||||
: Normalize(((contact.FirstName ?? "") + " " + (contact.LastName ?? "")).Trim());
|
: Normalize(((contact.FirstName ?? "") + " " + (contact.LastName ?? "")).Trim());
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SH-190: a manual POC equal to one of the Site's live contacts (trimmed
|
/// SH-190: true when a manual POC (trimmed name + phone) equals the single
|
||||||
/// name + phone) is not a change away from the Site and must not lock.
|
/// 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>
|
/// </summary>
|
||||||
public static bool MatchesAnySiteContact(
|
public static bool Matches(string? name, string? phone, Contacts? contact)
|
||||||
string? name,
|
|
||||||
string? phone,
|
|
||||||
IEnumerable<Contacts>? contacts)
|
|
||||||
{
|
{
|
||||||
if (contacts == null)
|
if (contact == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var normalizedName = Normalize(name);
|
return string.Equals(DisplayName(contact), Normalize(name), StringComparison.Ordinal)
|
||||||
var normalizedPhone = Normalize(phone);
|
&& string.Equals(Normalize(contact.PhoneNumber), Normalize(phone), StringComparison.Ordinal);
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? Normalize(string? value)
|
private static string? Normalize(string? value)
|
||||||
|
|
|
||||||
|
|
@ -67,11 +67,21 @@ namespace SeaHaven.Services.Implementation
|
||||||
var pocName = TrimOrNull(request.PocName);
|
var pocName = TrimOrNull(request.PocName);
|
||||||
var pocPhone = TrimOrNull(request.PocPhone);
|
var pocPhone = TrimOrNull(request.PocPhone);
|
||||||
var pocNotes = TrimOrNull(request.PocNotes);
|
var pocNotes = TrimOrNull(request.PocNotes);
|
||||||
// SH-190 AC3: a create-time POC equal to one of the Site's live contacts
|
// SH-190 AC3: a create-time POC equal to the single contact the work
|
||||||
// is not an override. Store nothing so the work order keeps following
|
// order will follow is not an override. Store nothing so the work order
|
||||||
// the Site's live contact until a dispatcher edits away from it.
|
// 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);
|
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;
|
pocName = null;
|
||||||
pocPhone = null;
|
pocPhone = null;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
/// value is stored in the WO-level PocName/PocPhone/PocNotes fields, staged as
|
/// 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
|
/// 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.
|
/// 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).
|
/// never-overridden work order keeps following the Site (SH-190 AC3).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class WorkOrderPocService : IWorkOrderPocService
|
public class WorkOrderPocService : IWorkOrderPocService
|
||||||
|
|
@ -75,13 +76,19 @@ namespace SeaHaven.Services.Implementation
|
||||||
var newPhone = TrimOrNull(request.PocPhone);
|
var newPhone = TrimOrNull(request.PocPhone);
|
||||||
var newNotes = TrimOrNull(request.PocNotes);
|
var newNotes = TrimOrNull(request.PocNotes);
|
||||||
|
|
||||||
// SH-190: changing away from the Site's live contact locks the POC.
|
// SH-190: changing away from the contact the work order follows
|
||||||
// An edit that equals one of the Site's live contacts (and blanking
|
// locks the POC. An edit that equals that single contact — the
|
||||||
// both fields) stores no override, so the work order follows the Site.
|
// 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 overrideName = newName;
|
||||||
var overridePhone = newPhone;
|
var overridePhone = newPhone;
|
||||||
if ((overrideName != null || overridePhone != null)
|
if ((overrideName != null || overridePhone != null)
|
||||||
&& WorkOrderPocSiteContact.MatchesAnySiteContact(overrideName, overridePhone, workOrder.Locations?.Contacts))
|
&& WorkOrderPocSiteContact.Matches(overrideName, overridePhone, followContact))
|
||||||
{
|
{
|
||||||
overrideName = null;
|
overrideName = null;
|
||||||
overridePhone = null;
|
overridePhone = null;
|
||||||
|
|
|
||||||
|
|
@ -402,6 +402,129 @@ public class WorkOrderPocServiceTests
|
||||||
Assert.True(row.PocCustomized);
|
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()
|
private static ApplicationDbContext CreateContext()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue