fix(workorders): persist manual POC override with audit and site-follow (SH-379)

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.
This commit is contained in:
Alexandre Brandizzi 2026-09-18 14:30:54 -03:00
parent 23f69307dd
commit cbecc7b4ea
22 changed files with 964 additions and 16 deletions

View file

@ -23,6 +23,7 @@ public class WorkOrderControllerSearchTests
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
Mock.Of<IWorkOrderPocService>(),
advancedSearch);
controller.ControllerContext = new ControllerContext

View file

@ -71,6 +71,7 @@ public class WorkOrderRouteContractTests
"PATCH {id:int}/board",
"PATCH {id:int}/comments/{commentId:int}",
"PATCH {id:int}/media/{mediaId:int}",
"PATCH {id:int}/poc",
"POST AddChecklistItem",
"POST AddComment",
"POST AddCommentJson",

View file

@ -24,6 +24,7 @@ namespace Api.SeaHavenIndustries.Controllers
private readonly IWorkOrderBoardUpdateService _boardUpdateService;
private readonly IWorkOrderBoardCreateService _boardCreateService;
private readonly IWorkOrderBoardCancelService _boardCancelService;
private readonly IWorkOrderPocService _pocService;
private readonly IWorkOrderAdvancedSearchService _advancedSearchService;
public WorkOrderBoardController(
@ -31,12 +32,14 @@ namespace Api.SeaHavenIndustries.Controllers
IWorkOrderBoardUpdateService boardUpdateService,
IWorkOrderBoardCreateService boardCreateService,
IWorkOrderBoardCancelService boardCancelService,
IWorkOrderPocService pocService,
IWorkOrderAdvancedSearchService advancedSearchService)
{
_workOrderBoardService = workOrderBoardService;
_boardUpdateService = boardUpdateService;
_boardCreateService = boardCreateService;
_boardCancelService = boardCancelService;
_pocService = pocService;
_advancedSearchService = advancedSearchService;
}
@ -165,6 +168,56 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
/// <summary>
/// SH-379: replaces the WO-level POC (name, phone, notes) with audit entries.
/// Blank name+phone clears the override so the work order follows the Site.
/// </summary>
[HttpPatch("{id:int}/poc")]
public async Task<IActionResult> UpdateWorkOrderPoc(int id, [FromBody] WorkOrderPocUpdateRequestDto request)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var row = await _pocService.UpdatePocAsync(id, request, User, actorId);
return Ok(row);
}
catch (WorkOrderBoardConcurrencyException ex)
{
return Conflict(new WorkOrderBoardConflictDto
{
CurrentState = ex.CurrentState
});
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (ArgumentException ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpPost("board")]
public async Task<IActionResult> CreateBoardWorkOrder(
[FromBody] WorkOrderBoardCreateRequestDto request,

View file

@ -52,6 +52,20 @@ namespace SeaHaven.DataServices.Helpers
c.Notes
})
.FirstOrDefault(),
// SH-379/SH-190: the Site's live primary contact backs the
// follow-the-site POC display when no override or link exists.
SitePoc = w.Locations != null
? w.Locations.Contacts!
.Where(c => c.IsDeleted != true)
.OrderBy(c => c.SiteContactOrder)
.ThenBy(c => c.Id)
.Select(c => new
{
Name = ((c.FirstName ?? "") + " " + (c.LastName ?? "")).Trim(),
c.PhoneNumber
})
.FirstOrDefault()
: null,
w.LifecycleStatus,
w.LegacyStatus,
w.Status,
@ -116,11 +130,11 @@ namespace SeaHaven.DataServices.Helpers
: null;
var primaryFrozenPoc = frozenPoc?.Contacts.FirstOrDefault();
var pocName = primaryFrozenPoc?.Name
?? (!string.IsNullOrWhiteSpace(w.WoPocName) ? w.WoPocName : w.ContactPoc?.Name);
?? FirstNotBlank(w.WoPocName, w.ContactPoc?.Name, w.SitePoc?.Name);
var pocPhone = primaryFrozenPoc?.Phone
?? (!string.IsNullOrWhiteSpace(w.WoPocPhone) ? w.WoPocPhone : w.ContactPoc?.PhoneNumber);
?? FirstNotBlank(w.WoPocPhone, w.ContactPoc?.PhoneNumber, w.SitePoc?.PhoneNumber);
var pocNotes = frozenPoc?.Notes
?? (!string.IsNullOrWhiteSpace(w.WoPocNotes) ? w.WoPocNotes : w.ContactPoc?.Notes);
?? FirstNotBlank(w.WoPocNotes, w.ContactPoc?.Notes);
var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone)
? w.DispatchTechPhone
: (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone);
@ -188,10 +202,15 @@ namespace SeaHaven.DataServices.Helpers
w.MediaCount,
frozenSite,
frozenCompany,
frozenPoc);
frozenPoc,
w.WoPocName,
w.WoPocPhone);
}).ToList();
}
private static string? FirstNotBlank(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
private static T? Deserialize<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))

