Merge branch 'dev' into feat/ab/sh-348-dashboard-region

This commit is contained in:
Alexandre Brandizzi 2026-09-17 01:30:08 -03:00 • committed by GitHub
commit 7e06d1c51b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 4435 additions and 5 deletions

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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