feature: Barcode/QR Cover Sheet System

This commit is contained in:
voltsrage
2026-06-27 21:50:32 +08:00
parent 5db60f46eb
commit 756cff332c
43 changed files with 8203 additions and 21 deletions
@@ -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>