diff --git a/Data.SeaHavenIndustries/Migrations/20260929210000_SH411WorkOrderTechnicianAssigned.cs b/Data.SeaHavenIndustries/Migrations/20260929210000_SH411WorkOrderTechnicianAssigned.cs new file mode 100644 index 0000000..de365ba --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260929210000_SH411WorkOrderTechnicianAssigned.cs @@ -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( + name: "TechnicianAssigned", + table: "workOrders", + type: "bit", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "TechnicianAssigned", + table: "workOrders"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 78c4d11..9bde6e5 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2826,6 +2826,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("FrozenCompany") .HasColumnType("nvarchar(max)"); + b.Property("TechnicianAssigned") + .HasColumnType("bit"); + b.Property("FrozenPoc") .HasColumnType("nvarchar(max)"); diff --git a/Data.SeaHavenIndustries/Models/WorkOrderCompletionSnapshot.cs b/Data.SeaHavenIndustries/Models/WorkOrderCompletionSnapshot.cs index 98e02ae..9628b03 100644 --- a/Data.SeaHavenIndustries/Models/WorkOrderCompletionSnapshot.cs +++ b/Data.SeaHavenIndustries/Models/WorkOrderCompletionSnapshot.cs @@ -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; } diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 8e5cd54..f5c5edb 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -51,6 +51,8 @@ namespace Data.SeaHavenIndustries public int? PrimaryDispatchId { get; set; } [ForeignKey(nameof(PrimaryDispatchId))] public virtual Dispatch? PrimaryDispatch { get; set; } + /// Explicit technician selection for this work order; null preserves legacy vendor-contact behavior. + public bool? TechnicianAssigned { get; set; } // --- Service & Notes / POC (structured create) --- [MaxLength(200)] diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index e14972f..f12d774 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -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(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(); } diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs index 5232121..909540c 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs @@ -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)) diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index 1aea2d8..766afe8 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -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 ScheduledRows, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index 44938d8..18ed325 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -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; } /// /// Primary dispatch status. Inactive statuses are not a live company assignment. /// @@ -147,6 +149,8 @@ namespace SeaHaven.Services.DTOs { public string Field { get; set; } = ""; public string? Value { get; set; } + /// Explicit technician-selection state, accepted only with a vendorId patch. + public bool? TechnicianAssigned { get; set; } public string? WorkOrderVersion { get; set; } public string? DispatchVersion { get; set; } public int? PrimaryDispatchId { get; set; } diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 7dd513f..829d810 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -45,6 +45,8 @@ namespace SeaHaven.Services.DTOs /// Optional create-time lifecycle. When omitted, server defaults to Incomplete. public LifecycleStatus? LifecycleStatus { get; set; } public int? VendorId { get; set; } + /// Optional explicit technician-selection state for the selected vendor. + public bool? TechnicianAssigned { get; set; } public DateTime? ApptDate { get; set; } public string? ApptTime { get; set; } public int? PocContactId { get; set; } diff --git a/SeaHaven.Services/Helpers/WorkOrderCompletionSnapshotMapper.cs b/SeaHaven.Services/Helpers/WorkOrderCompletionSnapshotMapper.cs index 0fd78cb..09ef03c 100644 --- a/SeaHaven.Services/Helpers/WorkOrderCompletionSnapshotMapper.cs +++ b/SeaHaven.Services/Helpers/WorkOrderCompletionSnapshotMapper.cs @@ -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, diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index 6218c9b..baed123 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -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: diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 9341fa8..cc43121 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -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, diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 3a194fc..25e56de 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -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.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: diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs index 432996d..e5f8672 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -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, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs index d91794e..3e539ab 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs @@ -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, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index ae19c33..9901e3a 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -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(() => + 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")] diff --git a/SeaHavenIndustries.Tests/WorkOrderCompletionFreezeTests.cs b/SeaHavenIndustries.Tests/WorkOrderCompletionFreezeTests.cs index 11f7ab4..8d837a6 100644 --- a/SeaHavenIndustries.Tests/WorkOrderCompletionFreezeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderCompletionFreezeTests.cs @@ -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() { diff --git a/SeaHavenIndustries.Tests/WorkOrderMigrationDiscoveryTests.cs b/SeaHavenIndustries.Tests/WorkOrderMigrationDiscoveryTests.cs index 30258a6..5fdc56e 100644 --- a/SeaHavenIndustries.Tests/WorkOrderMigrationDiscoveryTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderMigrationDiscoveryTests.cs @@ -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() + .UseSqlite("DataSource=:memory:") + .Options; + + using var context = new ApplicationDbContext(options); + var migrations = context.Database.GetMigrations().ToList(); + + Assert.Contains("20260929210000_SH411WorkOrderTechnicianAssigned", migrations); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderTechnicianAssignmentRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderTechnicianAssignmentRelationalTests.cs new file mode 100644 index 0000000..9c6d746 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderTechnicianAssignmentRelationalTests.cs @@ -0,0 +1,143 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +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() + .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( + await data.GetBoardRowByIdAsync(1)); + var workOrderB = Assert.IsType( + 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); + } + + private sealed class SqliteBoardTestDbContext : ApplicationDbContext + { + public SqliteBoardTestDbContext(DbContextOptions 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; + } + } + } +}