mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 11:39:03 +00:00
fix(uplifts): serialize evidence photo count with the work-order lock
Overlapping evidence uploads could both read nine photos and both save. The count and the insert now run under ExecuteWorkOrderMutationAsync, the same gate the dispatcher media path uses. Co-authored-by: Arthur Bassi <bassi-arthurr@users.noreply.github.com>
This commit is contained in:
parent
98bd45423d
commit
93dda60425
2 changed files with 151 additions and 34 deletions
|
|
@ -76,42 +76,54 @@ namespace SeaHaven.Services.Implementation
|
||||||
RejectTerminalTarget(workOrder, dispatch);
|
RejectTerminalTarget(workOrder, dispatch);
|
||||||
|
|
||||||
var (contentType, bytes) = await ReadAcceptedFileAsync(file, cancellationToken);
|
var (contentType, bytes) = await ReadAcceptedFileAsync(file, cancellationToken);
|
||||||
await EnsureWithinMediaCountsAsync(workOrderId, contentType, file.FileName, cancellationToken);
|
|
||||||
var latest = await _documentData.GetLatestForDispatchAsync(dispatch.Id, cancellationToken);
|
var latest = await _documentData.GetLatestForDispatchAsync(dispatch.Id, cancellationToken);
|
||||||
var version = (latest?.Version ?? 0) + 1;
|
var version = (latest?.Version ?? 0) + 1;
|
||||||
var storedFileName = $"{dispatch.Id}_{version}_{Guid.NewGuid():N}{SafeExtension(file.FileName)}";
|
var storedFileName = $"{dispatch.Id}_{version}_{Guid.NewGuid():N}{SafeExtension(file.FileName)}";
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var passWithoutScanner = _documentOptions.PassWhenScannerUnavailable;
|
var passWithoutScanner = _documentOptions.PassWhenScannerUnavailable;
|
||||||
var document = new VendorCompletionDocument
|
var actorId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
{
|
|
||||||
VendorId = dispatch.VendorId,
|
|
||||||
DispatchId = dispatch.Id,
|
|
||||||
WorkOrderId = workOrderId,
|
|
||||||
OriginalFileName = Path.GetFileName(file.FileName),
|
|
||||||
StoredFileName = storedFileName,
|
|
||||||
ContentType = contentType,
|
|
||||||
SizeBytes = bytes.Length,
|
|
||||||
ScanStatus = passWithoutScanner ? "Passed" : "Pending",
|
|
||||||
ReviewStatus = "Processing",
|
|
||||||
ScannedAt = passWithoutScanner ? now : null,
|
|
||||||
Version = version,
|
|
||||||
Purpose = VendorDocumentPurpose.UpliftEvidence,
|
|
||||||
CreatedDate = now
|
|
||||||
};
|
|
||||||
|
|
||||||
await _documentData.AddAsync(document, cancellationToken);
|
// Photo/video caps are a work-order aggregate shared with dispatcher media
|
||||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
// and the vendor portal, so the count and the insert share that mutation lock.
|
||||||
{
|
var document = await _upliftData.ExecuteWorkOrderMutationAsync(
|
||||||
WorkOrderId = workOrderId,
|
workOrderId,
|
||||||
DispatchId = dispatch.Id,
|
async ct =>
|
||||||
UserId = user.FindFirstValue(ClaimTypes.NameIdentifier),
|
{
|
||||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift Evidence",
|
await EnsureWithinMediaCountsAsync(workOrderId, contentType, file.FileName, ct);
|
||||||
NewValue = document.OriginalFileName,
|
|
||||||
Action = "uplift_evidence_uploaded",
|
var now = DateTime.UtcNow;
|
||||||
ActorType = "internal",
|
var created = new VendorCompletionDocument
|
||||||
CreatedAt = now
|
{
|
||||||
}, cancellationToken);
|
VendorId = dispatch.VendorId,
|
||||||
await _documentData.SaveChangesAsync(cancellationToken);
|
DispatchId = dispatch.Id,
|
||||||
|
WorkOrderId = workOrderId,
|
||||||
|
OriginalFileName = Path.GetFileName(file.FileName),
|
||||||
|
StoredFileName = storedFileName,
|
||||||
|
ContentType = contentType,
|
||||||
|
SizeBytes = bytes.Length,
|
||||||
|
ScanStatus = passWithoutScanner ? "Passed" : "Pending",
|
||||||
|
ReviewStatus = "Processing",
|
||||||
|
ScannedAt = passWithoutScanner ? now : null,
|
||||||
|
Version = version,
|
||||||
|
Purpose = VendorDocumentPurpose.UpliftEvidence,
|
||||||
|
CreatedDate = now
|
||||||
|
};
|
||||||
|
|
||||||
|
await _documentData.AddAsync(created, ct);
|
||||||
|
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||||
|
{
|
||||||
|
WorkOrderId = workOrderId,
|
||||||
|
DispatchId = dispatch.Id,
|
||||||
|
UserId = actorId,
|
||||||
|
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift Evidence",
|
||||||
|
NewValue = created.OriginalFileName,
|
||||||
|
Action = "uplift_evidence_uploaded",
|
||||||
|
ActorType = "internal",
|
||||||
|
CreatedAt = now
|
||||||
|
}, ct);
|
||||||
|
await _documentData.SaveChangesAsync(ct);
|
||||||
|
return created;
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
using var stored = new MemoryStream(bytes);
|
using var stored = new MemoryStream(bytes);
|
||||||
await _documentStorage.SaveAsync(
|
await _documentStorage.SaveAsync(
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ public sealed class WorkOrderUpliftEvidenceServiceTests
|
||||||
|
|
||||||
private static async Task<WorkOrder> SeedWorkOrderAsync(
|
private static async Task<WorkOrder> SeedWorkOrderAsync(
|
||||||
ApplicationDbContext context,
|
ApplicationDbContext context,
|
||||||
string role = "Admin")
|
string role = "Admin",
|
||||||
|
int workOrderId = 1)
|
||||||
{
|
{
|
||||||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
|
||||||
context.Users.Add(new ApplicationUser
|
context.Users.Add(new ApplicationUser
|
||||||
|
|
@ -48,14 +49,14 @@ public sealed class WorkOrderUpliftEvidenceServiceTests
|
||||||
{
|
{
|
||||||
Id = 10,
|
Id = 10,
|
||||||
VendorId = 1,
|
VendorId = 1,
|
||||||
WorkOrderId = 1,
|
WorkOrderId = workOrderId,
|
||||||
NTEAmount = 1000m,
|
NTEAmount = 1000m,
|
||||||
DispatchNumber = "DIS-10",
|
DispatchNumber = "DIS-10",
|
||||||
Status = "Scheduled",
|
Status = "Scheduled",
|
||||||
});
|
});
|
||||||
var workOrder = new WorkOrder
|
var workOrder = new WorkOrder
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = workOrderId,
|
||||||
InternalWONumber = "10000000001",
|
InternalWONumber = "10000000001",
|
||||||
PrimaryDispatchId = 10,
|
PrimaryDispatchId = 10,
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
|
|
@ -207,6 +208,110 @@ public sealed class WorkOrderUpliftEvidenceServiceTests
|
||||||
Assert.Empty(storage.Saved);
|
Assert.Empty(storage.Saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadAsync_ConcurrentPhotos_PersistOnlyTheLastOpenSlot()
|
||||||
|
{
|
||||||
|
// Distinct id: the mutation gate is process-wide per work order. Nine photos
|
||||||
|
// leave one slot. Both uploads must wait on that gate; otherwise each reads 9
|
||||||
|
// and both persist.
|
||||||
|
const int workOrderId = 388_171;
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
await using (var seed = new ApplicationDbContext(options))
|
||||||
|
{
|
||||||
|
await SeedWorkOrderAsync(seed, workOrderId: workOrderId);
|
||||||
|
for (var i = 0; i < 9; i++)
|
||||||
|
{
|
||||||
|
seed.VendorCompletionDocuments.Add(new VendorCompletionDocument
|
||||||
|
{
|
||||||
|
VendorId = 1,
|
||||||
|
DispatchId = 10,
|
||||||
|
WorkOrderId = workOrderId,
|
||||||
|
OriginalFileName = $"photo-{i}.jpg",
|
||||||
|
StoredFileName = $"photo-{i}.jpg",
|
||||||
|
ContentType = "image/jpeg",
|
||||||
|
Purpose = "Completion",
|
||||||
|
ScanStatus = "Passed",
|
||||||
|
Version = 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await seed.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
await using var holderContext = new ApplicationDbContext(options);
|
||||||
|
var holder = new UpliftDataService(holderContext).ExecuteWorkOrderMutationAsync(
|
||||||
|
workOrderId,
|
||||||
|
async _ =>
|
||||||
|
{
|
||||||
|
held.SetResult();
|
||||||
|
await release.Task;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
CancellationToken.None);
|
||||||
|
await held.Task;
|
||||||
|
|
||||||
|
await using var firstContext = new ApplicationDbContext(options);
|
||||||
|
await using var secondContext = new ApplicationDbContext(options);
|
||||||
|
var (first, firstStorage) = NewService(firstContext);
|
||||||
|
var (second, secondStorage) = NewService(secondContext);
|
||||||
|
var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 };
|
||||||
|
var firstUpload = first.UploadAsync(
|
||||||
|
workOrderId,
|
||||||
|
FormFile(jpeg, "a.jpg", "image/jpeg"),
|
||||||
|
Dispatcher(),
|
||||||
|
CancellationToken.None);
|
||||||
|
var secondUpload = second.UploadAsync(
|
||||||
|
workOrderId,
|
||||||
|
FormFile(jpeg, "b.jpg", "image/jpeg"),
|
||||||
|
Dispatcher(),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(200);
|
||||||
|
Assert.False(firstUpload.IsCompleted);
|
||||||
|
Assert.False(secondUpload.IsCompleted);
|
||||||
|
await using var during = new ApplicationDbContext(options);
|
||||||
|
Assert.Equal(9, await during.VendorCompletionDocuments.CountAsync());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
release.TrySetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
await holder;
|
||||||
|
var outcomes = await Task.WhenAll(Observe(firstUpload), Observe(secondUpload));
|
||||||
|
|
||||||
|
await using var verify = new ApplicationDbContext(options);
|
||||||
|
Assert.Equal(10, await verify.VendorCompletionDocuments.CountAsync());
|
||||||
|
Assert.Equal(
|
||||||
|
1,
|
||||||
|
await verify.VendorCompletionDocuments.CountAsync(
|
||||||
|
document => document.Purpose == VendorDocumentPurpose.UpliftEvidence));
|
||||||
|
Assert.Equal(1, outcomes.Count(outcome => outcome is null));
|
||||||
|
var rejected = Assert.Single(outcomes.OfType<InvalidOperationException>());
|
||||||
|
Assert.Equal("A work order can have at most 10 photos.", rejected.Message);
|
||||||
|
Assert.Equal(1, firstStorage.Saved.Count + secondStorage.Saved.Count);
|
||||||
|
|
||||||
|
static async Task<Exception?> Observe(Task<UploadCompletionDocumentResultDTO?> upload)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await upload;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UploadAsync_RequestUpliftsDenied_PersistsNothing()
|
public async Task UploadAsync_RequestUpliftsDenied_PersistsNothing()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue