mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-01 19:13:13 +00:00
Merge pull request #201 from Sea-Haven-Industries/fix/ab/sh-412-addon-reschedule
fix: recalculate Add-On on work order reschedule
This commit is contained in:
commit
c655f753b2
7 changed files with 233 additions and 10 deletions
|
|
@ -100,6 +100,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
LocationId = model.LocationId,
|
||||
AssignTo = model.AssignTo,
|
||||
DueDate = model.DueDate,
|
||||
ScheduledDate = model.ScheduledDate,
|
||||
CompletedDate = model.CompletedDate,
|
||||
ContactIds = model.ContactIds,
|
||||
CategoryIds = model.CategoryIds,
|
||||
|
|
|
|||
|
|
@ -206,6 +206,12 @@ namespace SeaHaven.DataServices.Implementation
|
|||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public Task<T> ExecuteWorkOrderMutationAsync<T>(
|
||||
int workOrderId,
|
||||
Func<CancellationToken, Task<T>> work,
|
||||
CancellationToken cancellationToken)
|
||||
=> WorkOrderMutationLock.RunAsync(_context, workOrderId, work, cancellationToken);
|
||||
|
||||
public async Task DeleteAsync(int id)
|
||||
{
|
||||
var entity = await GetByIdAsync(id);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<WorkOrder> AddAsync(WorkOrder workOrder);
|
||||
Task UpdateAsync(WorkOrder workOrder);
|
||||
Task DeleteAsync(int id);
|
||||
// Runs a read-modify-write on one work order under the shared per-work-order gate,
|
||||
// transaction and row lock (WorkOrderMutationLock).
|
||||
Task<T> ExecuteWorkOrderMutationAsync<T>(
|
||||
int workOrderId,
|
||||
Func<CancellationToken, Task<T>> work,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// Related entities - Read
|
||||
Task<IEnumerable<WorkOrderContacts>> GetWorkOrderContactsAsync(int workOrderId);
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ namespace SeaHaven.Services.DTOs
|
|||
public int? LocationId { get; set; }
|
||||
public string? AssignTo { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
|
||||
public static void UpdateEntity(UpdateWorkOrderDTO dto, WorkOrder entity)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ namespace SeaHaven.Services.DTOs
|
|||
public int? LocationId { get; set; }
|
||||
public string? AssignTo { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
public List<int>? ContactIds { get; set; }
|
||||
public List<int>? CategoryIds { get; set; }
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using SeaHaven.DataServices.Interfaces;
|
|||
using SeaHaven.DataServices.Models;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using SeaHaven.Services.Validation;
|
||||
|
||||
|
|
@ -98,20 +99,48 @@ namespace SeaHaven.Services.Implementation
|
|||
if (!validationResult.IsValid)
|
||||
throw new ValidationException(validationResult.Errors);
|
||||
|
||||
var existingWorkOrder = await _workOrderDataService.GetByIdAsync(id);
|
||||
if (existingWorkOrder == null)
|
||||
return false;
|
||||
// The read, the schedule side effects (RescheduleCount, OriginalDate/Week, IsAddOn)
|
||||
// and the audit entry run under the work order's mutation gate and row lock, so a
|
||||
// concurrent reschedule cannot interleave with this read-modify-write.
|
||||
return await _workOrderDataService.ExecuteWorkOrderMutationAsync(
|
||||
id,
|
||||
async _ =>
|
||||
{
|
||||
var existingWorkOrder = await _workOrderDataService.GetByIdAsync(id);
|
||||
if (existingWorkOrder == null)
|
||||
return false;
|
||||
|
||||
var changes = new List<string>();
|
||||
changes.AddRange(TrackChanges(existingWorkOrder, dto));
|
||||
var changes = new List<string>();
|
||||
changes.AddRange(TrackChanges(existingWorkOrder, dto));
|
||||
|
||||
UpdateWorkOrderDTO.UpdateEntity(dto, existingWorkOrder);
|
||||
await _workOrderDataService.UpdateAsync(existingWorkOrder);
|
||||
var scheduledDateChanged = dto.ScheduledDate.HasValue
|
||||
&& dto.ScheduledDate.Value.Date != existingWorkOrder.ScheduledDate?.Date;
|
||||
var previousIsAddOn = existingWorkOrder.IsAddOn;
|
||||
UpdateWorkOrderDTO.UpdateEntity(dto, existingWorkOrder);
|
||||
if (scheduledDateChanged)
|
||||
{
|
||||
var scheduleChanges = WorkOrderBoardFieldMutations.ApplyScheduledDate(
|
||||
existingWorkOrder,
|
||||
dto.ScheduledDate!.Value.Date,
|
||||
WorkOrderBoardFieldNames.ScheduledDate);
|
||||
changes.AddRange(scheduleChanges
|
||||
.Where(c => c.HasChanged && c.FieldName != WorkOrderBoardFieldNames.ScheduledDate)
|
||||
.Select(c => $"{c.FieldName}: {c.OldValue} → {c.NewValue}"));
|
||||
existingWorkOrder.IsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule(
|
||||
existingWorkOrder.CreatedDate ?? DateTime.UtcNow,
|
||||
existingWorkOrder.ScheduledDate,
|
||||
existingWorkOrder.TargetWeek);
|
||||
if (existingWorkOrder.IsAddOn != previousIsAddOn)
|
||||
changes.Add($"IsAddOn: {previousIsAddOn} → {existingWorkOrder.IsAddOn}");
|
||||
}
|
||||
await _workOrderDataService.UpdateAsync(existingWorkOrder);
|
||||
|
||||
if (changes.Any())
|
||||
await LogAuditAsync(id, userId, AuditAction.Edit, string.Join(", ", changes));
|
||||
if (changes.Any())
|
||||
await LogAuditAsync(id, userId, AuditAction.Edit, string.Join(", ", changes));
|
||||
|
||||
return true;
|
||||
return true;
|
||||
},
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteWorkOrderAsync(int id, string userId)
|
||||
|
|
@ -405,6 +434,7 @@ namespace SeaHaven.Services.Implementation
|
|||
LocationId = input.LocationId,
|
||||
AssignTo = input.AssignTo,
|
||||
DueDate = input.DueDate,
|
||||
ScheduledDate = input.ScheduledDate,
|
||||
CompletedDate = input.CompletedDate
|
||||
};
|
||||
|
||||
|
|
@ -630,6 +660,8 @@ namespace SeaHaven.Services.Implementation
|
|||
changes.Add($"Assigned To: {existing.AssignTo} → {dto.AssignTo}");
|
||||
if (dto.DueDate.HasValue && dto.DueDate != existing.DueDate)
|
||||
changes.Add($"Due Date: {existing.DueDate:yyyy-MM-dd} → {dto.DueDate:yyyy-MM-dd}");
|
||||
if (dto.ScheduledDate.HasValue && dto.ScheduledDate.Value.Date != existing.ScheduledDate?.Date)
|
||||
changes.Add($"Schedule On: {existing.ScheduledDate:yyyy-MM-dd} → {dto.ScheduledDate:yyyy-MM-dd}");
|
||||
return changes;
|
||||
}
|
||||
|
||||
|
|
|
|||
176
SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs
Normal file
176
SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
using System.Security.Claims;
|
||||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Api.SeaHavenIndustries.DTOs;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.DataServices.Models;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Validation;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public sealed class WorkOrderEditAddonRescheduleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EditWorkorder_RescheduleIntoEarlierWeek_EnablesAndAuditsAddOn()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc),
|
||||
ScheduledDate = new DateTime(2026, 6, 29),
|
||||
IsAddOn = false
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await CreateController(context).Editworkorder(new EditWorkorder_DTO
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 25)
|
||||
});
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
var persisted = await context.workOrders.SingleAsync(x => x.Id == 1);
|
||||
Assert.Equal(new DateTime(2026, 6, 25), persisted.ScheduledDate);
|
||||
Assert.True(persisted.IsAddOn);
|
||||
Assert.Equal(1, persisted.RescheduleCount);
|
||||
var audit = await context.WorkOrderAuditLogs
|
||||
.Where(x => x.WorkOrderId == 1)
|
||||
.ToListAsync();
|
||||
Assert.Single(audit);
|
||||
Assert.Contains(audit, x => x.FieldName?.Contains("Schedule On", StringComparison.Ordinal) == true);
|
||||
Assert.Contains(audit, x => x.FieldName?.Contains("IsAddOn", StringComparison.Ordinal) == true);
|
||||
Assert.Contains(audit, x => x.FieldName?.Contains("RescheduleCount: 0 → 1", StringComparison.Ordinal) == true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditWorkorder_FirstSchedule_SetsOriginalWeekAndPromotesIncomplete()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc),
|
||||
LifecycleStatus = LifecycleStatus.Incomplete
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await CreateController(context).Editworkorder(new EditWorkorder_DTO
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = new DateTime(2026, 7, 1)
|
||||
});
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
var persisted = await context.workOrders.SingleAsync(x => x.Id == 1);
|
||||
Assert.Equal(new DateOnly(2026, 7, 1), persisted.OriginalDate);
|
||||
Assert.Equal(new DateOnly(2026, 6, 29), persisted.OriginalWeek);
|
||||
Assert.Equal(0, persisted.RescheduleCount);
|
||||
Assert.Equal(LifecycleStatus.Scheduled, persisted.LifecycleStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditWorkorder_Reschedule_WaitsForWorkOrderMutationGateAndReadsCommittedSchedule()
|
||||
{
|
||||
const int workOrderId = 41201;
|
||||
var databaseName = Guid.NewGuid().ToString();
|
||||
await using (var seed = CreateContext(databaseName))
|
||||
{
|
||||
seed.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = workOrderId,
|
||||
CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc),
|
||||
ScheduledDate = new DateTime(2026, 7, 6),
|
||||
OriginalDate = new DateOnly(2026, 7, 6),
|
||||
IsAddOn = false
|
||||
});
|
||||
await seed.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// A concurrent reschedule holds the work order's mutation gate and commits its own
|
||||
// date while the edit is waiting.
|
||||
await using var holderContext = CreateContext(databaseName);
|
||||
var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var holder = new UpliftDataService(holderContext).ExecuteWorkOrderMutationAsync(
|
||||
workOrderId,
|
||||
async ct =>
|
||||
{
|
||||
held.SetResult();
|
||||
await release.Task;
|
||||
var workOrder = await holderContext.workOrders.SingleAsync(x => x.Id == workOrderId, ct);
|
||||
workOrder.ScheduledDate = new DateTime(2026, 6, 29);
|
||||
workOrder.RescheduleCount++;
|
||||
await holderContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
},
|
||||
CancellationToken.None);
|
||||
await held.Task;
|
||||
|
||||
await using var editContext = CreateContext(databaseName);
|
||||
var edit = CreateController(editContext).Editworkorder(new EditWorkorder_DTO
|
||||
{
|
||||
Id = workOrderId,
|
||||
ScheduledDate = new DateTime(2026, 6, 25)
|
||||
});
|
||||
await Task.Delay(200);
|
||||
|
||||
Assert.False(edit.IsCompleted);
|
||||
|
||||
release.SetResult();
|
||||
await holder;
|
||||
Assert.IsType<OkObjectResult>(await edit);
|
||||
|
||||
await using var verify = CreateContext(databaseName);
|
||||
var persisted = await verify.workOrders.SingleAsync(x => x.Id == workOrderId);
|
||||
Assert.Equal(new DateTime(2026, 6, 25), persisted.ScheduledDate);
|
||||
Assert.Equal(2, persisted.RescheduleCount);
|
||||
Assert.True(persisted.IsAddOn);
|
||||
var audit = await verify.WorkOrderAuditLogs.SingleAsync(x => x.WorkOrderId == workOrderId);
|
||||
Assert.Contains("Schedule On: 2026-06-29 → 2026-06-25", audit.FieldName);
|
||||
Assert.Contains("RescheduleCount: 1 → 2", audit.FieldName);
|
||||
}
|
||||
|
||||
private static ApplicationDbContext CreateContext(string? databaseName = null)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(databaseName ?? Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static WorkOrderController CreateController(ApplicationDbContext context)
|
||||
{
|
||||
var accountResolver = WorkOrderAccountTestHelpers.Resolver(context);
|
||||
var workOrderService = new WorkOrderService(
|
||||
new WorkOrderDataService(context),
|
||||
new CommentDataService(context),
|
||||
new UserDataService(context),
|
||||
new QuotesDataService(context),
|
||||
fileStorage: null!,
|
||||
new CreateWorkOrderValidation(),
|
||||
new UpdateWorkOrderValidation(),
|
||||
accountResolver);
|
||||
return new WorkOrderController(
|
||||
workOrderService,
|
||||
accountResolver,
|
||||
NullLogger<WorkOrderController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[] { new Claim(ClaimTypes.NameIdentifier, "dispatcher-1") },
|
||||
authenticationType: "test"))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue