feat(work-orders): re-derive lifecycle when board status is patched

Scheduled still requires a concrete date, and Incomplete/Pending with a date must promote even when only lifecycleStatus is sent.
This commit is contained in:
Arthur Bassi 2026-09-09 10:23:22 -03:00
parent 12354f9bf7
commit 8b4aec300f
4 changed files with 202 additions and 10 deletions

View file

@ -33,7 +33,7 @@ namespace SeaHaven.Services.Helpers
}
if (requested == LifecycleStatus.Scheduled
&& (!scheduledDate.HasValue || scheduleWeekOnly == true))
&& !WorkOrderBoardMutationRules.HasConcreteSchedule(scheduledDate, scheduleWeekOnly))
{
throw new WorkOrderBoardValidationException(
"ScheduledRequiresDate",

View file

@ -183,7 +183,7 @@ namespace SeaHaven.Services.Implementation
WorkOrderBoardFieldNames.WorkOrderType => ApplyWorkOrderType(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.LifecycleStatus => new List<FieldChange> { ApplyLifecycleStatus(workOrder, value, auditField) },
WorkOrderBoardFieldNames.LifecycleStatus => ApplyLifecycleStatus(workOrder, value, auditField),
WorkOrderBoardFieldNames.AssignTo => ApplyAssignTo(workOrder, value, auditField),
WorkOrderBoardFieldNames.DueDate => new List<FieldChange> { ApplyDateField(value, auditField, v => workOrder.DueDate = v, () => workOrder.DueDate) },
WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField),
@ -503,7 +503,7 @@ namespace SeaHaven.Services.Implementation
return normalized;
}
private static FieldChange ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField)
private static List<FieldChange> ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField)
{
var parsed = LifecycleStatusMapper.ParseLifecycleStatus(value);
if (parsed == null)
@ -516,16 +516,31 @@ namespace SeaHaven.Services.Implementation
"Replace the provisional SH work order number before marking the work order Completed.");
}
if (parsed == LifecycleStatus.Scheduled
&& !WorkOrderBoardMutationRules.HasConcreteSchedule(
workOrder.ScheduledDate,
workOrder.ScheduleWeekOnly))
{
throw new WorkOrderBoardValidationException(
"ScheduledRequiresDate",
"Scheduled requires a concrete scheduledDate when scheduleWeekOnly is not true.");
}
var changes = new List<FieldChange>();
var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status;
if (workOrder.LifecycleStatus == parsed)
return FieldChange.Unchanged(auditField);
if (workOrder.LifecycleStatus != parsed)
{
workOrder.LifecycleStatus = parsed;
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(parsed);
if (workOrder.LegacyStatus == null && workOrder.Status != null)
workOrder.LegacyStatus = workOrder.Status;
workOrder.LifecycleStatus = parsed;
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(parsed);
if (workOrder.LegacyStatus == null && workOrder.Status != null)
workOrder.LegacyStatus = workOrder.Status;
changes.Add(FieldChange.StatusChanged(auditField, old, parsed.ToString()));
}
return FieldChange.StatusChanged(auditField, old, parsed.ToString());
// why: Incomplete/Pending patched while a concrete date already exists must still promote.
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
return changes;
}
private static List<FieldChange> ApplyAssignTo(WorkOrder workOrder, string? value, string auditField)

View file

@ -742,6 +742,26 @@ public class WorkOrderBoardCreateServiceTests
Assert.Equal("ScheduledRequiresDate", ex.Code);
}
[Fact]
public async Task Create_WithScheduledLifecycleWeekOnly_Throws()
{
var (_, service) = CreateSut();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 7, 15),
ScheduleWeekOnly = true,
TargetWeek = new DateOnly(2026, 7, 13),
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
Assert.Equal("ScheduledRequiresDate", ex.Code);
}
[Fact]
public async Task Create_EmergencyWithSeverity_Persists()
{

View file

@ -128,6 +128,9 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal(LifecycleStatus.Pending, result.LifecycleStatus);
Assert.Null(result.ScheduledDate);
var audits = await context.WorkOrderAuditLogs.ToListAsync();
Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString());
Assert.Contains(audits, a => a.FieldName == "LifecycleStatus" && a.NewValue == LifecycleStatus.Pending.ToString());
}
[Fact]
@ -154,6 +157,136 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal(LifecycleStatus.Pending, result.LifecycleStatus);
Assert.True(result.ScheduleWeekOnly);
var audits = await context.WorkOrderAuditLogs.ToListAsync();
Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString());
}
[Fact]
public async Task PatchField_ScheduleWeekOnlyFalse_PromotesPendingToScheduled()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Pending,
ScheduledDate = new DateTime(2026, 12, 15),
ScheduleWeekOnly = true,
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.ScheduleWeekOnly,
Value = "false",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
Assert.False(result.ScheduleWeekOnly);
}
[Fact]
public async Task PatchField_LifecycleStatusScheduledWithoutDate_Throws()
{
var (context, service) = CreateSut();
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 ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Scheduled",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("ScheduledRequiresDate", ex.Code);
Assert.Equal(LifecycleStatus.Incomplete, (await context.workOrders.SingleAsync()).LifecycleStatus);
}
[Fact]
public async Task PatchField_LifecycleStatusScheduledWithWeekOnly_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Pending,
ScheduledDate = DateTime.UtcNow.Date.AddDays(14),
ScheduleWeekOnly = true,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Scheduled",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("ScheduledRequiresDate", ex.Code);
Assert.Equal(LifecycleStatus.Pending, (await context.workOrders.SingleAsync()).LifecycleStatus);
}
[Fact]
public async Task PatchField_LifecycleStatusScheduledWithConcreteDate_Persists()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
ScheduledDate = DateTime.UtcNow.Date.AddDays(14),
ScheduleWeekOnly = false,
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.LifecycleStatus,
Value = "Scheduled",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
}
[Fact]
public async Task PatchField_LifecycleStatusPendingWithConcreteDate_PromotesToScheduled()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
ScheduledDate = DateTime.UtcNow.Date.AddDays(14),
ScheduleWeekOnly = false,
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.LifecycleStatus,
Value = "Pending",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
}
[Fact]
@ -180,6 +313,30 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal(LifecycleStatus.InProgress, result.LifecycleStatus);
}
[Fact]
public async Task PatchField_EnRoute_ClearDate_DoesNotChangeStatus()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.EnRoute,
ScheduledDate = new DateTime(2026, 6, 25),
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.ScheduledDate,
Value = null,
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.EnRoute, result.LifecycleStatus);
}
[Fact]
public async Task PatchField_IncrementsRescheduleCount()
{