mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 17:43:12 +00:00
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.
176 lines
9.1 KiB
C#
176 lines
9.1 KiB
C#
using System.Security.Claims;
|
|
using Data.SeaHavenIndustries;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Exceptions;
|
|
using SeaHaven.Services.Helpers;
|
|
using SeaHaven.Services.Interfaces;
|
|
|
|
namespace SeaHaven.Services.Implementation
|
|
{
|
|
/// <summary>
|
|
/// SH-379: persists a manual POC override on an existing work order. The manual
|
|
/// 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 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
|
|
{
|
|
private readonly IWorkOrderPocDataService _pocData;
|
|
private readonly IWorkOrderBoardMutationDataService _mutationData;
|
|
private readonly IWorkOrderBoardService _boardService;
|
|
private readonly IWorkOrderAuditService _auditService;
|
|
private readonly IWorkOrderAccountResolver _accountResolver;
|
|
|
|
public WorkOrderPocService(
|
|
IWorkOrderPocDataService pocData,
|
|
IWorkOrderBoardMutationDataService mutationData,
|
|
IWorkOrderBoardService boardService,
|
|
IWorkOrderAuditService auditService,
|
|
IWorkOrderAccountResolver accountResolver)
|
|
{
|
|
_pocData = pocData;
|
|
_mutationData = mutationData;
|
|
_boardService = boardService;
|
|
_auditService = auditService;
|
|
_accountResolver = accountResolver;
|
|
}
|
|
|
|
public async Task<WorkOrderBoardRowDto> UpdatePocAsync(
|
|
int workOrderId,
|
|
WorkOrderPocUpdateRequestDto request,
|
|
ClaimsPrincipal user,
|
|
string? actorId)
|
|
{
|
|
var accountId = _accountResolver.ResolveAccountFilter(user);
|
|
|
|
var workOrderVersion = ParseRowVersion(request.WorkOrderVersion);
|
|
if (workOrderVersion == null)
|
|
throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required.");
|
|
|
|
ValidateLengths(request);
|
|
|
|
await _mutationData.ExecuteTransactionalAsync(async ct =>
|
|
{
|
|
var workOrder = await _pocData.GetTrackedWorkOrderAsync(workOrderId, accountId, ct);
|
|
if (workOrder == null)
|
|
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
|
|
|
if (!RowVersionsMatch(workOrder.RowVersion, workOrderVersion))
|
|
throw new WorkOrderBoardConcurrencyException(await LoadBoardRowAsync(workOrderId, user));
|
|
|
|
if (WorkOrderBoardMutationRules.IsFullyLocked(workOrder.LifecycleStatus))
|
|
throw new WorkOrderBoardValidationException("CanceledReadOnly", "Work order is read-only in its current status.");
|
|
if (WorkOrderBoardMutationRules.IsCoreLocked(workOrder.LifecycleStatus))
|
|
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
|
|
|
|
var sitePrimary = WorkOrderPocSiteContact.ResolvePrimary(workOrder.Locations?.Contacts);
|
|
var linkedContact = workOrder.WorkOrderContacts?
|
|
.OrderBy(item => item.Id)
|
|
.FirstOrDefault()?.POC;
|
|
|
|
var newName = TrimOrNull(request.PocName);
|
|
var newPhone = TrimOrNull(request.PocPhone);
|
|
var newNotes = TrimOrNull(request.PocNotes);
|
|
|
|
// 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.Matches(overrideName, overridePhone, followContact))
|
|
{
|
|
overrideName = null;
|
|
overridePhone = null;
|
|
}
|
|
|
|
var oldEffectiveName = FirstNotBlank(workOrder.PocName, ContactDisplayName(linkedContact), WorkOrderPocSiteContact.DisplayName(sitePrimary));
|
|
var oldEffectivePhone = FirstNotBlank(workOrder.PocPhone, linkedContact?.PhoneNumber, sitePrimary?.PhoneNumber);
|
|
var newEffectiveName = FirstNotBlank(overrideName, ContactDisplayName(linkedContact), WorkOrderPocSiteContact.DisplayName(sitePrimary));
|
|
var newEffectivePhone = FirstNotBlank(overridePhone, linkedContact?.PhoneNumber, sitePrimary?.PhoneNumber);
|
|
|
|
var storedChanged = !string.Equals(workOrder.PocName, overrideName, StringComparison.Ordinal)
|
|
|| !string.Equals(workOrder.PocPhone, overridePhone, StringComparison.Ordinal)
|
|
|| !string.Equals(workOrder.PocNotes, newNotes, StringComparison.Ordinal);
|
|
|
|
var oldNotes = workOrder.PocNotes;
|
|
workOrder.PocName = overrideName;
|
|
workOrder.PocPhone = overridePhone;
|
|
workOrder.PocNotes = newNotes;
|
|
|
|
if (!storedChanged)
|
|
return;
|
|
|
|
_mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion);
|
|
|
|
// Audit effective old -> new values with the same field names create
|
|
// uses, so the trail reads consistently across create and update.
|
|
if (!string.Equals(oldEffectiveName, newEffectiveName, StringComparison.Ordinal))
|
|
await _auditService.StageFieldChangedAsync(workOrderId, "PocName", oldEffectiveName, newEffectiveName, actorId);
|
|
if (!string.Equals(oldEffectivePhone, newEffectivePhone, StringComparison.Ordinal))
|
|
await _auditService.StageFieldChangedAsync(workOrderId, "PocPhone", oldEffectivePhone, newEffectivePhone, actorId);
|
|
if (!string.Equals(TrimOrNull(oldNotes), newNotes, StringComparison.Ordinal))
|
|
await _auditService.StageFieldChangedAsync(workOrderId, "PocNotes", TrimOrNull(oldNotes), newNotes, actorId);
|
|
|
|
var outcome = await _mutationData.SaveAsync(ct);
|
|
if (outcome == BoardSaveOutcome.ConcurrencyConflict)
|
|
throw new WorkOrderBoardConcurrencyException(await LoadBoardRowAsync(workOrderId, user));
|
|
}, CancellationToken.None);
|
|
|
|
var row = await LoadBoardRowAsync(workOrderId, user);
|
|
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
|
}
|
|
|
|
private async Task<WorkOrderBoardRowDto?> LoadBoardRowAsync(int workOrderId, ClaimsPrincipal user)
|
|
=> await _boardService.GetBoardRowAsync(workOrderId, user);
|
|
|
|
private static void ValidateLengths(WorkOrderPocUpdateRequestDto request)
|
|
{
|
|
if (request.PocName?.Length > 200)
|
|
throw new WorkOrderBoardValidationException("InvalidValue", "pocName must be at most 200 characters.");
|
|
if (request.PocPhone?.Length > 50)
|
|
throw new WorkOrderBoardValidationException("InvalidValue", "pocPhone must be at most 50 characters.");
|
|
if (request.PocNotes?.Length > 2000)
|
|
throw new WorkOrderBoardValidationException("InvalidValue", "pocNotes must be at most 2000 characters.");
|
|
}
|
|
|
|
private static string? ContactDisplayName(Contacts? contact)
|
|
=> contact == null
|
|
? null
|
|
: TrimOrNull(((contact.FirstName ?? "") + " " + (contact.LastName ?? "")).Trim());
|
|
|
|
private static string? FirstNotBlank(params string?[] values)
|
|
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
|
|
|
|
private static string? TrimOrNull(string? value)
|
|
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
|
|
|
private static byte[]? ParseRowVersion(string? base64)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(base64))
|
|
return null;
|
|
|
|
try
|
|
{
|
|
return Convert.FromBase64String(base64);
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
throw new WorkOrderBoardValidationException("InvalidRowVersion", "Invalid workOrderVersion format.");
|
|
}
|
|
}
|
|
|
|
private static bool RowVersionsMatch(byte[]? current, byte[] expected)
|
|
=> current != null && current.AsSpan().SequenceEqual(expected);
|
|
}
|
|
}
|