Merge pull request #155 from Sea-Haven-Industries/fix/ab/sh-379-manual-poc-override

Persist manual POC override with audit and site-follow (SH-379)
This commit is contained in:
Alexandre Brandizzi 2026-09-18 22:54:51 +00:00 • committed by GitHub
commit 0b3084a274
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1101 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,51 @@
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 contact the work order follows" 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: 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 Matches(string? name, string? phone, Contacts? contact)
{
if (contact == null)
return false;
return string.Equals(DisplayName(contact), Normalize(name), StringComparison.Ordinal)
&& string.Equals(Normalize(contact.PhoneNumber), Normalize(phone), 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,25 @@ 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 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);
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;
}
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,176 @@
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);
}
}

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,641 @@
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);
}
[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>()
.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 };
}