View file

@ -21,7 +21,8 @@ namespace SeaHaven.DataServices.Implementation
public Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
=> _context.workOrders
.Include(workOrder => workOrder.Locations)
.Include(workOrder => workOrder.Locations!)
.ThenInclude(location => location.Contacts)
.Include(workOrder => workOrder.WorkOrderContacts!)
.ThenInclude(contact => contact.POC)
.Include(workOrder => workOrder.PrimaryDispatch!)

View file

@ -0,0 +1,47 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class WorkOrderPocDataService : IWorkOrderPocDataService
{
private readonly ApplicationDbContext _context;
public WorkOrderPocDataService(ApplicationDbContext context)
{
_context = context;
}
public Task<WorkOrder?> GetTrackedWorkOrderAsync(
int workOrderId,
int? accountId,
CancellationToken cancellationToken)
=> _context.workOrders
.Include(workOrder => workOrder.Locations!)
.ThenInclude(location => location.Contacts)
.Include(workOrder => workOrder.WorkOrderContacts!)
.ThenInclude(contact => contact.POC)
.FirstOrDefaultAsync(
w => w.Id == workOrderId
&& w.istemplate != true
&& (w.IsDeleted != true || w.IsDeleted == null)
&& (accountId == null || w.AccountId == accountId),
cancellationToken);
public async Task<IReadOnlyList<Contacts>> GetSiteContactsAsync(
int? locationId,
CancellationToken cancellationToken)
{
if (locationId == null)
return Array.Empty<Contacts>();
return await _context.Contacts
.AsNoTracking()
.Where(c => c.LocationId == locationId && c.IsDeleted != true)
.OrderBy(c => c.SiteContactOrder)
.ThenBy(c => c.Id)
.ToListAsync(cancellationToken);
}
}
}

View file

@ -0,0 +1,28 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces
{
/// <summary>
/// SH-379: data access for the work-order-level POC override.
/// </summary>
public interface IWorkOrderPocDataService
{
/// <summary>
/// SH-221 account-scoped tracked load for POC updates. Includes the site's
/// contacts and the linked WorkOrderContacts POC. Null when the work order
/// does not exist or is outside the caller's account scope.
/// </summary>
Task<WorkOrder?> GetTrackedWorkOrderAsync(
int workOrderId,
int? accountId,
CancellationToken cancellationToken);
/// <summary>
/// The site's live contacts for a location ordered by SiteContactOrder then Id.
/// Deleted contacts are excluded. Empty when the location has none.
/// </summary>
Task<IReadOnlyList<Contacts>> GetSiteContactsAsync(
int? locationId,
CancellationToken cancellationToken);
}
}

View file

