feature: Barcode/QR Cover Sheet System
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using QRCoder;
|
||||
|
||||
/// <summary>
|
||||
/// Builds printable cover sheet PDFs using raw PDF 1.4 objects and QRCoder for QR images.
|
||||
/// Each page includes a header, QR code, human-readable metadata, and a footer instruction line.
|
||||
/// </summary>
|
||||
public static class CoverSheetPdfGenerator
|
||||
{
|
||||
private const float PageWidth = 612f;
|
||||
private const float PageHeight = 792f;
|
||||
private const float QrDisplaySize = 200f;
|
||||
private const float LeftMargin = 72f;
|
||||
|
||||
public static byte[] Generate(CoverSheet sheet) =>
|
||||
GenerateBatch(new[] { sheet });
|
||||
|
||||
public static byte[] GenerateBatch(IReadOnlyList<CoverSheet> sheets)
|
||||
{
|
||||
if (sheets.Count == 0)
|
||||
throw new ArgumentException("At least one cover sheet is required.", nameof(sheets));
|
||||
|
||||
var pages = sheets.Select(BuildPage).ToList();
|
||||
return BuildPdf(pages);
|
||||
}
|
||||
|
||||
private sealed record PageData(string ContentStream, byte[] ImageRgb, int ImageWidth, int ImageHeight);
|
||||
|
||||
private static PageData BuildPage(CoverSheet sheet)
|
||||
{
|
||||
var (rgb, width, height) = GenerateQrRgb(sheet.Code);
|
||||
var content = BuildContentStream(sheet);
|
||||
return new PageData(content, rgb, width, height);
|
||||
}
|
||||
|
||||
private static (byte[] Rgb, int Width, int Height) GenerateQrRgb(string code)
|
||||
{
|
||||
using var generator = new QRCodeGenerator();
|
||||
using var data = generator.CreateQrCode(code, QRCodeGenerator.ECCLevel.M);
|
||||
|
||||
var modules = data.ModuleMatrix.Count;
|
||||
const int scale = 8;
|
||||
const int quiet = 2;
|
||||
var size = (modules + quiet * 2) * scale;
|
||||
var rgb = new byte[size * size * 3];
|
||||
|
||||
for (var y = 0; y < size; y++)
|
||||
{
|
||||
for (var x = 0; x < size; x++)
|
||||
{
|
||||
var mx = x / scale - quiet;
|
||||
var my = y / scale - quiet;
|
||||
var dark = mx >= 0 && my >= 0 && mx < modules && my < modules && data.ModuleMatrix[my][mx];
|
||||
var color = dark ? (byte)0 : (byte)255;
|
||||
var i = (y * size + x) * 3;
|
||||
rgb[i] = color;
|
||||
rgb[i + 1] = color;
|
||||
rgb[i + 2] = color;
|
||||
}
|
||||
}
|
||||
|
||||
return (rgb, size, size);
|
||||
}
|
||||
|
||||
private static string BuildContentStream(CoverSheet sheet)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var qrX = (PageWidth - QrDisplaySize) / 2f;
|
||||
const float qrY = 470f;
|
||||
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"q {QrDisplaySize} 0 0 {QrDisplaySize} {qrX} {qrY} cm /Im1 Do Q");
|
||||
|
||||
WriteTextLine(sb, 18f, LeftMargin, 740f, "VigilCare Records - Cover Sheet");
|
||||
|
||||
var y = 430f;
|
||||
y = WriteTextLine(sb, 12f, LeftMargin, y, $"Code: {sheet.Code}");
|
||||
y = WriteTextLine(sb, 12f, LeftMargin, y, $"Batch Type: {sheet.BatchType.ToDbString()}");
|
||||
y = WriteTextLine(sb, 12f, LeftMargin, y, $"Track: {sheet.Track.ToDbString()}");
|
||||
|
||||
if (sheet.Patient is not null)
|
||||
{
|
||||
y = WriteTextLine(sb, 12f, LeftMargin, y,
|
||||
$"Patient: {sheet.Patient.FullName} (MRN {sheet.Patient.Mrn})");
|
||||
}
|
||||
|
||||
if (sheet.AssignToUser is not null)
|
||||
{
|
||||
y = WriteTextLine(sb, 12f, LeftMargin, y,
|
||||
$"Assigned To: {sheet.AssignToUser.FullName}");
|
||||
}
|
||||
|
||||
var generated = sheet.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
WriteTextLine(sb, 12f, LeftMargin, y, $"Generated: {generated}");
|
||||
|
||||
WriteTextLine(sb, 10f, LeftMargin, 48f,
|
||||
"Attach to front of chart section. Scanner reads QR code automatically.");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static float WriteTextLine(StringBuilder sb, float fontSize, float x, float y, string text)
|
||||
{
|
||||
sb.AppendLine("BT");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"/F1 {fontSize} Tf");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"{x} {y} Td");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"({EscapePdfString(text)}) Tj");
|
||||
sb.AppendLine("ET");
|
||||
return y - fontSize - 8f;
|
||||
}
|
||||
|
||||
private static byte[] BuildPdf(IReadOnlyList<PageData> pages)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
ms.Write("%PDF-1.4\n"u8);
|
||||
|
||||
var offsets = new List<long>();
|
||||
const int catalogId = 1;
|
||||
const int pagesId = 2;
|
||||
const int fontId = 3;
|
||||
var pageIds = Enumerable.Range(0, pages.Count).Select(i => 4 + i * 3).ToArray();
|
||||
var contentIds = pageIds.Select(id => id + 1).ToArray();
|
||||
var imageIds = pageIds.Select(id => id + 2).ToArray();
|
||||
|
||||
offsets.Add(ms.Position);
|
||||
WriteAscii(ms, $"{catalogId} 0 obj\n<< /Type /Catalog /Pages {pagesId} 0 R >>\nendobj\n");
|
||||
|
||||
offsets.Add(ms.Position);
|
||||
var kids = string.Join(" ", pageIds.Select(id => $"{id} 0 R"));
|
||||
WriteAscii(ms, $"{pagesId} 0 obj\n<< /Type /Pages /Kids [{kids}] /Count {pages.Count} >>\nendobj\n");
|
||||
|
||||
offsets.Add(ms.Position);
|
||||
WriteAscii(ms, $"{fontId} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n");
|
||||
|
||||
for (var i = 0; i < pages.Count; i++)
|
||||
{
|
||||
var page = pages[i];
|
||||
var pageId = pageIds[i];
|
||||
var contentId = contentIds[i];
|
||||
var imageId = imageIds[i];
|
||||
var contentBytes = Encoding.ASCII.GetBytes(page.ContentStream);
|
||||
var imageBytes = FlateEncode(page.ImageRgb);
|
||||
|
||||
offsets.Add(ms.Position);
|
||||
WriteAscii(ms,
|
||||
$"{pageId} 0 obj\n" +
|
||||
$"<< /Type /Page /Parent {pagesId} 0 R " +
|
||||
$"/MediaBox [0 0 {PageWidth} {PageHeight}] " +
|
||||
$"/Contents {contentId} 0 R " +
|
||||
$"/Resources << /Font << /F1 {fontId} 0 R >> /XObject << /Im1 {imageId} 0 R >> >> >>\n" +
|
||||
"endobj\n");
|
||||
|
||||
offsets.Add(ms.Position);
|
||||
WriteAscii(ms,
|
||||
$"{contentId} 0 obj\n<< /Length {contentBytes.Length} >>\nstream\n");
|
||||
ms.Write(contentBytes);
|
||||
WriteAscii(ms, "\nendstream\nendobj\n");
|
||||
|
||||
offsets.Add(ms.Position);
|
||||
WriteAscii(ms,
|
||||
$"{imageId} 0 obj\n" +
|
||||
$"<< /Type /XObject /Subtype /Image " +
|
||||
$"/Width {page.ImageWidth} /Height {page.ImageHeight} " +
|
||||
"/ColorSpace /DeviceRGB /BitsPerComponent 8 " +
|
||||
$"/Filter /FlateDecode /Length {imageBytes.Length} >>\nstream\n");
|
||||
ms.Write(imageBytes);
|
||||
WriteAscii(ms, "\nendstream\nendobj\n");
|
||||
}
|
||||
|
||||
var xrefOffset = ms.Position;
|
||||
WriteAscii(ms, "xref\n");
|
||||
WriteAscii(ms, $"0 {offsets.Count + 1}\n");
|
||||
WriteAscii(ms, "0000000000 65535 f \n");
|
||||
foreach (var offset in offsets)
|
||||
WriteAscii(ms, $"{offset:D10} 00000 n \n");
|
||||
|
||||
WriteAscii(ms,
|
||||
"trailer\n" +
|
||||
$"<< /Size {offsets.Count + 1} /Root {catalogId} 0 R >>\n" +
|
||||
"startxref\n" +
|
||||
$"{xrefOffset}\n" +
|
||||
"%%EOF\n");
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteAscii(Stream stream, string text) =>
|
||||
stream.Write(Encoding.ASCII.GetBytes(text));
|
||||
|
||||
private static byte[] FlateEncode(byte[] data)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
using (var deflate = new ZLibStream(output, CompressionLevel.Optimal, leaveOpen: true))
|
||||
deflate.Write(data, 0, data.Length);
|
||||
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static string EscapePdfString(string value) =>
|
||||
value.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
.Replace("(", "\\(", StringComparison.Ordinal)
|
||||
.Replace(")", "\\)", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class CoverSheetService : ICoverSheetService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ILogger<CoverSheetService> _logger;
|
||||
|
||||
public CoverSheetService(AppDbContext db, ILogger<CoverSheetService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<CoverSheet>> GenerateAsync(
|
||||
GenerateCoverSheetsRequest request, Guid actorUserId)
|
||||
{
|
||||
var count = Math.Clamp(request.Count, 1, 100);
|
||||
var batchType = BatchTypeExtensions.FromDbString(request.BatchType.ToUpperInvariant());
|
||||
var track = string.IsNullOrEmpty(request.Track)
|
||||
? BatchTrack.Backfill
|
||||
: BatchTrackExtensions.FromDbString(request.Track.ToUpperInvariant());
|
||||
|
||||
if (request.PatientId.HasValue)
|
||||
{
|
||||
var patientExists = await _db.Patients.AnyAsync(p => p.Id == request.PatientId.Value);
|
||||
if (!patientExists)
|
||||
throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
|
||||
}
|
||||
|
||||
if (request.AssignToUserId.HasValue)
|
||||
{
|
||||
var user = await _db.Users.FindAsync(request.AssignToUserId.Value);
|
||||
if (user is null || !user.IsActive)
|
||||
throw new NotFoundException("User not found or inactive.", "USER_NOT_FOUND");
|
||||
}
|
||||
|
||||
var sheets = new List<CoverSheet>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var code = $"VCR-CS-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
|
||||
sheets.Add(new CoverSheet
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = code,
|
||||
BatchType = batchType,
|
||||
Track = track,
|
||||
PatientId = request.PatientId,
|
||||
AssignToUserId = request.AssignToUserId,
|
||||
GeneratedByUserId = actorUserId,
|
||||
IsUsed = false,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
_db.CoverSheets.AddRange(sheets);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Generated {Count} cover sheets for batch type {BatchType}, track {Track}",
|
||||
count, batchType.ToDbString(), track.ToDbString());
|
||||
|
||||
return sheets;
|
||||
}
|
||||
|
||||
public async Task<CoverSheet?> LookupByCodeAsync(string code)
|
||||
{
|
||||
return await _db.CoverSheets
|
||||
.Include(c => c.Patient)
|
||||
.Include(c => c.AssignToUser)
|
||||
.FirstOrDefaultAsync(c => c.Code == code.ToUpperInvariant().Trim());
|
||||
}
|
||||
|
||||
public async Task<CoverSheet?> LookupByIdAsync(Guid id)
|
||||
{
|
||||
return await _db.CoverSheets
|
||||
.Include(c => c.Patient)
|
||||
.Include(c => c.AssignToUser)
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
}
|
||||
|
||||
public async Task<List<CoverSheet>> GetByIdsAsync(IReadOnlyList<Guid> ids)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
return new List<CoverSheet>();
|
||||
|
||||
var idSet = ids.Distinct().ToList();
|
||||
var sheets = await _db.CoverSheets
|
||||
.AsNoTracking()
|
||||
.Include(c => c.Patient)
|
||||
.Include(c => c.AssignToUser)
|
||||
.Where(c => idSet.Contains(c.Id))
|
||||
.ToListAsync();
|
||||
|
||||
var byId = sheets.ToDictionary(c => c.Id);
|
||||
return idSet
|
||||
.Where(byId.ContainsKey)
|
||||
.Select(id => byId[id])
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task RedeemAsync(Guid coverSheetId, Guid batchId)
|
||||
{
|
||||
var sheet = await _db.CoverSheets.FindAsync(coverSheetId);
|
||||
if (sheet is null)
|
||||
throw new NotFoundException("Cover sheet not found.", "COVER_SHEET_NOT_FOUND");
|
||||
|
||||
if (sheet.IsUsed)
|
||||
throw new ConflictException(
|
||||
$"Cover sheet {sheet.Code} has already been used for batch {sheet.BatchId}.",
|
||||
"COVER_SHEET_ALREADY_USED");
|
||||
|
||||
sheet.IsUsed = true;
|
||||
sheet.BatchId = batchId;
|
||||
sheet.UsedAt = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<List<CoverSheet>> ListAsync(
|
||||
bool? isUsed, Guid? patientId, int page, int pageSize)
|
||||
{
|
||||
var query = _db.CoverSheets
|
||||
.AsNoTracking()
|
||||
.Include(c => c.Patient)
|
||||
.Include(c => c.AssignToUser)
|
||||
.AsQueryable();
|
||||
|
||||
if (isUsed.HasValue)
|
||||
query = query.Where(c => c.IsUsed == isUsed.Value);
|
||||
if (patientId.HasValue)
|
||||
query = query.Where(c => c.PatientId == patientId.Value);
|
||||
|
||||
return await query
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
public interface ICoverSheetService
|
||||
{
|
||||
Task<List<CoverSheet>> GenerateAsync(GenerateCoverSheetsRequest request, Guid actorUserId);
|
||||
Task<CoverSheet?> LookupByCodeAsync(string code);
|
||||
Task<CoverSheet?> LookupByIdAsync(Guid id);
|
||||
Task<List<CoverSheet>> GetByIdsAsync(IReadOnlyList<Guid> ids);
|
||||
Task RedeemAsync(Guid coverSheetId, Guid batchId);
|
||||
Task<List<CoverSheet>> ListAsync(bool? isUsed, Guid? patientId, int page, int pageSize);
|
||||
}
|
||||
Reference in New Issue
Block a user