feat(vendor-portal): report work-order media counts on the dispatch detail

Adds MediaCounts (limits, current photos/videos, and the counts a new
completion version would see) so the portal can refuse an 11th photo or
4th video before uploading it. Uses the same count and replacement rule
the upload check enforces.
This commit is contained in:
Alexandre Brandizzi 2026-09-24 22:24:07 -03:00
parent fd59a3da13
commit eecc2a61bd
4 changed files with 106 additions and 4 deletions

View file

@ -553,6 +553,46 @@ public sealed class VendorPortalDocumentTests : IDisposable
context.VendorCompletionDocuments.Should().HaveCount(2);
}
[Fact]
public async Task GetDispatchDetail_ReportsWorkOrderMediaCountsForThePortalPreCheck()
{
using var context = NewContext();
var (vendor, dispatch) = await SeedDispatch(context);
foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "IMG_0003.jpg" })
{
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = dispatch.WorkOrderId!.Value,
Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}",
});
}
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
{
VendorId = vendor.Id,
DispatchId = dispatch.Id,
WorkOrderId = dispatch.WorkOrderId!.Value,
ContentType = "video/mp4",
StoredFileName = "v1.mp4",
Version = 1,
Purpose = "Completion"
});
await context.SaveChangesAsync();
var service = NewService(context);
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None);
detail!.MediaCounts.Should().BeEquivalentTo(new SeaHaven.Services.DTOs.PortalMediaCountsDTO
{
MaxPhotos = 10,
MaxVideos = 3,
Photos = 1,
Videos = 3,
CompletionPhotos = 1,
CompletionVideos = 2,
});
}
[Fact]
public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes()
{

View file

@ -134,6 +134,26 @@ namespace SeaHaven.Services.DTOs
public IEnumerable<PortalCommentDTO> Comments { get; set; } = Enumerable.Empty<PortalCommentDTO>();
public IEnumerable<PortalUpliftDTO> UpliftRequests { get; set; } = Enumerable.Empty<PortalUpliftDTO>();
public IEnumerable<VendorPortalDocumentDTO> Documents { get; set; } = Enumerable.Empty<VendorPortalDocumentDTO>();
public PortalMediaCountsDTO? MediaCounts { get; set; }
}
/// <summary>
/// SH-116 per-work-order photo/video usage, so the portal can pre-check an upload
/// before sending it. The server still enforces the limit on upload.
/// </summary>
public class PortalMediaCountsDTO
{
public int MaxPhotos { get; set; }
public int MaxVideos { get; set; }
/// <summary>Photos/videos on the work order, counted for evidence uploads.</summary>
public int Photos { get; set; }
public int Videos { get; set; }
/// <summary>
/// Photos/videos counted for a new completion version, which replaces the dispatch's
/// latest document and so does not count it.
/// </summary>
public int CompletionPhotos { get; set; }
public int CompletionVideos { get; set; }
}
public class VendorPortalDocumentDTO

View file

@ -93,16 +93,29 @@ namespace SeaHaven.Services.Helpers
if (kind != UploadKind.Photo && kind != UploadKind.Video)
return null;
var count = attachmentUrls.Count(url => ResolveKind(null, url) == kind)
+ vendorContentTypes.Count(type => ResolveKind(type, null) == kind);
var (photos, videos) = CountKinds(attachmentUrls, vendorContentTypes);
if (kind == UploadKind.Photo && count >= MaxPhotosPerWorkOrder)
if (kind == UploadKind.Photo && photos >= MaxPhotosPerWorkOrder)
return "A work order can have at most 10 photos.";
if (kind == UploadKind.Video && count >= MaxVideosPerWorkOrder)
if (kind == UploadKind.Video && videos >= MaxVideosPerWorkOrder)
return "A work order can have at most 3 videos.";
return null;
}
/// <summary>
/// Photos and videos on a work order: dispatcher attachments (kind from the stored URL)
/// plus current vendor-portal documents (kind from the validated content type).
/// </summary>
public static (int Photos, int Videos) CountKinds(
IEnumerable<string> attachmentUrls,
IEnumerable<string> vendorContentTypes)
{
var kinds = attachmentUrls.Select(url => ResolveKind(null, url))
.Concat(vendorContentTypes.Select(type => ResolveKind(type, null)))
.ToList();
return (kinds.Count(k => k == UploadKind.Photo), kinds.Count(k => k == UploadKind.Video));
}
/// <summary>
/// 90-second video limit, read from the MP4/QuickTime movie header. A video whose
/// duration cannot be read is not blocked. Returns the stable message or null.

View file

@ -206,8 +206,37 @@ namespace SeaHaven.Services.Implementation
}
}
PortalMediaCountsDTO? mediaCounts = null;
if (dispatch.WorkOrderId is int workOrderId)
{
// Same basis as the upload check: a new completion version replaces the
// dispatch's latest document, so that one is not counted for it.
var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync(
workOrderId, cancellationToken);
var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync(
workOrderId, null, cancellationToken);
var latest = await _documentData.GetLatestForDispatchAsync(id, cancellationToken);
var completionTypes = latest == null
? vendorTypes
: await _documentData.ListActiveContentTypesForWorkOrderAsync(
workOrderId, latest.Id, cancellationToken);
var (photos, videos) = WorkOrderMediaContract.CountKinds(attachmentUrls, vendorTypes);
var (completionPhotos, completionVideos) =
WorkOrderMediaContract.CountKinds(attachmentUrls, completionTypes);
mediaCounts = new PortalMediaCountsDTO
{
MaxPhotos = WorkOrderMediaContract.MaxPhotosPerWorkOrder,
MaxVideos = WorkOrderMediaContract.MaxVideosPerWorkOrder,
Photos = photos,
Videos = videos,
CompletionPhotos = completionPhotos,
CompletionVideos = completionVideos
};
}
return new VendorDispatchDetailDTO
{
MediaCounts = mediaCounts,
Id = dispatch.Id,
DispatchNumber = dispatch.DispatchNumber,
PONumber = dispatch.PONumber,