@ -71,7 +71,9 @@ namespace SeaHaven.DataServices.Interfaces
int MediaCount = 0,
WorkOrderFrozenSite? FrozenSite = null,
WorkOrderFrozenCompany? FrozenCompany = null,
WorkOrderFrozenPoc? FrozenPoc = null);
WorkOrderFrozenPoc? FrozenPoc = null,
string? WoPocName = null,
string? WoPocPhone = null);
public record WorkOrderBoardQueryResult(
IReadOnlyList<WorkOrderBoardRawRow> ScheduledRows,

View file

@ -92,6 +92,8 @@ namespace SeaHaven.Services.DTOs
public string? PocName { get; set; }
public string? PocPhone { get; set; }
public string? PocNotes { get; set; }
/// <summary>SH-190: a manual WO-level POC override is present (locked away from the Site).</summary>
public bool PocCustomized { get; set; }
public List<WorkOrderAdditionalContactDto>? AdditionalContacts { get; set; }
public WorkOrderFrozenSiteDto? FrozenSite { get; set; }
public WorkOrderFrozenCompanyDto? FrozenCompany { get; set; }

View file

@ -58,6 +58,20 @@ namespace SeaHaven.Services.DTOs
public int? Severity { get; set; }
}
/// <summary>
/// SH-379: replaces the work-order-level POC override (PocName/PocPhone/PocNotes).
/// Blank name and phone clear the override so the work order follows the Site's
/// live contact again. An edit equal to a live Site contact does not lock (SH-190).
/// </summary>
public class WorkOrderPocUpdateRequestDto
{
public string? PocName { get; set; }
public string? PocPhone { get; set; }
public string? PocNotes { get; set; }
/// <summary>Base64 row version, same concurrency token as the board patch.</summary>
public string? WorkOrderVersion { get; set; }
}
/// <summary>Advanced search / filter query for the board.</summary>
public class WorkOrderAdvancedSearchQueryDto
{

View file

@ -17,10 +17,14 @@ namespace SeaHaven.Services.Helpers
var contact = workOrder.WorkOrderContacts?
.OrderBy(item => item.Id)
.FirstOrDefault();
// SH-379/SH-190: a work order without an override or link freezes the
// Site's live primary contact, matching the board display precedence.
var sitePrimary = WorkOrderPocSiteContact.ResolvePrimary(location?.Contacts);
var pocName = FirstNotBlank(
workOrder.PocName,
JoinName(contact?.POC?.FirstName, contact?.POC?.MiddleName, contact?.POC?.LastName));
var pocPhone = FirstNotBlank(workOrder.PocPhone, contact?.POC?.PhoneNumber);
JoinName(contact?.POC?.FirstName, contact?.POC?.MiddleName, contact?.POC?.LastName),
WorkOrderPocSiteContact.DisplayName(sitePrimary));
var pocPhone = FirstNotBlank(workOrder.PocPhone, contact?.POC?.PhoneNumber, sitePrimary?.PhoneNumber);
var pocNotes = FirstNotBlank(workOrder.PocNotes, contact?.Notes);
var contacts = new List<WorkOrderFrozenPocContact>();

View file

@ -0,0 +1,54 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// 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.
/// </summary>
public static class WorkOrderPocSiteContact
{
/// <summary>
/// The Site's primary live contact: first non-deleted contact ordered by
/// SiteContactOrder then Id. Null when the site has no live contacts.
/// </summary>
public static Contacts? ResolvePrimary(IEnumerable<Contacts>? contacts)
=> contacts?
.Where(c => c.IsDeleted != true)
.OrderBy(c => c.SiteContactOrder)
.ThenBy(c => c.Id)
.FirstOrDefault();
/// <summary>Display name matching the board projection: trimmed First + Last.</summary>
public static string? DisplayName(Contacts? contact)
=> contact == null
? null
: 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.
/// </summary>
public static bool MatchesAnySiteContact(
string? name,
string? phone,
IEnumerable<Contacts>? contacts)
{
if (contacts == 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));
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}

View file

@ -20,6 +20,7 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderAuditService _auditService;
private readonly IWorkOrderBoardCreateValidation _validator;
private readonly IWorkOrderAccountResolver _accountResolver;
private readonly IWorkOrderPocDataService _pocData;
private readonly IServicesRegistryService? _servicesRegistryService;
public WorkOrderBoardCreateService(
@ -29,6 +30,7 @@ namespace SeaHaven.Services.Implementation
IWorkOrderAuditService auditService,
IWorkOrderBoardCreateValidation validator,
IWorkOrderAccountResolver accountResolver,
IWorkOrderPocDataService pocData,
IServicesRegistryService? servicesRegistryService = null)
{
_boardDataService = boardDataService;
@ -37,6 +39,7 @@ namespace SeaHaven.Services.Implementation
_auditService = auditService;
_validator = validator;
_accountResolver = accountResolver;
_pocData = pocData;
_servicesRegistryService = servicesRegistryService;
}
@ -64,6 +67,15 @@ 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.
var siteContacts = await _pocData.GetSiteContactsAsync(request.LocationId, cancellationToken);
if (WorkOrderPocSiteContact.MatchesAnySiteContact(pocName, pocPhone, siteContacts))
{
pocName = null;
pocPhone = null;
}
var additionalContactsJson = WorkOrderAdditionalContactsMapper.SerializeForStorage(request.AdditionalContacts);
var techPhone = TrimOrNull(request.TechPhone);
var vendorNotes = TrimOrNull(request.VendorNotes);

View file

@ -116,6 +116,7 @@ namespace SeaHaven.Services.Implementation
PocName = row.PocName,
PocPhone = row.PocPhone,
PocNotes = row.PocNotes,
PocCustomized = !string.IsNullOrWhiteSpace(row.WoPocName) || !string.IsNullOrWhiteSpace(row.WoPocPhone),
FrozenSite = MapFrozenSite(row.FrozenSite),
FrozenCompany = MapFrozenCompany(row.FrozenCompany),
FrozenPoc = MapFrozenPoc(row.FrozenPoc),

View file

@ -0,0 +1,169 @@
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);
}
}

View file

@ -0,0 +1,18 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// SH-379: persists a manual POC override on an existing work order with audit
/// entries, and applies the SH-190 follow-site / lock semantics.
/// </summary>
public interface IWorkOrderPocService
{
Task<WorkOrderBoardRowDto> UpdatePocAsync(
int workOrderId,
WorkOrderPocUpdateRequestDto request,
ClaimsPrincipal user,
string? actorId);
}
}

View file

@ -33,7 +33,7 @@ public class WorkOrderAccountScopeTests
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
return new WorkOrderBoardCreateService(
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver, new WorkOrderPocDataService(context));
}
[Fact]
@ -232,7 +232,7 @@ public class WorkOrderAccountScopeTests
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var create = new WorkOrderBoardCreateService(
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver, new WorkOrderPocDataService(context));
using var cts = new CancellationTokenSource();
await create.CreateAsync(

View file

@ -49,7 +49,8 @@ public class WorkOrderBoardCreateRelationalTests
boardService,
audit,
new WorkOrderBoardCreateValidation(),
resolver);
resolver,
new WorkOrderPocDataService(context));
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
@ -111,7 +112,8 @@ public class WorkOrderBoardCreateRelationalTests
boardService,
audit,
new WorkOrderBoardCreateValidation(),
resolver);
resolver,
new WorkOrderPocDataService(context));
await Assert.ThrowsAsync<DbUpdateException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto

View file

@ -27,7 +27,7 @@ public class WorkOrderBoardCreateServiceTests
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var validator = new WorkOrderBoardCreateValidation();
var service = new WorkOrderBoardCreateService(boardData, mutationData, boardService, audit, validator, resolver);
var service = new WorkOrderBoardCreateService(boardData, mutationData, boardService, audit, validator, resolver, new WorkOrderPocDataService(context));
return (context, service);
}

View file

@ -32,7 +32,8 @@ public class WorkOrderBoardCreateSyncLockTests
boardService,
audit,
new WorkOrderBoardCreateValidation(),
resolver);
resolver,
new WorkOrderPocDataService(context));
var policy = new SyncFieldMergePolicy(fieldLocks);
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
@ -81,7 +82,8 @@ public class WorkOrderBoardCreateSyncLockTests
boardService,
audit,
new WorkOrderBoardCreateValidation(),
resolver);
resolver,
new WorkOrderPocDataService(context));
var policy = new SyncFieldMergePolicy(fieldLocks);
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto

