mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
Merge pull request #131 from Sea-Haven-Industries/feat/ab/sh-187-service-picker
SH-187: Connect work-order service selection to the registry
This commit is contained in:
commit
e07f04a6af
15 changed files with 4435 additions and 5 deletions
|
|
@ -80,6 +80,12 @@ namespace Data.SeaHavenIndustries
|
||||||
.HasIndex(w => w.InternalWONumber)
|
.HasIndex(w => w.InternalWONumber)
|
||||||
.HasFilter("[istemplate] = 0");
|
.HasFilter("[istemplate] = 0");
|
||||||
|
|
||||||
|
builder.Entity<WorkOrder>()
|
||||||
|
.HasOne(w => w.ServiceDefinition)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(w => w.ServiceId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
builder.Entity<WorkOrder>()
|
builder.Entity<WorkOrder>()
|
||||||
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
|
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
|
||||||
.HasFilter("[istemplate] = 0");
|
.HasFilter("[istemplate] = 0");
|
||||||
|
|
|
||||||
4134
Data.SeaHavenIndustries/Migrations/20260916204606_SH187_WorkOrderServiceLink.Designer.cs
generated
Normal file
4134
Data.SeaHavenIndustries/Migrations/20260916204606_SH187_WorkOrderServiceLink.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,73 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SH187_WorkOrderServiceLink : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ServiceId",
|
||||||
|
table: "workOrders",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ServiceNameSnapshot",
|
||||||
|
table: "workOrders",
|
||||||
|
type: "nvarchar(200)",
|
||||||
|
maxLength: 200,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
UPDATE w
|
||||||
|
SET
|
||||||
|
w.[ServiceNameSnapshot] = LEFT(COALESCE(NULLIF(LTRIM(RTRIM(w.[Trade])), N''), NULLIF(LTRIM(RTRIM(w.[Problem])), N'')), 200),
|
||||||
|
w.[ServiceId] = s.[Id]
|
||||||
|
FROM [workOrders] w
|
||||||
|
LEFT JOIN [Services] s
|
||||||
|
ON (s.[IsDeleted] <> 1 OR s.[IsDeleted] IS NULL)
|
||||||
|
AND s.[NormalizedName] = LOWER(LTRIM(RTRIM(COALESCE(NULLIF(LTRIM(RTRIM(w.[Trade])), N''), NULLIF(LTRIM(RTRIM(w.[Problem])), N'')))))
|
||||||
|
WHERE w.[ServiceNameSnapshot] IS NULL;
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_workOrders_ServiceId",
|
||||||
|
table: "workOrders",
|
||||||
|
column: "ServiceId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_workOrders_Services_ServiceId",
|
||||||
|
table: "workOrders",
|
||||||
|
column: "ServiceId",
|
||||||
|
principalTable: "Services",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_workOrders_Services_ServiceId",
|
||||||
|
table: "workOrders");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_workOrders_ServiceId",
|
||||||
|
table: "workOrders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ServiceId",
|
||||||
|
table: "workOrders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ServiceNameSnapshot",
|
||||||
|
table: "workOrders");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2763,6 +2763,13 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
.HasColumnType("nvarchar(128)");
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<int?>("ServiceId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceNameSnapshot")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
b.Property<string>("ServiceNotes")
|
b.Property<string>("ServiceNotes")
|
||||||
.HasMaxLength(4000)
|
.HasMaxLength(4000)
|
||||||
.HasColumnType("nvarchar(4000)");
|
.HasColumnType("nvarchar(4000)");
|
||||||
|
|
@ -2847,6 +2854,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
|
||||||
b.HasIndex("ScheduledDate");
|
b.HasIndex("ScheduledDate");
|
||||||
|
|
||||||
|
b.HasIndex("ServiceId");
|
||||||
|
|
||||||
b.HasIndex("SiteCode")
|
b.HasIndex("SiteCode")
|
||||||
.HasFilter("[istemplate] = 0");
|
.HasFilter("[istemplate] = 0");
|
||||||
|
|
||||||
|
|
@ -3876,6 +3885,11 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
.HasForeignKey("PrimaryDispatchId")
|
.HasForeignKey("PrimaryDispatchId")
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("Data.SeaHavenIndustries.Service", "ServiceDefinition")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ServiceId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.Navigation("Account");
|
b.Navigation("Account");
|
||||||
|
|
||||||
b.Navigation("AssignToUser");
|
b.Navigation("AssignToUser");
|
||||||
|
|
@ -3883,6 +3897,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Navigation("Locations");
|
b.Navigation("Locations");
|
||||||
|
|
||||||
b.Navigation("PrimaryDispatch");
|
b.Navigation("PrimaryDispatch");
|
||||||
|
|
||||||
|
b.Navigation("ServiceDefinition");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,11 @@ namespace Data.SeaHavenIndustries
|
||||||
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
|
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
|
||||||
[MaxLength(128)]
|
[MaxLength(128)]
|
||||||
public string? Service { get; set; }
|
public string? Service { get; set; }
|
||||||
|
public int? ServiceId { get; set; }
|
||||||
|
[MaxLength(200)]
|
||||||
|
public string? ServiceNameSnapshot { get; set; }
|
||||||
|
[ForeignKey(nameof(ServiceId))]
|
||||||
|
public virtual Service? ServiceDefinition { get; set; }
|
||||||
public int? PrimaryDispatchId { get; set; }
|
public int? PrimaryDispatchId { get; set; }
|
||||||
[ForeignKey(nameof(PrimaryDispatchId))]
|
[ForeignKey(nameof(PrimaryDispatchId))]
|
||||||
public virtual Dispatch? PrimaryDispatch { get; set; }
|
public virtual Dispatch? PrimaryDispatch { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
w.OriginalDate,
|
w.OriginalDate,
|
||||||
w.OriginalWeek,
|
w.OriginalWeek,
|
||||||
w.WorkOrderType,
|
w.WorkOrderType,
|
||||||
|
w.ServiceId,
|
||||||
|
w.ServiceNameSnapshot,
|
||||||
w.SiteCode,
|
w.SiteCode,
|
||||||
LocationName = w.Locations != null ? w.Locations.Name : null,
|
LocationName = w.Locations != null ? w.Locations.Name : null,
|
||||||
WoPocName = w.PocName,
|
WoPocName = w.PocName,
|
||||||
|
|
@ -136,6 +138,8 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
w.CarriedOver,
|
w.CarriedOver,
|
||||||
w.IsAddOn,
|
w.IsAddOn,
|
||||||
w.WorkOrderType,
|
w.WorkOrderType,
|
||||||
|
w.ServiceId,
|
||||||
|
w.ServiceNameSnapshot,
|
||||||
frozenSite?.Code ?? w.SiteCode,
|
frozenSite?.Code ?? w.SiteCode,
|
||||||
frozenSite?.Label ?? w.LocationName,
|
frozenSite?.Label ?? w.LocationName,
|
||||||
pocName,
|
pocName,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
int CarriedOver,
|
int CarriedOver,
|
||||||
bool IsAddOn,
|
bool IsAddOn,
|
||||||
WorkOrderType? WorkOrderType,
|
WorkOrderType? WorkOrderType,
|
||||||
|
int? ServiceId,
|
||||||
|
string? ServiceNameSnapshot,
|
||||||
string? SiteCode,
|
string? SiteCode,
|
||||||
string? LocationName,
|
string? LocationName,
|
||||||
string? PocName,
|
string? PocName,
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,8 @@ namespace SeaHaven.Services.DTOs
|
||||||
public DateOnly? OriginalDate { get; set; }
|
public DateOnly? OriginalDate { get; set; }
|
||||||
public DateOnly? OriginalWeek { get; set; }
|
public DateOnly? OriginalWeek { get; set; }
|
||||||
public WorkOrderType? WorkOrderType { get; set; }
|
public WorkOrderType? WorkOrderType { get; set; }
|
||||||
|
public int? ServiceId { get; set; }
|
||||||
|
public string? ServiceNameSnapshot { get; set; }
|
||||||
public bool IsPastDue { get; set; }
|
public bool IsPastDue { get; set; }
|
||||||
public string? SiteCode { get; set; }
|
public string? SiteCode { get; set; }
|
||||||
public string? LocationName { get; set; }
|
public string? LocationName { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Trade { get; set; }
|
public string? Trade { get; set; }
|
||||||
/// <summary>Primary service label; persisted as Trade / board pm.</summary>
|
/// <summary>Primary service label; persisted as Trade / board pm.</summary>
|
||||||
public string? PrimaryService { get; set; }
|
public string? PrimaryService { get; set; }
|
||||||
|
public int? ServiceId { get; set; }
|
||||||
public List<string>? ExtraServices { get; set; }
|
public List<string>? ExtraServices { get; set; }
|
||||||
public string? ServiceNotes { get; set; }
|
public string? ServiceNotes { get; set; }
|
||||||
/// <summary>Per-WO Aveta compliance. Omit or false = not required.</summary>
|
/// <summary>Per-WO Aveta compliance. Omit or false = not required.</summary>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
public const string DocStatus = "docStatus";
|
public const string DocStatus = "docStatus";
|
||||||
public const string CompletedDate = "completedDate";
|
public const string CompletedDate = "completedDate";
|
||||||
public const string Pm = "pm";
|
public const string Pm = "pm";
|
||||||
|
public const string ServiceId = "serviceId";
|
||||||
public const string ServiceNotes = "serviceNotes";
|
public const string ServiceNotes = "serviceNotes";
|
||||||
public const string ExtraServices = "extraServices";
|
public const string ExtraServices = "extraServices";
|
||||||
public const string AdditionalContacts = "additionalContacts";
|
public const string AdditionalContacts = "additionalContacts";
|
||||||
|
|
@ -49,6 +50,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
DocStatus,
|
DocStatus,
|
||||||
CompletedDate,
|
CompletedDate,
|
||||||
Pm,
|
Pm,
|
||||||
|
ServiceId,
|
||||||
ServiceNotes,
|
ServiceNotes,
|
||||||
ExtraServices,
|
ExtraServices,
|
||||||
AdditionalContacts,
|
AdditionalContacts,
|
||||||
|
|
@ -93,6 +95,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
DocStatus => "DocStatus",
|
DocStatus => "DocStatus",
|
||||||
CompletedDate => "CompletedDate",
|
CompletedDate => "CompletedDate",
|
||||||
Pm => "Trade",
|
Pm => "Trade",
|
||||||
|
ServiceId => "ServiceId",
|
||||||
ServiceNotes => "ServiceNotes",
|
ServiceNotes => "ServiceNotes",
|
||||||
ExtraServices => "ExtraServices",
|
ExtraServices => "ExtraServices",
|
||||||
AdditionalContacts => "AdditionalContacts",
|
AdditionalContacts => "AdditionalContacts",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
private readonly IWorkOrderAuditService _auditService;
|
private readonly IWorkOrderAuditService _auditService;
|
||||||
private readonly IWorkOrderBoardCreateValidation _validator;
|
private readonly IWorkOrderBoardCreateValidation _validator;
|
||||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||||
|
private readonly IServicesRegistryService? _servicesRegistryService;
|
||||||
|
|
||||||
public WorkOrderBoardCreateService(
|
public WorkOrderBoardCreateService(
|
||||||
IWorkOrderBoardDataService boardDataService,
|
IWorkOrderBoardDataService boardDataService,
|
||||||
|
|
@ -27,7 +28,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
IWorkOrderBoardService boardService,
|
IWorkOrderBoardService boardService,
|
||||||
IWorkOrderAuditService auditService,
|
IWorkOrderAuditService auditService,
|
||||||
IWorkOrderBoardCreateValidation validator,
|
IWorkOrderBoardCreateValidation validator,
|
||||||
IWorkOrderAccountResolver accountResolver)
|
IWorkOrderAccountResolver accountResolver,
|
||||||
|
IServicesRegistryService? servicesRegistryService = null)
|
||||||
{
|
{
|
||||||
_boardDataService = boardDataService;
|
_boardDataService = boardDataService;
|
||||||
_mutationData = mutationData;
|
_mutationData = mutationData;
|
||||||
|
|
@ -35,6 +37,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
_auditService = auditService;
|
_auditService = auditService;
|
||||||
_validator = validator;
|
_validator = validator;
|
||||||
_accountResolver = accountResolver;
|
_accountResolver = accountResolver;
|
||||||
|
_servicesRegistryService = servicesRegistryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WorkOrderBoardRowDto> CreateAsync(
|
public async Task<WorkOrderBoardRowDto> CreateAsync(
|
||||||
|
|
@ -54,7 +57,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
var woNumber = await ResolveWoNumberAsync(request.WoNumber, cancellationToken);
|
var woNumber = await ResolveWoNumberAsync(request.WoNumber, cancellationToken);
|
||||||
var siteCode = request.SiteCode!.Trim();
|
var siteCode = request.SiteCode!.Trim();
|
||||||
var primaryService = ResolvePrimaryService(request);
|
var selectedService = await ResolveServiceAsync(request.ServiceId, request.WorkOrderType, request.PrimaryService ?? request.Trade, cancellationToken);
|
||||||
|
var primaryService = selectedService?.Name ?? ResolvePrimaryService(request);
|
||||||
var extraServicesJson = SerializeExtraServices(request.ExtraServices);
|
var extraServicesJson = SerializeExtraServices(request.ExtraServices);
|
||||||
var serviceNotes = TrimOrNull(request.ServiceNotes);
|
var serviceNotes = TrimOrNull(request.ServiceNotes);
|
||||||
var pocName = TrimOrNull(request.PocName);
|
var pocName = TrimOrNull(request.PocName);
|
||||||
|
|
@ -87,6 +91,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
AccountId = accountId,
|
AccountId = accountId,
|
||||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
||||||
Trade = primaryService,
|
Trade = primaryService,
|
||||||
|
ServiceId = selectedService?.Id,
|
||||||
|
ServiceNameSnapshot = selectedService?.Name,
|
||||||
ExtraServices = extraServicesJson,
|
ExtraServices = extraServicesJson,
|
||||||
ServiceNotes = serviceNotes,
|
ServiceNotes = serviceNotes,
|
||||||
AvetaRequired = request.AvetaRequired == true,
|
AvetaRequired = request.AvetaRequired == true,
|
||||||
|
|
@ -138,6 +144,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Description", "", workOrder.Description));
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Description", "", workOrder.Description));
|
||||||
if (workOrder.Trade != null)
|
if (workOrder.Trade != null)
|
||||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Trade", "", workOrder.Trade));
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Trade", "", workOrder.Trade));
|
||||||
|
if (workOrder.ServiceId.HasValue)
|
||||||
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ServiceId", "", workOrder.ServiceId.Value.ToString()));
|
||||||
if (workOrder.ExtraServices != null)
|
if (workOrder.ExtraServices != null)
|
||||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ExtraServices", "", workOrder.ExtraServices));
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ExtraServices", "", workOrder.ExtraServices));
|
||||||
if (workOrder.ServiceNotes != null)
|
if (workOrder.ServiceNotes != null)
|
||||||
|
|
@ -251,6 +259,46 @@ namespace SeaHaven.Services.Implementation
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<ServiceDto?> ResolveServiceAsync(
|
||||||
|
int? serviceId,
|
||||||
|
WorkOrderType workOrderType,
|
||||||
|
string? legacyLabel,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_servicesRegistryService == null)
|
||||||
|
{
|
||||||
|
if (serviceId.HasValue)
|
||||||
|
throw new WorkOrderBoardValidationException("ServiceInvalid", "serviceId is not available.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(workOrderType))
|
||||||
|
{
|
||||||
|
if (serviceId.HasValue)
|
||||||
|
throw new WorkOrderBoardValidationException(
|
||||||
|
"ServiceInvalid",
|
||||||
|
"serviceId is not supported for this work order type.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var services = await _servicesRegistryService.GetAllAsync(true, workOrderType, cancellationToken);
|
||||||
|
if (serviceId.HasValue)
|
||||||
|
{
|
||||||
|
var selected = services.FirstOrDefault(service => service.Id == serviceId.Value);
|
||||||
|
if (selected == null)
|
||||||
|
throw new WorkOrderBoardValidationException(
|
||||||
|
"ServiceInvalid",
|
||||||
|
"serviceId must reference an active service that supports this work order type.");
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(legacyLabel))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return services.FirstOrDefault(service =>
|
||||||
|
string.Equals(service.Name.Trim(), legacyLabel.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
private static string? SerializeExtraServices(List<string>? extras)
|
private static string? SerializeExtraServices(List<string>? extras)
|
||||||
{
|
{
|
||||||
if (extras == null || extras.Count == 0)
|
if (extras == null || extras.Count == 0)
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
OriginalDate = row.OriginalDate,
|
OriginalDate = row.OriginalDate,
|
||||||
OriginalWeek = row.OriginalWeek,
|
OriginalWeek = row.OriginalWeek,
|
||||||
WorkOrderType = row.WorkOrderType,
|
WorkOrderType = row.WorkOrderType,
|
||||||
|
ServiceId = row.ServiceId,
|
||||||
|
ServiceNameSnapshot = row.ServiceNameSnapshot,
|
||||||
IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow),
|
IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow),
|
||||||
SiteCode = row.SiteCode,
|
SiteCode = row.SiteCode,
|
||||||
LocationName = row.LocationName,
|
LocationName = row.LocationName,
|
||||||
|
|
@ -136,7 +138,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
TechPhone = row.TechPhone,
|
TechPhone = row.TechPhone,
|
||||||
ApptDate = row.DispatchApptDate ?? row.ScheduledDate,
|
ApptDate = row.DispatchApptDate ?? row.ScheduledDate,
|
||||||
ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd),
|
ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd),
|
||||||
Pm = row.Trade ?? row.Problem,
|
Pm = row.Trade ?? row.ServiceNameSnapshot ?? row.Problem,
|
||||||
ServiceNotes = row.ServiceNotes,
|
ServiceNotes = row.ServiceNotes,
|
||||||
ExtraServices = ParseExtraServices(row.ExtraServices),
|
ExtraServices = ParseExtraServices(row.ExtraServices),
|
||||||
AdditionalContacts = MapAdditionalContacts(row),
|
AdditionalContacts = MapAdditionalContacts(row),
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,18 @@ namespace SeaHaven.Services.Implementation
|
||||||
private readonly IWorkOrderBoardDataService _boardDataService;
|
private readonly IWorkOrderBoardDataService _boardDataService;
|
||||||
private readonly IWorkOrderBoardMutationDataService _mutationData;
|
private readonly IWorkOrderBoardMutationDataService _mutationData;
|
||||||
private readonly IWorkOrderAuditService _auditService;
|
private readonly IWorkOrderAuditService _auditService;
|
||||||
|
private readonly IServicesRegistryService? _servicesRegistryService;
|
||||||
|
|
||||||
public WorkOrderBoardUpdateService(
|
public WorkOrderBoardUpdateService(
|
||||||
IWorkOrderBoardDataService boardDataService,
|
IWorkOrderBoardDataService boardDataService,
|
||||||
IWorkOrderBoardMutationDataService mutationData,
|
IWorkOrderBoardMutationDataService mutationData,
|
||||||
IWorkOrderAuditService auditService)
|
IWorkOrderAuditService auditService,
|
||||||
|
IServicesRegistryService? servicesRegistryService = null)
|
||||||
{
|
{
|
||||||
_boardDataService = boardDataService;
|
_boardDataService = boardDataService;
|
||||||
_mutationData = mutationData;
|
_mutationData = mutationData;
|
||||||
_auditService = auditService;
|
_auditService = auditService;
|
||||||
|
_servicesRegistryService = servicesRegistryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WorkOrderBoardRowDto> PatchFieldAsync(
|
public async Task<WorkOrderBoardRowDto> PatchFieldAsync(
|
||||||
|
|
@ -73,6 +76,13 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
_mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion);
|
_mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion);
|
||||||
|
|
||||||
|
if (canonicalField.Equals(WorkOrderBoardFieldNames.WorkOrderType, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& workOrder.ServiceId.HasValue)
|
||||||
|
{
|
||||||
|
var nextType = ParseWorkOrderType(value: request.Value);
|
||||||
|
await EnsureServiceSupportsTypeAsync(workOrder.ServiceId.Value, nextType, ct);
|
||||||
|
}
|
||||||
|
|
||||||
Dispatch? dispatch = null;
|
Dispatch? dispatch = null;
|
||||||
ResolvedDispatch? resolved = null;
|
ResolvedDispatch? resolved = null;
|
||||||
if (WorkOrderBoardFieldNames.IsDispatchField(canonicalField))
|
if (WorkOrderBoardFieldNames.IsDispatchField(canonicalField))
|
||||||
|
|
@ -181,6 +191,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { await ApplyWoNumber(workOrder, value, auditField, cancellationToken) },
|
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { await ApplyWoNumber(workOrder, value, auditField, cancellationToken) },
|
||||||
WorkOrderBoardFieldNames.WorkOrderType => ApplyWorkOrderType(workOrder, value, auditField),
|
WorkOrderBoardFieldNames.WorkOrderType => ApplyWorkOrderType(workOrder, value, auditField),
|
||||||
|
WorkOrderBoardFieldNames.ServiceId => await ApplyServiceIdAsync(workOrder, value, auditField, cancellationToken),
|
||||||
WorkOrderBoardFieldNames.Severity => new List<FieldChange> { ApplySeverity(workOrder, value, auditField) },
|
WorkOrderBoardFieldNames.Severity => new List<FieldChange> { ApplySeverity(workOrder, value, auditField) },
|
||||||
WorkOrderBoardFieldNames.SiteCode => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) },
|
WorkOrderBoardFieldNames.SiteCode => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) },
|
||||||
WorkOrderBoardFieldNames.LifecycleStatus => ApplyLifecycleStatus(workOrder, value, auditField),
|
WorkOrderBoardFieldNames.LifecycleStatus => ApplyLifecycleStatus(workOrder, value, auditField),
|
||||||
|
|
@ -388,6 +399,83 @@ namespace SeaHaven.Services.Implementation
|
||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<List<FieldChange>> ApplyServiceIdAsync(
|
||||||
|
WorkOrder workOrder,
|
||||||
|
string? value,
|
||||||
|
string auditField,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_servicesRegistryService == null)
|
||||||
|
throw new WorkOrderBoardValidationException("ServiceInvalid", "Service registry is unavailable.");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
var previousServiceId = workOrder.ServiceId;
|
||||||
|
var previousServiceLabel = workOrder.ServiceNameSnapshot ?? workOrder.Trade;
|
||||||
|
workOrder.ServiceId = null;
|
||||||
|
workOrder.ServiceNameSnapshot = null;
|
||||||
|
workOrder.Trade = null;
|
||||||
|
return new List<FieldChange>
|
||||||
|
{
|
||||||
|
FieldChange.ForField(auditField, previousServiceId?.ToString(), null),
|
||||||
|
FieldChange.ForField("Trade", previousServiceLabel, null)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!int.TryParse(value.Trim(), out var serviceId) || serviceId <= 0)
|
||||||
|
throw new WorkOrderBoardValidationException("ServiceInvalid", "serviceId must be a positive integer.");
|
||||||
|
|
||||||
|
var service = await _servicesRegistryService.GetByIdAsync(serviceId, cancellationToken);
|
||||||
|
if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(workOrder.WorkOrderType ?? WorkOrderType.PM)
|
||||||
|
|| service == null
|
||||||
|
|| !service.IsActive
|
||||||
|
|| !service.SupportedWorkOrderTypes.Contains(workOrder.WorkOrderType ?? WorkOrderType.PM))
|
||||||
|
{
|
||||||
|
throw new WorkOrderBoardValidationException(
|
||||||
|
"ServiceInvalid",
|
||||||
|
"serviceId must reference an active service that supports this work order type.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldId = workOrder.ServiceId;
|
||||||
|
var oldLabel = workOrder.ServiceNameSnapshot ?? workOrder.Trade;
|
||||||
|
workOrder.ServiceId = service.Id;
|
||||||
|
workOrder.ServiceNameSnapshot = service.Name;
|
||||||
|
workOrder.Trade = service.Name;
|
||||||
|
return new List<FieldChange>
|
||||||
|
{
|
||||||
|
oldId == service.Id
|
||||||
|
? FieldChange.Unchanged(auditField)
|
||||||
|
: FieldChange.ForField(auditField, oldId?.ToString(), service.Id.ToString()),
|
||||||
|
oldLabel == service.Name
|
||||||
|
? FieldChange.Unchanged("Trade")
|
||||||
|
: FieldChange.ForField("Trade", oldLabel, service.Name)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureServiceSupportsTypeAsync(int serviceId, WorkOrderType workOrderType, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_servicesRegistryService == null)
|
||||||
|
throw new WorkOrderBoardValidationException("ServiceTypeInvalid", "Service registry is unavailable.");
|
||||||
|
|
||||||
|
var service = await _servicesRegistryService.GetByIdAsync(serviceId, cancellationToken);
|
||||||
|
if (service == null || !service.SupportedWorkOrderTypes.Contains(workOrderType))
|
||||||
|
throw new WorkOrderBoardValidationException(
|
||||||
|
"ServiceTypeInvalid",
|
||||||
|
"The selected service does not support this work order type.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderType ParseWorkOrderType(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value)
|
||||||
|
|| string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| !WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
|
||||||
|
{
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
private static FieldChange ApplySeverity(WorkOrder workOrder, string? value, string auditField)
|
private static FieldChange ApplySeverity(WorkOrder workOrder, string? value, string auditField)
|
||||||
{
|
{
|
||||||
if (!WorkOrderSeverityRules.TryNormalize(value, out var normalized))
|
if (!WorkOrderSeverityRules.TryNormalize(value, out var normalized))
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,28 @@ public class SH303ServicesRegistryMigrationTests
|
||||||
Assert.Empty(operations.OfType<SqlOperation>());
|
Assert.Empty(operations.OfType<SqlOperation>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SH187_Up_AddsNullableServiceLinkAndBackfillsLegacyLabels()
|
||||||
|
{
|
||||||
|
var operations = BuildOperations<SH187_WorkOrderServiceLink>("Up");
|
||||||
|
|
||||||
|
var columns = operations.OfType<AddColumnOperation>().ToList();
|
||||||
|
Assert.Contains(columns, c => c.Table == "workOrders" && c.Name == "ServiceId" && c.IsNullable);
|
||||||
|
Assert.Contains(columns, c => c.Table == "workOrders" && c.Name == "ServiceNameSnapshot"
|
||||||
|
&& c.IsNullable && c.MaxLength == 200);
|
||||||
|
Assert.Contains(operations.OfType<CreateIndexOperation>(), i =>
|
||||||
|
i.Table == "workOrders" && i.Columns.SequenceEqual(new[] { "ServiceId" }));
|
||||||
|
Assert.Contains(operations.OfType<AddForeignKeyOperation>(), fk =>
|
||||||
|
fk.Table == "workOrders"
|
||||||
|
&& fk.PrincipalTable == "Services"
|
||||||
|
&& fk.OnDelete == ReferentialAction.Restrict);
|
||||||
|
|
||||||
|
var sql = string.Join("\n", operations.OfType<SqlOperation>().Select(o => o.Sql));
|
||||||
|
Assert.Contains("UPDATE w", sql);
|
||||||
|
Assert.Contains("ServiceNameSnapshot", sql);
|
||||||
|
Assert.Contains("LEFT JOIN [Services]", sql);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Up_SeedsActiveProblemValuesIdempotently_WhenSqlServerAvailable()
|
public async Task Up_SeedsActiveProblemValuesIdempotently_WhenSqlServerAvailable()
|
||||||
{
|
{
|
||||||
|
|
@ -205,11 +227,15 @@ public class SH303ServicesRegistryMigrationTests
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<MigrationOperation> BuildOperations(string direction)
|
private static List<MigrationOperation> BuildOperations(string direction)
|
||||||
|
=> BuildOperations<SH303_ServicesRegistry>(direction);
|
||||||
|
|
||||||
|
private static List<MigrationOperation> BuildOperations<TMigration>(string direction)
|
||||||
|
where TMigration : Migration, new()
|
||||||
{
|
{
|
||||||
var builder = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer");
|
var builder = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer");
|
||||||
var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic)
|
var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||||
?? throw new InvalidOperationException($"Migration.{direction} not found.");
|
?? throw new InvalidOperationException($"Migration.{direction} not found.");
|
||||||
operation.Invoke(new SH303_ServicesRegistry(), [builder]);
|
operation.Invoke(new TMigration(), [builder]);
|
||||||
return builder.Operations;
|
return builder.Operations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1043,6 +1043,24 @@ public class WorkOrderBoardServiceTests
|
||||||
Assert.Equal("09:00", dto.ApptTime);
|
Assert.Equal("09:00", dto.ApptTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapRawRow_PmEditTakesPrecedenceOverServiceSnapshot()
|
||||||
|
{
|
||||||
|
var row = RawRow(
|
||||||
|
scheduledDate: new DateTime(2026, 6, 23),
|
||||||
|
scheduledStart: null,
|
||||||
|
scheduledEnd: null,
|
||||||
|
dispatchApptDate: null) with
|
||||||
|
{
|
||||||
|
Trade = "Updated service label",
|
||||||
|
ServiceNameSnapshot = "Original service label"
|
||||||
|
};
|
||||||
|
|
||||||
|
var dto = WorkOrderBoardService.MapRawRow(row, DateTime.UtcNow);
|
||||||
|
|
||||||
|
Assert.Equal("Updated service label", dto.Pm);
|
||||||
|
}
|
||||||
|
|
||||||
private static WorkOrderBoardRawRow RawRow(
|
private static WorkOrderBoardRawRow RawRow(
|
||||||
DateTime? scheduledDate,
|
DateTime? scheduledDate,
|
||||||
DateTime? scheduledStart,
|
DateTime? scheduledStart,
|
||||||
|
|
@ -1055,6 +1073,8 @@ public class WorkOrderBoardServiceTests
|
||||||
CarriedOver: 0,
|
CarriedOver: 0,
|
||||||
IsAddOn: false,
|
IsAddOn: false,
|
||||||
WorkOrderType: null,
|
WorkOrderType: null,
|
||||||
|
ServiceId: null,
|
||||||
|
ServiceNameSnapshot: null,
|
||||||
SiteCode: null,
|
SiteCode: null,
|
||||||
LocationName: null,
|
LocationName: null,
|
||||||
PocName: null,
|
PocName: null,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue