Compare commits

...

4 commits

Author SHA1 Message Date
renovate[bot]
808c2b6ba2
Merge d489cfee13 into 5a5472ed43 2026-09-29 22:03:37 +00:00
Alexandre Brandizzi
5a5472ed43
Merge pull request #199 from Sea-Haven-Industries/fix/ab/sh411-technician-assignment-backend-thr_9fpik7tdmw
Some checks are pending
Backend CI / Build and test (push) Waiting to run
Backend CI / architecture (push) Waiting to run
Backend CI / review (push) Waiting to run
Backend CI / ci-complete (push) Blocked by required conditions
Persist explicit work-order technician assignment
2026-09-29 21:58:16 +00:00
Alexandre Brandizzi
2de65539a1 test(workorders): cover technician-name search for cleared assignment
WorkOrderBoardSearchFilter now skips the vendor ContactName for work
orders whose TechnicianAssigned is explicitly false. Exercise the
predicate through SQLite so relational null semantics are covered:
legacy null and true rows still match a technician-name search, the
explicitly cleared row does not, and the vendor company name still
matches all three.
2026-09-29 18:45:05 -03:00
Alexandre Brandizzi
35adef5808 fix(workorders): persist explicit technician assignment 2026-09-29 17:58:52 -03:00
19 changed files with 575 additions and 9 deletions

View file

@ -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");
}
}
}

View file

@ -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)");

View file

@ -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; }

View file

@ -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)]

View file

@ -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();
}

View file

@ -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))

View file

@ -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,

View file

@ -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; }

View file

@ -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; }

View file

@ -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,

View file

@ -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:

View file

@ -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,

View file

@ -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:

View file

@ -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,

View file

@ -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,

View file

@ -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")]

View file

@ -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()
{

View file

@ -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);
}
}

View file

@ -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;
}
}
}
}