View file

@ -74,7 +74,7 @@ public class WorkOrderPhase7CoexistenceTests
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var createService = new WorkOrderBoardCreateService(
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver, new WorkOrderPocDataService(context));
var policy = new SyncFieldMergePolicy(fieldLocks);
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), policy, fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context));

View file

@ -0,0 +1,518 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
namespace SeaHavenIndustries.Tests;
public class WorkOrderPocServiceTests
{
[Fact]
public async Task ManualOverridePersistsIsAuditedAndLocks()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
await context.SaveChangesAsync();
var service = CreatePocService(context);
var row = await service.UpdatePocAsync(
workOrder.Id,
Poc("Manual POC", "312-555-0001", "Call first", workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1");
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
Assert.Equal("Manual POC", persisted.PocName);
Assert.Equal("312-555-0001", persisted.PocPhone);
Assert.Equal("Call first", persisted.PocNotes);
Assert.Equal("Manual POC", row.PocName);
Assert.Equal("312-555-0001", 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("Manual POC", auditName.NewValue);
Assert.Equal("FieldChanged", auditName.Action);
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 ManualOverrideSurvivesCompleteAndLaterSiteChanges()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
workOrder.ScheduledDate = DateTime.UtcNow.Date.AddDays(1);
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
await context.SaveChangesAsync();
var service = CreatePocService(context);
await service.UpdatePocAsync(
workOrder.Id,
Poc("Manual POC", "312-555-0001", null, workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1");
var updateService = CreateUpdateService(context);
var completed = await updateService.PatchFieldAsync(
workOrder.Id,
Patch(WorkOrderBoardFieldNames.LifecycleStatus, "Complete", workOrder),
"actor-1");
Assert.Equal("Manual POC", completed.PocName);
var siteContact = await context.Contacts.SingleAsync(c => c.FirstName == "Alice");
siteContact.FirstName = "Changed";
siteContact.PhoneNumber = "999-555-0100";
await context.SaveChangesAsync();
var row = await CreateBoardService(context).GetBoardRowAsync(
workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
Assert.Equal("Manual POC", row!.PocName);
Assert.Equal("312-555-0001", row.PocPhone);
}
[Fact]
public async Task SiteContactUpdateDoesNotOverwriteManualOverride()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
var siteContact = await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
await context.SaveChangesAsync();
var service = CreatePocService(context);
await service.UpdatePocAsync(
workOrder.Id,
Poc("Manual POC", "312-555-0001", null, workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1");
siteContact.FirstName = "Rotated";
siteContact.PhoneNumber = "999-555-0100";
await context.SaveChangesAsync();
var row = await CreateBoardService(context).GetBoardRowAsync(
workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
Assert.Equal("Manual POC", row!.PocName);
Assert.Equal("312-555-0001", row.PocPhone);
Assert.True(row.PocCustomized);
}
[Fact]
public async Task UnlockedWorkOrderFollowsSiteContact()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
var primary = 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();
var boardService = CreateBoardService(context);
var before = await boardService.GetBoardRowAsync(workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
Assert.Equal("Alice Site", before!.PocName);
Assert.Equal("312-555-0100", before.PocPhone);
Assert.False(before.PocCustomized);
primary.PhoneNumber = "312-555-0999";
await context.SaveChangesAsync();
var after = await boardService.GetBoardRowAsync(workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
Assert.Equal("Alice Site", after!.PocName);
Assert.Equal("312-555-0999", after.PocPhone);
Assert.False(after.PocCustomized);
}
[Fact]
public async Task EditEqualToSiteContactDoesNotLock()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
await context.SaveChangesAsync();
var service = CreatePocService(context);
await service.UpdatePocAsync(
workOrder.Id,
Poc("Manual POC", "312-555-0001", null, workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1");
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.Null(persisted.PocName);
Assert.Null(persisted.PocPhone);
Assert.Equal("Alice Site", row.PocName);
Assert.Equal("312-555-0100", row.PocPhone);
Assert.False(row.PocCustomized);
var auditName = await context.WorkOrderAuditLogs.Where(a =>
a.WorkOrderId == workOrder.Id && a.FieldName == "PocName")
.OrderByDescending(a => a.Id)
.FirstAsync();
Assert.Equal("Manual POC", auditName.OldValue);
Assert.Equal("Alice Site", auditName.NewValue);
}
[Fact]
public async Task ClearingBothFieldsFollowsSiteAgain()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
await context.SaveChangesAsync();
var service = CreatePocService(context);
await service.UpdatePocAsync(
workOrder.Id,
Poc("Manual POC", "312-555-0001", "keep notes", workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1");
var row = await service.UpdatePocAsync(
workOrder.Id,
Poc(null, null, null, workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1");
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
Assert.Null(persisted.PocName);
Assert.Null(persisted.PocPhone);
Assert.Equal("Alice Site", row.PocName);
Assert.False(row.PocCustomized);
}
[Fact]
public async Task CrossTenantUpdateIsRejected()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context, accountId: 2);
await context.SaveChangesAsync();
var service = CreatePocService(context);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdatePocAsync(
workOrder.Id,
Poc("Manual POC", "312-555-0001", null, workOrder),
WorkOrderAccountTestHelpers.AccountUser(accountId: 1),
"actor-1"));
Assert.Equal("NotFound", ex.Code);
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
Assert.Null(persisted.PocName);
Assert.Empty(await context.WorkOrderAuditLogs.ToListAsync());
}
[Fact]
public async Task MissingAccountScopeIsForbidden()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
await context.SaveChangesAsync();
var service = CreatePocService(context);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdatePocAsync(
workOrder.Id,
Poc("Manual POC", "312-555-0001", null, workOrder),
WorkOrderAccountTestHelpers.MissingScope(),
"actor-1"));
Assert.Equal("Forbidden", ex.Code);
}
[Fact]
public async Task CompletedWorkOrderIsReadOnly()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
workOrder.LifecycleStatus = LifecycleStatus.Completed;
workOrder.CompletedDate = DateTime.UtcNow;
await context.SaveChangesAsync();
var service = CreatePocService(context);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdatePocAsync(
workOrder.Id,
Poc("Manual POC", "312-555-0001", null, workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1"));
Assert.Equal("ReadOnly", ex.Code);
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
Assert.Null(persisted.PocName);
}
[Fact]
public async Task CanceledWorkOrderIsReadOnly()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
workOrder.LifecycleStatus = LifecycleStatus.Canceled;
await context.SaveChangesAsync();
var service = CreatePocService(context);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdatePocAsync(
workOrder.Id,
Poc("Manual POC", "312-555-0001", null, workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1"));
Assert.Equal("CanceledReadOnly", ex.Code);
}
[Fact]
public async Task StaleVersionThrowsConflict()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
await context.SaveChangesAsync();
var service = CreatePocService(context);
var stale = new WorkOrderPocUpdateRequestDto
{
PocName = "Manual POC",
PocPhone = "312-555-0001",
WorkOrderVersion = Convert.ToBase64String(new byte[] { 9, 9, 9, 9 })
};
await Assert.ThrowsAsync<WorkOrderBoardConcurrencyException>(() =>
service.UpdatePocAsync(workOrder.Id, stale, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
Assert.Null(persisted.PocName);
}
[Fact]
public async Task CompletionFreezesSiteContactWhenNoOverrideOrLink()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
workOrder.ScheduledDate = DateTime.UtcNow.Date.AddDays(1);
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
await context.SaveChangesAsync();
var updateService = CreateUpdateService(context);
var completed = await updateService.PatchFieldAsync(
workOrder.Id,
Patch(WorkOrderBoardFieldNames.LifecycleStatus, "Complete", workOrder),
"actor-1");
Assert.Equal(LifecycleStatus.Completed, completed.LifecycleStatus);
Assert.Equal("Alice Site", completed.PocName);
Assert.Equal("312-555-0100", completed.PocPhone);
var contact = await context.Contacts.SingleAsync();
contact.PhoneNumber = "999-555-0100";
await context.SaveChangesAsync();
var row = await CreateBoardService(context).GetBoardRowAsync(
workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
Assert.Equal("Alice Site", row!.PocName);
Assert.Equal("312-555-0100", row.PocPhone);
}
[Fact]
public async Task BoardCreateWithPocEqualToSiteContactFollowsSite()
{
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
});
await context.SaveChangesAsync();
var service = CreateCreateService(context);
var row = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
PocName = "Alice Site",
PocPhone = "312-555-0100"
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
Assert.Null(persisted.PocName);
Assert.Null(persisted.PocPhone);
Assert.Equal("Alice Site", row.PocName);
Assert.False(row.PocCustomized);
}
[Fact]
public async Task BoardCreateWithPocAwayFromSiteContactIsStored()
{
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
});
await context.SaveChangesAsync();
var service = CreateCreateService(context);
var row = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
PocName = "Manual POC",
PocPhone = "312-555-0001"
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
Assert.Equal("Manual POC", persisted.PocName);
Assert.Equal("312-555-0001", persisted.PocPhone);
Assert.True(row.PocCustomized);
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static async Task SeedScopeAsync(ApplicationDbContext context)
{
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
await context.SaveChangesAsync();
}
private static WorkOrder NewWorkOrder(ApplicationDbContext context, int accountId = 1)
{
var location = context.Locations.Local.Single(l => l.Id == 1);
var workOrder = new WorkOrder
{
Id = 1,
InternalWONumber = "00000012345",
SiteCode = "BK5",
LocationId = location.Id,
Locations = location,
AccountId = accountId,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = Version()
};
context.workOrders.Add(workOrder);
return workOrder;
}
private static async Task<Contacts> SeedSiteContactAsync(
ApplicationDbContext context,
string firstName,
string lastName,
string phone,
int order = 0)
{
var contact = new Contacts
{
FirstName = firstName,
LastName = lastName,
PhoneNumber = phone,
LocationId = 1,
SiteContactOrder = order
};
context.Contacts.Add(contact);
await context.SaveChangesAsync();
return contact;
}
private static WorkOrderPocService CreatePocService(ApplicationDbContext context)
=> new(
new WorkOrderPocDataService(context),
new WorkOrderBoardMutationDataService(context),
CreateBoardService(context),
new WorkOrderAuditService(
new WorkOrderAuditDataService(context),
new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context))),
WorkOrderAccountTestHelpers.Resolver(context));
private static WorkOrderBoardService CreateBoardService(ApplicationDbContext context)
=> new(
new WorkOrderBoardDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
private static WorkOrderBoardUpdateService CreateUpdateService(ApplicationDbContext context)
{
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
return new WorkOrderBoardUpdateService(
new WorkOrderBoardDataService(context),
new WorkOrderBoardMutationDataService(context),
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks));
}
private static WorkOrderBoardCreateService CreateCreateService(ApplicationDbContext context)
=> new(
new WorkOrderBoardDataService(context),
new WorkOrderBoardMutationDataService(context),
CreateBoardService(context),
new WorkOrderAuditService(
new WorkOrderAuditDataService(context),
new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context))),
new WorkOrderBoardCreateValidation(),
WorkOrderAccountTestHelpers.Resolver(context),
new WorkOrderPocDataService(context));
private static WorkOrderPocUpdateRequestDto Poc(
string? name,
string? phone,
string? notes,
WorkOrder workOrder)
=> new()
{
PocName = name,
PocPhone = phone,
PocNotes = notes,
WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion ?? Version())
};
private static WorkOrderBoardPatchRequestDto Patch(
string field,
string value,
WorkOrder workOrder)
=> new()
{
Field = field,
Value = value,
WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion ?? Version())
};
private static byte[] Version() => new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 };
}