mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
Editing a work order's POC never reached the backend: no update path wrote
PocName/PocPhone/PocNotes, so the optimistic UI edit was lost on refetch and
the completion freeze captured the Site contact instead of the manual value,
and nothing was audited.
- Add tenant-scoped PATCH api/workorders/{id}/poc via new WorkOrderPocService
+ WorkOrderPocDataService: persists the override, stages FieldChanged audit
entries (which also write field locks so sync never overwrites a manual POC),
and enforces row-version concurrency and terminal-status read-only rules.
- Lock semantics (SH-190): a manual POC away from the Site's live contacts is
stored WO-level; an edit equal to a live Site contact (or blanking name+phone)
stores nothing so the WO follows the Site. PocCustomized exposes the state.
- Board projection, completion freeze and create now share one Site-contact
fallback (first non-deleted contact by SiteContactOrder) so a never-overridden
WO keeps following the Site, including at create when the wizard prefills it.
- Route contract baseline gains PATCH {id:int}/poc.
169 lines
8.6 KiB
C#
169 lines
8.6 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 one of the Site's live contacts 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 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.
|
|
var overrideName = newName;
|
|
var overridePhone = newPhone;
|
|
if ((overrideName != null || overridePhone != null)
|
|
&& WorkOrderPocSiteContact.MatchesAnySiteContact(overrideName, overridePhone, workOrder.Locations?.Contacts))
|
|
{
|
|
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);
|
|
}
|
|
}
|