fix(work-orders): address PR #14 board PATCH review feedback

Reuse field-lock DB checks during staged audits, defer dispatch creation to the final save, and return 404 for missing work orders.
This commit is contained in:
Arthur Bassi 2026-07-09 09:54:53 -03:00
parent 91122d753d
commit 88caede86a
5 changed files with 94 additions and 41 deletions

View file

@ -131,6 +131,14 @@ namespace Api.SeaHavenIndustries.Controllers
CurrentState = ex.CurrentState
});
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto

View file

@ -28,17 +28,17 @@ namespace SeaHaven.Services.Implementation
=> LogAsync(workOrderId, AuditActionType.SyncRejected, AuditEventType.Sync,
AuditActorType.Sync, fieldName, oldValue, newValue);
public void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null)
=> StageAudit(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId);
public Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null)
=> StageAuditAsync(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId);
public void StageStatusChanged(int workOrderId, string? oldStatus, string? newStatus, string? actorId)
=> StageAudit(workOrderId, AuditActionType.StatusChanged, "LifecycleStatus", oldStatus, newStatus, actorId);
public Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId)
=> StageAuditAsync(workOrderId, AuditActionType.StatusChanged, "LifecycleStatus", oldStatus, newStatus, actorId);
public void StageAssignmentChanged(int workOrderId, string? oldValue, string? newValue, string? actorId)
=> StageAudit(workOrderId, AuditActionType.AssignmentChanged, "AssignTo", oldValue, newValue, actorId);
public Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId)
=> StageAuditAsync(workOrderId, AuditActionType.AssignmentChanged, "AssignTo", oldValue, newValue, actorId);
public void StageCreated(int workOrderId, string? actorId, string? woNumber)
=> StageAudit(workOrderId, AuditActionType.Create, "WorkOrder", null, woNumber, actorId);
public Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber)
=> StageAuditAsync(workOrderId, AuditActionType.Create, "WorkOrder", null, woNumber, actorId);
public async Task LogAsync(
int workOrderId,
@ -65,7 +65,7 @@ namespace SeaHaven.Services.Implementation
await _context.SaveChangesAsync();
}
private void StageAudit(
private async Task StageAuditAsync(
int workOrderId,
AuditActionType action,
string fieldName,
@ -76,7 +76,13 @@ namespace SeaHaven.Services.Implementation
{
AddAuditEntry(workOrderId, action, AuditEventType.Manual, AuditActorType.Dispatcher,
fieldName, oldValue, newValue, actorId, dispatchId);
StageFieldLock(workOrderId, fieldName, actorId);
if (action == AuditActionType.FieldChanged ||
action == AuditActionType.StatusChanged ||
action == AuditActionType.AssignmentChanged)
{
await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId);
}
}
private void AddAuditEntry(
@ -107,21 +113,5 @@ namespace SeaHaven.Services.Implementation
});
}
private void StageFieldLock(int workOrderId, string fieldName, string? userId)
{
var alreadyTracked = _context.WorkOrderFieldLocks.Local
.Any(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName);
if (alreadyTracked)
return;
_context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
{
WorkOrderId = workOrderId,
FieldName = fieldName,
LockedAt = DateTime.UtcNow,
LockedByUserId = userId
});
}
}
}

View file

@ -106,16 +106,17 @@ namespace SeaHaven.Services.Implementation
foreach (var change in changes)
{
var dispatchId = change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null);
switch (change.Action)
{
case AuditActionType.StatusChanged:
_auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId);
await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
break;
case AuditActionType.AssignmentChanged:
_auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId);
await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
break;
default:
_auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, change.DispatchId);
await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, dispatchId);
break;
}
}
@ -195,8 +196,7 @@ namespace SeaHaven.Services.Implementation
CreatedDate = DateTime.UtcNow
};
_context.Dispatches.Add(created);
await _context.SaveChangesAsync();
workOrder.PrimaryDispatchId = created.Id;
workOrder.PrimaryDispatch = created;
return created;
}
@ -392,10 +392,10 @@ namespace SeaHaven.Services.Implementation
var old = dispatch.VendorId.ToString();
if (dispatch.VendorId == vendorId.Value)
return FieldChange.Unchanged(auditField, dispatch.Id);
return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch));
dispatch.VendorId = vendorId.Value;
return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), dispatch.Id);
return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), ResolveDispatchIdForAudit(dispatch));
}
private static FieldChange ApplyApptDate(Dispatch dispatch, string? value, string auditField)
@ -411,12 +411,14 @@ namespace SeaHaven.Services.Implementation
var old = FormatDate(dispatch.ScheduledDate);
var newVal = FormatDate(parsed);
if (old == newVal)
return FieldChange.Unchanged(auditField, dispatch.Id);
return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch));
dispatch.ScheduledDate = parsed;
return FieldChange.ForField(auditField, old, newVal, dispatch.Id);
return FieldChange.ForField(auditField, old, newVal, ResolveDispatchIdForAudit(dispatch));
}
// apptTime is stored on WorkOrder (ScheduledStart/End) but requires a primary dispatch
// for concurrency (dispatchVersion) and audit context; apptDate lives on Dispatch.ScheduledDate.
private static FieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value, string auditField)
{
if (!WorkOrderBoardApptTimeParser.TryParse(value, out var start, out var end, out var error))
@ -435,9 +437,9 @@ namespace SeaHaven.Services.Implementation
var newCombined = FormatApptRange(newStart, newEnd);
if (oldCombined == newCombined)
return FieldChange.Unchanged(auditField, dispatch.Id);
return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch));
return FieldChange.ForField(auditField, oldCombined, newCombined, dispatch.Id);
return FieldChange.ForField(auditField, oldCombined, newCombined, ResolveDispatchIdForAudit(dispatch));
}
private static FieldChange ApplyDocStatus(WorkOrder workOrder, string? value, string auditField)
@ -500,6 +502,9 @@ namespace SeaHaven.Services.Implementation
return vendorId;
}
private static int? ResolveDispatchIdForAudit(Dispatch dispatch)
=> dispatch.Id > 0 ? dispatch.Id : null;
private static string? FormatDate(DateTime? value) => value?.ToString("yyyy-MM-dd");
private static string? FormatDateTime(DateTime? value) => value?.ToString("O");
private static string? FormatApptRange(string? start, string? end)

View file

@ -20,9 +20,9 @@ namespace SeaHaven.Services.Interfaces
Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId);
Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue);
void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null);
void StageStatusChanged(int workOrderId, string? oldStatus, string? newStatus, string? actorId);
void StageAssignmentChanged(int workOrderId, string? oldValue, string? newValue, string? actorId);
void StageCreated(int workOrderId, string? actorId, string? woNumber);
Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null);
Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId);
Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId);
Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber);
}
}

View file

@ -180,6 +180,56 @@ public class WorkOrderBoardUpdateServiceTests
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.FieldName == "SiteCode"));
}
[Fact]
public async Task PatchField_SecondEditSameField_DoesNotDuplicateFieldLock()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
SiteCode = "OLD",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "BK5",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
var updated = await context.workOrders.FindAsync(1);
var secondResult = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "BK6",
WorkOrderVersion = ToVersion(updated!)
}, "actor-1");
Assert.Equal("BK6", secondResult.SiteCode);
Assert.Single(await context.WorkOrderFieldLocks.Where(l => l.FieldName == "SiteCode").ToListAsync());
Assert.Equal(2, await context.WorkOrderAuditLogs.Where(a => a.FieldName == "SiteCode").CountAsync());
}
[Fact]
public async Task PatchField_NotFound_ThrowsNotFoundCode()
{
var (_, service) = CreateSut();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(999, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "BK5",
WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
}, "actor-1"));
Assert.Equal("NotFound", ex.Code);
}
[Fact]
public async Task PatchField_NormalizesWoNumber()
{