diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index f227ffd..f11196d 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -148,6 +148,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 diff --git a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs index 0646cd0..b0e7c60 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs @@ -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 - }); - } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs index 273e26f..78c86bd 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs @@ -47,7 +47,7 @@ namespace SeaHaven.Services.Implementation if (workOrder.LegacyStatus == null && workOrder.Status != null) workOrder.LegacyStatus = workOrder.Status; - _auditService.StageStatusChanged(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId); + await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId); await _context.SaveChangesAsync(); var row = await _boardService.GetBoardRowAsync(workOrderId); diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index 6d6c2a0..35ea71c 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -124,8 +124,8 @@ namespace SeaHaven.Services.Implementation _context.workOrders.Add(workOrder); - _auditService.StageCreated(workOrder.Id, actorId, woNumber); - StageChanges(workOrder.Id, changes, actorId, dispatch); + await _auditService.StageCreatedAsync(workOrder.Id, actorId, woNumber); + await StageChangesAsync(workOrder.Id, changes, actorId, dispatch); await _context.SaveChangesAsync(); @@ -183,7 +183,7 @@ namespace SeaHaven.Services.Implementation return normalized; } - private void StageChanges( + private async Task StageChangesAsync( int workOrderId, IEnumerable changes, string? actorId, @@ -195,13 +195,13 @@ namespace SeaHaven.Services.Implementation 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, dispatchId); + await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue ?? "", change.NewValue, actorId, dispatchId); break; } } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 98239bb..1eb1ce7 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -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) diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs index 7029709..3dc4b57 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs @@ -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); } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 59b180c..cb2b73a 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -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(() => + 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() {