mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
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:
parent
23f69307dd
commit
cbecc7b4ea
22 changed files with 964 additions and 16 deletions
|
|
@ -23,6 +23,7 @@ public class WorkOrderControllerSearchTests
|
|||
Mock.Of<IWorkOrderBoardUpdateService>(),
|
||||
Mock.Of<IWorkOrderBoardCreateService>(),
|
||||
Mock.Of<IWorkOrderBoardCancelService>(),
|
||||
Mock.Of<IWorkOrderPocService>(),
|
||||
advancedSearch);
|
||||
|
||||
controller.ControllerContext = new ControllerContext
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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!)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
SeaHaven.DataServices/Interfaces/IWorkOrderPocDataService.cs
Normal file
28
SeaHaven.DataServices/Interfaces/IWorkOrderPocDataService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
54
SeaHaven.Services/Helpers/WorkOrderPocSiteContact.cs
Normal file
54
SeaHaven.Services/Helpers/WorkOrderPocSiteContact.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
169
SeaHaven.Services/Implementation/WorkOrderPocService.cs
Normal file
169
SeaHaven.Services/Implementation/WorkOrderPocService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
18
SeaHaven.Services/Interfaces/IWorkOrderPocService.cs
Normal file
18
SeaHaven.Services/Interfaces/IWorkOrderPocService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
518
SeaHavenIndustries.Tests/WorkOrderPocServiceTests.cs
Normal file
518
SeaHavenIndustries.Tests/WorkOrderPocServiceTests.cs
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue