mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-05 22:22:13 +00:00
fix(work-orders): wrap board create in a transaction for atomic persistence
This commit is contained in:
parent
9dc17d4255
commit
ceeb0c8fa3
2 changed files with 89 additions and 18 deletions
|
|
@ -122,29 +122,47 @@ namespace SeaHaven.Services.Implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_context.workOrders.Add(workOrder);
|
// Two SaveChanges are required so generated WO/dispatch IDs can backfill FKs and
|
||||||
await _context.SaveChangesAsync();
|
// audit/lock/contact rows. A transaction keeps the create atomic across both saves.
|
||||||
|
await using var transaction = _context.Database.IsRelational()
|
||||||
|
? await _context.Database.BeginTransactionAsync()
|
||||||
|
: null;
|
||||||
|
|
||||||
if (dispatch != null && workOrder.PrimaryDispatchId == null)
|
try
|
||||||
{
|
{
|
||||||
workOrder.PrimaryDispatchId = dispatch.Id;
|
_context.workOrders.Add(workOrder);
|
||||||
dispatch.WorkOrderId = workOrder.Id;
|
await _context.SaveChangesAsync();
|
||||||
}
|
|
||||||
|
|
||||||
_auditService.StageCreated(workOrder.Id, actorId, woNumber);
|
if (dispatch != null && workOrder.PrimaryDispatchId == null)
|
||||||
StageChanges(workOrder.Id, changes, actorId, dispatch);
|
|
||||||
|
|
||||||
if (request.PocContactId.HasValue)
|
|
||||||
{
|
|
||||||
_context.WorkOrderContacts.Add(new WorkOrderContacts
|
|
||||||
{
|
{
|
||||||
WorkorderId = workOrder.Id,
|
workOrder.PrimaryDispatchId = dispatch.Id;
|
||||||
ContactId = request.PocContactId,
|
dispatch.WorkOrderId = workOrder.Id;
|
||||||
Notes = string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim()
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await _context.SaveChangesAsync();
|
_auditService.StageCreated(workOrder.Id, actorId, woNumber);
|
||||||
|
StageChanges(workOrder.Id, changes, actorId, dispatch);
|
||||||
|
|
||||||
|
if (request.PocContactId.HasValue)
|
||||||
|
{
|
||||||
|
_context.WorkOrderContacts.Add(new WorkOrderContacts
|
||||||
|
{
|
||||||
|
WorkorderId = workOrder.Id,
|
||||||
|
ContactId = request.PocContactId,
|
||||||
|
Notes = string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
if (transaction is not null)
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (transaction is not null)
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
var row = await _boardService.GetBoardRowAsync(workOrder.Id);
|
var row = await _boardService.GetBoardRowAsync(workOrder.Id);
|
||||||
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
|
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,59 @@ public class WorkOrderBoardCreateRelationalTests
|
||||||
Assert.DoesNotContain(locks, l => l.WorkOrderId == 0);
|
Assert.DoesNotContain(locks, l => l.WorkOrderId == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WhenContactPersistenceFails_RollsBackWorkOrder()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
await using var context = new SqliteBoardTestDbContext(options);
|
||||||
|
await context.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
const string actorId = "actor-1";
|
||||||
|
context.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = actorId,
|
||||||
|
UserName = "actor-1",
|
||||||
|
NormalizedUserName = "ACTOR-1",
|
||||||
|
Email = "actor@test.local",
|
||||||
|
NormalizedEmail = "ACTOR@TEST.LOCAL"
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
|
var boardService = new WorkOrderBoardService(boardData);
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||||
|
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||||
|
var service = new WorkOrderBoardCreateService(
|
||||||
|
context,
|
||||||
|
boardData,
|
||||||
|
boardService,
|
||||||
|
audit,
|
||||||
|
new WorkOrderBoardCreateValidation());
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<DbUpdateException>(() =>
|
||||||
|
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
Description = "Should roll back",
|
||||||
|
PocContactId = 999_999
|
||||||
|
}, actorId));
|
||||||
|
|
||||||
|
// Fresh context on the same connection so we observe committed state only.
|
||||||
|
await using var verify = new SqliteBoardTestDbContext(options);
|
||||||
|
Assert.Empty(await verify.workOrders.AsNoTracking().ToListAsync());
|
||||||
|
Assert.Empty(await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync());
|
||||||
|
Assert.Empty(await verify.WorkOrderFieldLocks.AsNoTracking().ToListAsync());
|
||||||
|
Assert.Empty(await verify.WorkOrderContacts.AsNoTracking().ToListAsync());
|
||||||
|
Assert.Empty(await verify.Dispatches.AsNoTracking().ToListAsync());
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class SqliteBoardTestDbContext : ApplicationDbContext
|
private sealed class SqliteBoardTestDbContext : ApplicationDbContext
|
||||||
{
|
{
|
||||||
public SqliteBoardTestDbContext(DbContextOptions<ApplicationDbContext> options)
|
public SqliteBoardTestDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue