feature: Barcode/QR Cover Sheet System
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/cover-sheets")]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class CoverSheetController : ControllerBase
|
||||
{
|
||||
private readonly ICoverSheetService _coverSheets;
|
||||
|
||||
public CoverSheetController(ICoverSheetService coverSheets)
|
||||
{
|
||||
_coverSheets = coverSheets;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates one or more cover sheets with unique barcode codes.
|
||||
/// Each cover sheet encodes batch type, track, optional patient, and
|
||||
/// optional entry clerk assignment.
|
||||
/// </summary>
|
||||
[HttpPost("generate")]
|
||||
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<CoverSheetResponse>>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Generate([FromBody] GenerateCoverSheetsRequest request)
|
||||
{
|
||||
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
var sheets = await _coverSheets.GenerateAsync(request, actorUserId);
|
||||
var response = sheets.Select(MapToResponse).ToList();
|
||||
return StatusCode(201, ApiResponse<List<CoverSheetResponse>>.Created(response));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a cover sheet by its barcode code. Used during barcode-assisted
|
||||
/// upload to auto-populate batch type, track, patient, and clerk assignment.
|
||||
/// </summary>
|
||||
[HttpGet("lookup/{code}")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CoverSheetResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Lookup(string code)
|
||||
{
|
||||
var sheet = await _coverSheets.LookupByCodeAsync(code);
|
||||
if (sheet is null)
|
||||
return NotFound(ApiResponse<object>.Fail(404, "Cover sheet not found.", "COVER_SHEET_NOT_FOUND"));
|
||||
|
||||
return Ok(ApiResponse<CoverSheetResponse>.Ok(MapToResponse(sheet)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists cover sheets with optional filters.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<CoverSheetResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] bool? isUsed,
|
||||
[FromQuery] Guid? patientId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var sheets = await _coverSheets.ListAsync(isUsed, patientId, page, pageSize);
|
||||
var response = sheets.Select(MapToResponse).ToList();
|
||||
return Ok(ApiResponse<List<CoverSheetResponse>>.Ok(response));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a printable PDF containing cover sheets with QR codes.
|
||||
/// Each page has the cover sheet code as a QR code, plus human-readable
|
||||
/// batch type, patient info, and generation date.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/pdf")]
|
||||
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GeneratePdf(Guid id)
|
||||
{
|
||||
var sheet = await _coverSheets.LookupByIdAsync(id);
|
||||
if (sheet is null)
|
||||
return NotFound(ApiResponse<object>.Fail(404, "Cover sheet not found.", "COVER_SHEET_NOT_FOUND"));
|
||||
|
||||
var pdfBytes = CoverSheetPdfGenerator.Generate(sheet);
|
||||
return File(pdfBytes, "application/pdf", $"coversheet-{sheet.Code}.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a batch PDF containing multiple cover sheets (one per page).
|
||||
/// Accepts a list of cover sheet IDs.
|
||||
/// </summary>
|
||||
[HttpPost("batch-pdf")]
|
||||
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GenerateBatchPdf([FromBody] BatchPdfRequest request)
|
||||
{
|
||||
var sheets = await _coverSheets.GetByIdsAsync(request.CoverSheetIds);
|
||||
var pdfBytes = CoverSheetPdfGenerator.GenerateBatch(sheets);
|
||||
return File(pdfBytes, "application/pdf", $"coversheets-batch-{DateTime.UtcNow:yyyyMMdd}.pdf");
|
||||
}
|
||||
|
||||
private static CoverSheetResponse MapToResponse(CoverSheet sheet) => new(
|
||||
Id: sheet.Id,
|
||||
Code: sheet.Code,
|
||||
BatchType: sheet.BatchType.ToDbString(),
|
||||
Track: sheet.Track.ToDbString(),
|
||||
PatientId: sheet.PatientId,
|
||||
PatientName: sheet.Patient?.FullName,
|
||||
PatientMrn: sheet.Patient?.Mrn,
|
||||
AssignToUserId: sheet.AssignToUserId,
|
||||
AssignToUserName: sheet.AssignToUser?.FullName,
|
||||
IsUsed: sheet.IsUsed,
|
||||
BatchId: sheet.BatchId,
|
||||
CreatedAt: sheet.CreatedAt,
|
||||
UsedAt: sheet.UsedAt
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public class DigitizationBatchesController : ControllerBase
|
||||
private readonly IDocumentStorageService _storage;
|
||||
private readonly IPromotionService _promotion;
|
||||
private readonly IBatchEventService _batchEventService;
|
||||
private readonly ICoverSheetService _coverSheets;
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
private static readonly HashSet<string> _allowedMimeTypes = new()
|
||||
@@ -29,13 +30,15 @@ public class DigitizationBatchesController : ControllerBase
|
||||
IDocumentStorageService storage,
|
||||
IPromotionService promotion,
|
||||
IBatchEventService batchEventService,
|
||||
AppDbContext db)
|
||||
AppDbContext db,
|
||||
ICoverSheetService coverSheets)
|
||||
{
|
||||
_batches = batches;
|
||||
_storage = storage;
|
||||
_promotion = promotion;
|
||||
_batchEventService = batchEventService;
|
||||
_db = db;
|
||||
_coverSheets = coverSheets;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -60,10 +63,40 @@ public class DigitizationBatchesController : ControllerBase
|
||||
return BadRequest(ApiResponse<object>.Fail(400,
|
||||
"Accepted formats: PDF, JPEG, PNG.", "INVALID_MIME_TYPE"));
|
||||
|
||||
var parsedBatchType = BatchTypeExtensions.FromDbString(form.BatchType.ToUpperInvariant());
|
||||
var parsedTrack = string.IsNullOrEmpty(form.Track)
|
||||
? BatchTrack.Backfill
|
||||
: BatchTrackExtensions.FromDbString(form.Track.ToUpperInvariant());
|
||||
CoverSheet? coverSheet = null;
|
||||
BatchType parsedBatchType;
|
||||
BatchTrack parsedTrack;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(form.CoverSheetCode))
|
||||
{
|
||||
coverSheet = await _coverSheets.LookupByCodeAsync(form.CoverSheetCode);
|
||||
if (coverSheet is null)
|
||||
return NotFound(ApiResponse<object>.Fail(404,
|
||||
"Cover sheet not found.", "COVER_SHEET_NOT_FOUND"));
|
||||
|
||||
if (coverSheet.IsUsed)
|
||||
return Conflict(ApiResponse<object>.Fail(409,
|
||||
$"Cover sheet {coverSheet.Code} has already been used.",
|
||||
"COVER_SHEET_ALREADY_USED"));
|
||||
|
||||
parsedBatchType = coverSheet.BatchType;
|
||||
parsedTrack = coverSheet.Track;
|
||||
if (coverSheet.PatientId.HasValue)
|
||||
form.PatientId ??= coverSheet.PatientId;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(form.BatchType))
|
||||
return BadRequest(ApiResponse<object>.Fail(400,
|
||||
"BatchType is required when no cover sheet code is provided.",
|
||||
"MISSING_BATCH_TYPE"));
|
||||
|
||||
parsedBatchType = BatchTypeExtensions.FromDbString(form.BatchType.ToUpperInvariant());
|
||||
parsedTrack = string.IsNullOrEmpty(form.Track)
|
||||
? BatchTrack.Backfill
|
||||
: BatchTrackExtensions.FromDbString(form.Track.ToUpperInvariant());
|
||||
}
|
||||
|
||||
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
using var stream = form.File.OpenReadStream();
|
||||
@@ -71,8 +104,17 @@ public class DigitizationBatchesController : ControllerBase
|
||||
stream, form.File.ContentType, parsedBatchType, parsedTrack,
|
||||
form.PatientId, form.SupersedesBatchId, actorUserId);
|
||||
|
||||
var batch = result.Batch;
|
||||
if (coverSheet is not null)
|
||||
{
|
||||
await _coverSheets.RedeemAsync(coverSheet.Id, batch.Id);
|
||||
|
||||
if (coverSheet.AssignToUserId.HasValue)
|
||||
batch = await _batches.AssignAsync(batch.Id, coverSheet.AssignToUserId.Value, actorUserId);
|
||||
}
|
||||
|
||||
return StatusCode(201, ApiResponse<BatchDetailResponse>.Created(
|
||||
BatchDetailResponse.FromEntity(result.Batch, supersession: result.Supersession)));
|
||||
BatchDetailResponse.FromEntity(batch, supersession: result.Supersession)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user