mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
Merge pull request #199 from Sea-Haven-Industries/fix/ab/sh411-technician-assignment-backend-thr_9fpik7tdmw
Persist explicit work-order technician assignment
This commit is contained in:
commit
5a5472ed43
19 changed files with 575 additions and 9 deletions
|
|
@ -0,0 +1,29 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260929210000_SH411WorkOrderTechnicianAssigned")]
|
||||
public partial class SH411WorkOrderTechnicianAssigned : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "TechnicianAssigned",
|
||||
table: "workOrders",
|
||||
type: "bit",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TechnicianAssigned",
|
||||
table: "workOrders");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2826,6 +2826,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("FrozenCompany")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool?>("TechnicianAssigned")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("FrozenPoc")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ namespace Data.SeaHavenIndustries
|
|||
{
|
||||
public string? Name { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public bool? TechnicianAssigned { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ namespace Data.SeaHavenIndustries
|
|||
public int? PrimaryDispatchId { get; set; }
|
||||
[ForeignKey(nameof(PrimaryDispatchId))]
|
||||
public virtual Dispatch? PrimaryDispatch { get; set; }
|
||||
/// <summary>Explicit technician selection for this work order; null preserves legacy vendor-contact behavior.</summary>
|
||||
public bool? TechnicianAssigned { get; set; }
|
||||
|
||||
// --- Service & Notes / POC (structured create) ---
|
||||
[MaxLength(200)]
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ namespace SeaHaven.DataServices.Helpers
|
|||
TechName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
||||
? w.PrimaryDispatch.Vendor.ContactName
|
||||
: null,
|
||||
w.TechnicianAssigned,
|
||||
DispatchTechPhone = w.PrimaryDispatch != null ? w.PrimaryDispatch.TechPhone : null,
|
||||
WoTechPhone = w.TechPhone,
|
||||
VendorPhone = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
||||
|
|
@ -129,6 +130,7 @@ namespace SeaHaven.DataServices.Helpers
|
|||
var frozenPoc = w.LifecycleStatus == LifecycleStatus.Completed
|
||||
? Deserialize<WorkOrderFrozenPoc>(w.FrozenPoc)
|
||||
: null;
|
||||
var technicianAssigned = frozenCompany?.TechnicianAssigned ?? w.TechnicianAssigned;
|
||||
var primaryFrozenPoc = frozenPoc?.Contacts.FirstOrDefault();
|
||||
var pocName = primaryFrozenPoc?.Name
|
||||
?? FirstNotBlank(w.WoPocName, w.ContactPoc?.Name, w.SitePoc?.Name);
|
||||
|
|
@ -175,8 +177,8 @@ namespace SeaHaven.DataServices.Helpers
|
|||
w.ScheduleWeekOnly,
|
||||
vendorId,
|
||||
vendorName,
|
||||
frozenCompany?.ContactName ?? w.TechName,
|
||||
frozenCompany?.Phone ?? techPhone,
|
||||
technicianAssigned == false ? null : frozenCompany?.ContactName ?? w.TechName,
|
||||
technicianAssigned == false ? null : frozenCompany?.Phone ?? techPhone,
|
||||
w.DispatchApptDate,
|
||||
w.Trade ?? w.Problem,
|
||||
w.Problem,
|
||||
|
|
@ -205,7 +207,8 @@ namespace SeaHaven.DataServices.Helpers
|
|||
frozenCompany,
|
||||
frozenPoc,
|
||||
w.WoPocName,
|
||||
w.WoPocPhone);
|
||||
w.WoPocPhone,
|
||||
technicianAssigned);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ namespace SeaHaven.DataServices.Helpers
|
|||
|| (w.Trade ?? "").ToLower().Contains(s)
|
||||
|| (w.Problem ?? "").ToLower().Contains(s)
|
||||
|| (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s))
|
||||
|| (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.ContactName ?? "").ToLower().Contains(s))
|
||||
|| (w.TechnicianAssigned != false && w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.ContactName ?? "").ToLower().Contains(s))
|
||||
|| (w.Status ?? "").ToLower().Contains(s)
|
||||
|| (w.WorkOrderType == WorkOrderType.PO && "po".Contains(s))
|
||||
|| (w.WorkOrderType == WorkOrderType.PM && "pm".Contains(s))
|
||||
|
|
|
|||
|
|
@ -73,7 +73,8 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
WorkOrderFrozenCompany? FrozenCompany = null,
|
||||
WorkOrderFrozenPoc? FrozenPoc = null,
|
||||
string? WoPocName = null,
|
||||
string? WoPocPhone = null);
|
||||
string? WoPocPhone = null,
|
||||
bool? TechnicianAssigned = null);
|
||||
|
||||
public record WorkOrderBoardQueryResult(
|
||||
IReadOnlyList<WorkOrderBoardRawRow> ScheduledRows,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ namespace SeaHaven.Services.DTOs
|
|||
{
|
||||
public string? Name { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public bool? TechnicianAssigned { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
|
|
@ -112,6 +113,7 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? DayGroup { get; set; }
|
||||
public int? VendorId { get; set; }
|
||||
public string? VendorName { get; set; }
|
||||
public bool? TechnicianAssigned { get; set; }
|
||||
/// <summary>
|
||||
/// Primary dispatch status. Inactive statuses are not a live company assignment.
|
||||
/// </summary>
|
||||
|
|
@ -147,6 +149,8 @@ namespace SeaHaven.Services.DTOs
|
|||
{
|
||||
public string Field { get; set; } = "";
|
||||
public string? Value { get; set; }
|
||||
/// <summary>Explicit technician-selection state, accepted only with a vendorId patch.</summary>
|
||||
public bool? TechnicianAssigned { get; set; }
|
||||
public string? WorkOrderVersion { get; set; }
|
||||
public string? DispatchVersion { get; set; }
|
||||
public int? PrimaryDispatchId { get; set; }
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ namespace SeaHaven.Services.DTOs
|
|||
/// <summary>Optional create-time lifecycle. When omitted, server defaults to Incomplete.</summary>
|
||||
public LifecycleStatus? LifecycleStatus { get; set; }
|
||||
public int? VendorId { get; set; }
|
||||
/// <summary>Optional explicit technician-selection state for the selected vendor.</summary>
|
||||
public bool? TechnicianAssigned { get; set; }
|
||||
public DateTime? ApptDate { get; set; }
|
||||
public string? ApptTime { get; set; }
|
||||
public int? PocContactId { get; set; }
|
||||
|
|
|
|||
|
|
@ -57,7 +57,8 @@ namespace SeaHaven.Services.Helpers
|
|||
new WorkOrderFrozenCompany
|
||||
{
|
||||
Name = liveVendor.CompanyName,
|
||||
ContactName = liveVendor.ContactName,
|
||||
ContactName = workOrder.TechnicianAssigned == false ? null : liveVendor.ContactName,
|
||||
TechnicianAssigned = workOrder.TechnicianAssigned,
|
||||
Address = liveVendor.Address,
|
||||
City = liveVendor.City,
|
||||
State = liveVendor.State,
|
||||
|
|
|
|||
|
|
@ -94,6 +94,12 @@ namespace SeaHaven.Services.Implementation
|
|||
|| request.ApptDate.HasValue
|
||||
|| !string.IsNullOrWhiteSpace(request.ApptTime);
|
||||
|
||||
if (request.TechnicianAssigned.HasValue
|
||||
&& (!request.VendorId.HasValue || request.VendorId.Value <= 0))
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"DispatchRequired",
|
||||
"technicianAssigned requires a positive vendorId.");
|
||||
|
||||
if (request.VendorId.HasValue && request.VendorId.Value > 0)
|
||||
{
|
||||
if (!await _mutationData.VendorExistsAsync(request.VendorId.Value, cancellationToken))
|
||||
|
|
@ -128,6 +134,7 @@ namespace SeaHaven.Services.Implementation
|
|||
TargetWeek = request.TargetWeek,
|
||||
ScheduleWeekOnly = request.ScheduleWeekOnly,
|
||||
DocStatus = request.DocStatus,
|
||||
TechnicianAssigned = request.TechnicianAssigned,
|
||||
createdby = actorId,
|
||||
CreatedDate = DateTime.UtcNow,
|
||||
istemplate = false
|
||||
|
|
@ -159,6 +166,12 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderBoardFieldMutations.BoardFieldChange.Changed("LifecycleStatus", "", workOrder.LifecycleStatus?.ToString() ?? LifecycleStatus.Incomplete.ToString())
|
||||
};
|
||||
|
||||
if (request.TechnicianAssigned.HasValue)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed(
|
||||
"TechnicianAssigned",
|
||||
"",
|
||||
request.TechnicianAssigned.Value.ToString()));
|
||||
|
||||
if (workOrder.Severity != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Severity", "", workOrder.Severity));
|
||||
|
||||
|
|
@ -417,7 +430,9 @@ namespace SeaHaven.Services.Implementation
|
|||
{
|
||||
foreach (var change in changes.Where(c => c.HasChanged))
|
||||
{
|
||||
var dispatchId = change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null);
|
||||
var dispatchId = change.FieldName == "TechnicianAssigned"
|
||||
? null
|
||||
: change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null);
|
||||
switch (change.Action)
|
||||
{
|
||||
case AuditActionType.StatusChanged:
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ namespace SeaHaven.Services.Implementation
|
|||
DayGroup = WorkOrderDerivedFields.GetDayGroup(row.ScheduledDate),
|
||||
VendorId = vendorId,
|
||||
VendorName = vendorName,
|
||||
TechnicianAssigned = row.TechnicianAssigned,
|
||||
PrimaryDispatchStatus = row.PrimaryDispatchStatus,
|
||||
TechName = row.TechName,
|
||||
TechPhone = row.TechPhone,
|
||||
|
|
@ -202,6 +203,7 @@ namespace SeaHaven.Services.Implementation
|
|||
{
|
||||
Name = company.Name,
|
||||
ContactName = company.ContactName,
|
||||
TechnicianAssigned = company.TechnicianAssigned,
|
||||
Address = company.Address,
|
||||
City = company.City,
|
||||
State = company.State,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,18 @@ namespace SeaHaven.Services.Implementation
|
|||
if (canonicalField == null)
|
||||
throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.");
|
||||
|
||||
if (request.TechnicianAssigned.HasValue
|
||||
&& !canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase))
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"InvalidField",
|
||||
"technicianAssigned can only be supplied with a vendorId patch.");
|
||||
|
||||
if (request.TechnicianAssigned.HasValue
|
||||
&& !ParseVendorIdHint(request.Value).HasValue)
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"InvalidValue",
|
||||
"technicianAssigned requires a positive vendorId.");
|
||||
|
||||
var workOrderVersion = ParseRowVersion(request.WorkOrderVersion, "workOrderVersion");
|
||||
if (workOrderVersion == null)
|
||||
throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required.");
|
||||
|
|
@ -132,10 +144,30 @@ namespace SeaHaven.Services.Implementation
|
|||
_mutationData.DetachWorkOrderFromDispatch(previousDispatch, workOrder.Id);
|
||||
}
|
||||
|
||||
var priorVendorId = resolved?.Previous?.VendorId
|
||||
?? (resolved is { Created: true } ? null : resolved?.Dispatch.VendorId);
|
||||
var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField);
|
||||
var lifecycleBefore = workOrder.LifecycleStatus;
|
||||
var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField, ct);
|
||||
|
||||
if (canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase)
|
||||
&& resolved != null)
|
||||
{
|
||||
var requestedVendorId = ParseVendorIdHint(request.Value);
|
||||
var vendorChanged = requestedVendorId.HasValue && priorVendorId != requestedVendorId.Value;
|
||||
var nextTechnicianAssigned = request.TechnicianAssigned
|
||||
?? (vendorChanged ? null : workOrder.TechnicianAssigned);
|
||||
if (workOrder.TechnicianAssigned != nextTechnicianAssigned)
|
||||
{
|
||||
var oldValue = workOrder.TechnicianAssigned?.ToString();
|
||||
workOrder.TechnicianAssigned = nextTechnicianAssigned;
|
||||
changes.Add(FieldChange.ForField(
|
||||
"TechnicianAssigned",
|
||||
oldValue,
|
||||
nextTechnicianAssigned?.ToString()));
|
||||
}
|
||||
}
|
||||
|
||||
// Cancelling the work order cancels its pending uplift in the same action: the
|
||||
// withdrawal and its own audit entry are staged here and committed by the same
|
||||
// save as the status change.
|
||||
|
|
@ -147,6 +179,9 @@ namespace SeaHaven.Services.Implementation
|
|||
&& canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var previousVendor = resolved.Previous?.VendorId.ToString() ?? "";
|
||||
var technicianAssignmentChanges = changes
|
||||
.Where(change => change.FieldName == "TechnicianAssigned")
|
||||
.ToList();
|
||||
changes = new List<FieldChange>
|
||||
{
|
||||
FieldChange.ForField(
|
||||
|
|
@ -155,6 +190,7 @@ namespace SeaHaven.Services.Implementation
|
|||
createdDispatch.VendorId.ToString(),
|
||||
createdDispatch.Id)
|
||||
};
|
||||
changes.AddRange(technicianAssignmentChanges);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -172,7 +208,9 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
foreach (var change in changes)
|
||||
{
|
||||
var dispatchId = change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null);
|
||||
var dispatchId = change.FieldName == "TechnicianAssigned"
|
||||
? null
|
||||
: change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null);
|
||||
switch (change.Action)
|
||||
{
|
||||
case AuditActionType.StatusChanged:
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ namespace SeaHaven.Services.Implementation
|
|||
DayGroup = row.DayGroup,
|
||||
VendorId = row.VendorId,
|
||||
VendorName = row.VendorName,
|
||||
TechnicianAssigned = row.TechnicianAssigned,
|
||||
PrimaryDispatchStatus = row.PrimaryDispatchStatus,
|
||||
TechName = row.TechName,
|
||||
TechPhone = row.TechPhone,
|
||||
|
|
|
|||
|
|
@ -182,7 +182,8 @@ public class WorkOrderBoardCreateRelationalTests
|
|||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
LocationId = 1,
|
||||
VendorId = 5
|
||||
VendorId = 5,
|
||||
TechnicianAssigned = false
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
}
|
||||
|
||||
|
|
@ -192,6 +193,10 @@ public class WorkOrderBoardCreateRelationalTests
|
|||
var dispatch = await verify.Dispatches.AsNoTracking().SingleAsync();
|
||||
Assert.Equal(result.PrimaryDispatchId, dispatch.Id);
|
||||
Assert.Equal(result.Id, dispatch.WorkOrderId);
|
||||
Assert.False(await verify.workOrders
|
||||
.Where(workOrder => workOrder.Id == result.Id)
|
||||
.Select(workOrder => workOrder.TechnicianAssigned)
|
||||
.SingleAsync());
|
||||
|
||||
var upliftDispatch = await new UpliftDataService(verify).GetUpliftDispatchForWorkOrderAsync(
|
||||
result.Id,
|
||||
|
|
|
|||
|
|
@ -655,6 +655,185 @@ public class WorkOrderBoardUpdateServiceTests
|
|||
Assert.Equal(1, await context.Dispatches.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_TechnicianAssignedFalseWithSameInactiveVendor_ReplacesPrimaryDispatch()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Vendor Co", ContactName = "Tech One" });
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
Id = 10,
|
||||
WorkOrderId = 1,
|
||||
VendorId = 5,
|
||||
Status = "Refused",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
|
||||
};
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = dispatch.Id,
|
||||
TechnicianAssigned = false,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.Dispatches.Add(dispatch);
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.VendorId,
|
||||
Value = "5",
|
||||
TechnicianAssigned = false,
|
||||
WorkOrderVersion = ToVersion(wo),
|
||||
DispatchVersion = ToVersion(dispatch),
|
||||
PrimaryDispatchId = dispatch.Id
|
||||
}, "actor-1");
|
||||
|
||||
Assert.NotEqual(dispatch.Id, result.PrimaryDispatchId);
|
||||
Assert.Equal("Pending", result.PrimaryDispatchStatus);
|
||||
Assert.False(result.TechnicianAssigned);
|
||||
Assert.Equal("Refused", (await context.Dispatches.SingleAsync(item => item.Id == dispatch.Id)).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_OldClientVendorChange_ResetsExplicitTechnicianStateToLegacy()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.Vendors.AddRange(
|
||||
new Vendor { Id = 1, CompanyName = "Old Co" },
|
||||
new Vendor { Id = 5, CompanyName = "New Co" });
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
Id = 10,
|
||||
WorkOrderId = 1,
|
||||
VendorId = 1,
|
||||
Status = "Sent",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
|
||||
};
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = dispatch.Id,
|
||||
TechnicianAssigned = false,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.Dispatches.Add(dispatch);
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.VendorId,
|
||||
Value = "5",
|
||||
WorkOrderVersion = ToVersion(wo),
|
||||
DispatchVersion = ToVersion(dispatch),
|
||||
PrimaryDispatchId = dispatch.Id
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Null(result.TechnicianAssigned);
|
||||
Assert.Equal("New Co", result.VendorName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(null)]
|
||||
public async Task PatchField_OldClientSameVendor_PreservesExplicitOrLegacyState(bool? technicianAssigned)
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Vendor Co", ContactName = "Tech One" });
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
Id = 10,
|
||||
WorkOrderId = 1,
|
||||
VendorId = 5,
|
||||
Status = "Sent",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
|
||||
};
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = dispatch.Id,
|
||||
TechnicianAssigned = technicianAssigned,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.Dispatches.Add(dispatch);
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.VendorId,
|
||||
Value = "5",
|
||||
WorkOrderVersion = ToVersion(wo),
|
||||
DispatchVersion = ToVersion(dispatch),
|
||||
PrimaryDispatchId = dispatch.Id
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal(technicianAssigned, result.TechnicianAssigned);
|
||||
Assert.Equal("Tech One", result.TechName);
|
||||
Assert.Equal(dispatch.Id, result.PrimaryDispatchId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_NewVendorWithExplicitNoTechnician_AuditsWorkOrderState()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Vendor Co" });
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.VendorId,
|
||||
Value = "5",
|
||||
TechnicianAssigned = false,
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.False(result.TechnicianAssigned);
|
||||
var audit = await context.WorkOrderAuditLogs.SingleAsync(item => item.FieldName == "TechnicianAssigned");
|
||||
Assert.Equal(AuditActionType.FieldChanged.ToString(), audit.Action);
|
||||
Assert.Equal("False", audit.NewValue);
|
||||
Assert.Null(audit.DispatchId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_TechnicianAssignedOnAnotherField_IsRejectedWithoutMutation()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
TechnicianAssigned = true,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var exception = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ScheduledDate,
|
||||
Value = "2026-09-30",
|
||||
TechnicianAssigned = false,
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("InvalidField", exception.Code);
|
||||
Assert.True((await context.workOrders.SingleAsync()).TechnicianAssigned);
|
||||
Assert.Empty(await context.WorkOrderAuditLogs.ToListAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Cancelled")]
|
||||
[InlineData("Canceled")]
|
||||
|
|
|
|||
|
|
@ -149,6 +149,57 @@ public class WorkOrderCompletionFreezeTests
|
|||
Assert.Null(persisted.FrozenCompany);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompletionWithExplicitNoTechnicianFreezesCompanyWithoutTechnicianContact()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var service = CreateService(context);
|
||||
var vendor = new Vendor
|
||||
{
|
||||
Id = 20,
|
||||
CompanyName = "Frozen Vendor",
|
||||
ContactName = "Do Not Freeze Tech",
|
||||
CompanyPhone = "312-555-0200",
|
||||
Email = "company@example.com"
|
||||
};
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
Id = 40,
|
||||
WorkOrderId = 1,
|
||||
VendorId = vendor.Id,
|
||||
Vendor = vendor,
|
||||
Status = "Sent",
|
||||
TechPhone = "312-555-0299"
|
||||
};
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
ScheduledDate = DateTime.UtcNow.Date.AddDays(1),
|
||||
PrimaryDispatchId = dispatch.Id,
|
||||
PrimaryDispatch = dispatch,
|
||||
TechnicianAssigned = false,
|
||||
RowVersion = Version()
|
||||
};
|
||||
context.AddRange(vendor, dispatch, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var completed = await service.PatchFieldAsync(
|
||||
workOrder.Id,
|
||||
Patch(WorkOrderBoardFieldNames.LifecycleStatus, "Complete", workOrder),
|
||||
"actor-1");
|
||||
var persisted = await context.workOrders.SingleAsync();
|
||||
var frozen = WorkOrderCompletionSnapshotMapper.ParseCompany(persisted.FrozenCompany);
|
||||
|
||||
Assert.False(completed.TechnicianAssigned);
|
||||
Assert.Null(completed.TechName);
|
||||
Assert.Null(completed.TechPhone);
|
||||
Assert.False(frozen?.TechnicianAssigned);
|
||||
Assert.Null(frozen?.ContactName);
|
||||
Assert.Equal("312-555-0200", frozen?.Phone);
|
||||
Assert.Equal("company@example.com", frozen?.Email);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenWorkOrderProjectionKeepsUsingLiveLocation()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -45,4 +45,17 @@ public class WorkOrderMigrationDiscoveryTests
|
|||
|
||||
Assert.Contains("20260826120000_SH221_LocationAccountScope", migrations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SH411_TechnicianAssigned_is_discoverable_by_ef_runtime()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.Options;
|
||||
|
||||
using var context = new ApplicationDbContext(options);
|
||||
var migrations = context.Database.GetMigrations().ToList();
|
||||
|
||||
Assert.Contains("20260929210000_SH411WorkOrderTechnicianAssigned", migrations);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Helpers;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class WorkOrderTechnicianAssignmentRelationalTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ClearingTechnicianOnOneWorkOrder_PreservesSharedDispatchAndSiblingSelectionAfterReload()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
byte[] workOrderVersion = { 1, 0, 0, 0, 0, 0, 0, 1 };
|
||||
byte[] dispatchVersion = { 1, 0, 0, 0, 0, 0, 0, 2 };
|
||||
await using (var seed = new SqliteBoardTestDbContext(options))
|
||||
{
|
||||
await seed.Database.EnsureCreatedAsync();
|
||||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(seed);
|
||||
seed.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = "actor-1",
|
||||
UserName = "actor-1",
|
||||
NormalizedUserName = "ACTOR-1",
|
||||
Email = "actor@test.local",
|
||||
NormalizedEmail = "ACTOR@TEST.LOCAL"
|
||||
});
|
||||
seed.Vendors.Add(new Vendor
|
||||
{
|
||||
Id = 5,
|
||||
CompanyName = "Acme HVAC",
|
||||
ContactName = "Technician One",
|
||||
CompanyPhone = "312-555-0100"
|
||||
});
|
||||
await seed.SaveChangesAsync();
|
||||
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
Id = 10,
|
||||
VendorId = 5,
|
||||
Status = "Sent",
|
||||
TechPhone = "312-555-0199",
|
||||
RowVersion = dispatchVersion
|
||||
};
|
||||
seed.Dispatches.Add(dispatch);
|
||||
seed.workOrders.AddRange(
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
AccountId = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
ScheduledDate = new DateTime(2026, 9, 30),
|
||||
PrimaryDispatchId = 10,
|
||||
TechnicianAssigned = true,
|
||||
RowVersion = workOrderVersion
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 2,
|
||||
AccountId = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
ScheduledDate = new DateTime(2026, 9, 30),
|
||||
PrimaryDispatchId = 10,
|
||||
RowVersion = new byte[] { 2, 0, 0, 0, 0, 0, 0, 1 }
|
||||
});
|
||||
await seed.SaveChangesAsync();
|
||||
|
||||
dispatch.WorkOrderId = 1;
|
||||
seed.DispatchWorkOrders.Add(new DispatchWorkOrder { Id = 20, DispatchId = 10, WorkOrderId = 2 });
|
||||
await seed.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using (var context = new SqliteBoardTestDbContext(options))
|
||||
{
|
||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
var service = WorkOrderAccountTestHelpers.BoardUpdateService(context, audit);
|
||||
var updated = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.VendorId,
|
||||
Value = "5",
|
||||
TechnicianAssigned = false,
|
||||
WorkOrderVersion = Convert.ToBase64String(workOrderVersion),
|
||||
DispatchVersion = Convert.ToBase64String(dispatchVersion),
|
||||
PrimaryDispatchId = 10
|
||||
}, "actor-1");
|
||||
|
||||
Assert.False(updated.TechnicianAssigned);
|
||||
Assert.Equal(10, updated.PrimaryDispatchId);
|
||||
}
|
||||
|
||||
await using var verify = new SqliteBoardTestDbContext(options);
|
||||
var data = new WorkOrderBoardDataService(verify);
|
||||
var workOrderA = Assert.IsType<SeaHaven.DataServices.Interfaces.WorkOrderBoardRawRow>(
|
||||
await data.GetBoardRowByIdAsync(1));
|
||||
var workOrderB = Assert.IsType<SeaHaven.DataServices.Interfaces.WorkOrderBoardRawRow>(
|
||||
await data.GetBoardRowByIdAsync(2));
|
||||
|
||||
Assert.False(workOrderA.TechnicianAssigned);
|
||||
Assert.Null(workOrderA.TechName);
|
||||
Assert.Null(workOrderA.TechPhone);
|
||||
Assert.Equal("Technician One", workOrderB.TechName);
|
||||
Assert.Equal("312-555-0199", workOrderB.TechPhone);
|
||||
Assert.Equal(10, workOrderA.PrimaryDispatchId);
|
||||
Assert.Equal(10, workOrderB.PrimaryDispatchId);
|
||||
Assert.Equal(5, workOrderA.VendorId);
|
||||
Assert.Equal(5, workOrderB.VendorId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TechnicianNameSearch_MatchesLegacyAndAssignedRows_ButNotExplicitlyClearedRow()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
await using (var seed = new SqliteBoardTestDbContext(options))
|
||||
{
|
||||
await seed.Database.EnsureCreatedAsync();
|
||||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(seed);
|
||||
seed.Vendors.Add(new Vendor
|
||||
{
|
||||
Id = 5,
|
||||
CompanyName = "Acme HVAC",
|
||||
ContactName = "Technician One",
|
||||
CompanyPhone = "312-555-0100"
|
||||
});
|
||||
seed.Dispatches.AddRange(
|
||||
new Dispatch { Id = 10, VendorId = 5, Status = "Sent", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } },
|
||||
new Dispatch { Id = 11, VendorId = 5, Status = "Sent", RowVersion = new byte[] { 2, 0, 0, 0, 0, 0, 0, 2 } },
|
||||
new Dispatch { Id = 12, VendorId = 5, Status = "Sent", RowVersion = new byte[] { 3, 0, 0, 0, 0, 0, 0, 2 } });
|
||||
seed.workOrders.AddRange(
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
AccountId = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = 10,
|
||||
TechnicianAssigned = null,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 2,
|
||||
AccountId = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = 11,
|
||||
TechnicianAssigned = true,
|
||||
RowVersion = new byte[] { 2, 0, 0, 0, 0, 0, 0, 1 }
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 3,
|
||||
AccountId = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = 12,
|
||||
TechnicianAssigned = false,
|
||||
RowVersion = new byte[] { 3, 0, 0, 0, 0, 0, 0, 1 }
|
||||
});
|
||||
await seed.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using var context = new SqliteBoardTestDbContext(options);
|
||||
|
||||
var technicianMatches = await WorkOrderBoardSearchFilter
|
||||
.Apply(context.workOrders.AsQueryable(), "Technician One")
|
||||
.Select(w => w.Id)
|
||||
.OrderBy(id => id)
|
||||
.ToListAsync();
|
||||
Assert.Equal(new[] { 1, 2 }, technicianMatches);
|
||||
|
||||
var companyMatches = await WorkOrderBoardSearchFilter
|
||||
.Apply(context.workOrders.AsQueryable(), "Acme HVAC")
|
||||
.Select(w => w.Id)
|
||||
.OrderBy(id => id)
|
||||
.ToListAsync();
|
||||
Assert.Equal(new[] { 1, 2, 3 }, companyMatches);
|
||||
}
|
||||
|
||||
private sealed class SqliteBoardTestDbContext : ApplicationDbContext
|
||||
{
|
||||
public SqliteBoardTestDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes()))
|
||||
{
|
||||
if (index.GetFilter() != null)
|
||||
index.SetFilter(null);
|
||||
}
|
||||
|
||||
foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) })
|
||||
{
|
||||
var property = builder.Entity(entityType).Property("RowVersion").Metadata;
|
||||
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
|
||||
property.IsConcurrencyToken = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue